Compare commits

..

86 Commits

Author SHA1 Message Date
Joseph Doherty e3c0503a4f docs(sphistorianclient): mark RemoteGrpc (2023 R2) live-verified 2026-06-19 06:57:06 -04:00
Joseph Doherty a0527f9b5a fix(sphistorianclient): gRPC auth handshake uses StorageService.ValidateClientCredential
The RemoteGrpc orchestrator drove the SSPI/NTLM token loop through
HistoryService.ExchangeKey, which the 2023 R2 contract analysis shows is a
separate key-exchange/cert op — not the credential handshake. The server
rejected the NTLM Type-1 token at round 0. The Negotiate loop belongs on
StorageService.ValidateClientCredential (Handle/InBuff -> Status/OutBuff;
field names match the 2020 native contract). Live-verified end-to-end against
a 2023 R2 Historian (wonder-sql-vd03): SysTimeSec raw read returns correct
timestamped values.
2026-06-19 06:56:44 -04:00
Joseph Doherty 5f7d7e1b58 docs(sphistorianclient): document HISTORIAN_PORT env var; mark plan tasks complete 2026-06-19 06:09:43 -04:00
Joseph Doherty 78418346df build(sphistorianclient): pack 0.1.0 nupkg 2026-06-19 06:02:05 -04:00
Joseph Doherty 4920b89666 docs(sphistorianclient): correct retrieval-mode count (15) + EnsureTag verification scope 2026-06-19 06:01:07 -04:00
Joseph Doherty 989db9317d docs(sphistorianclient): add CLAUDE.md + README.md 2026-06-19 05:58:13 -04:00
Joseph Doherty 81bf7322f0 feat(sphistorianclient): add AddZbSpHistorianClient DI extension 2026-06-19 05:53:56 -04:00
Joseph Doherty 8033a7f12d fix(sphistorianclient): resolve port build/test fallout 2026-06-19 05:49:22 -04:00
Joseph Doherty 63cddfb65b feat(sphistorianclient): port SDK source + tests, rebrand namespace to ZB.MOM.WW.SPHistorianClient 2026-06-19 05:45:06 -04:00
Joseph Doherty 965f5006f2 feat(sphistorianclient): scaffold shared library skeleton (props, csprojs, slnx) 2026-06-19 05:40:10 -04:00
Joseph Doherty 294da8b2db docs(sphistorianclient): implementation plan + task tracking 2026-06-19 05:36:24 -04:00
Joseph Doherty bbb7942788 docs(sphistorianclient): approved design for ZB.MOM.WW.SPHistorianClient port 2026-06-19 05:29:51 -04:00
Joseph Doherty d5b134b117 docs: add MES + Delmia-DNC integration API/MXAccess specs
mes-delmia-integration-api.md: endpoints, request/response DTOs, and the MXAccess flag handshake for MESAPI (in-repo MesNotifier) and DelmiaIntegration (DNC Downloader.asmx -> WWNotifier /notify -> Galaxy $DelmiaReceiver). mesrec.md / nj.md: live Galaxy receiver + reactor attribute references.
2026-06-17 06:52:36 -04:00
Joseph Doherty eb8b44c29d loader: purge legacy driver in overlay namespace on teardown (self-heal nw-uns-modbus placeholder) 2026-06-08 07:07:22 -04:00
Joseph Doherty a6fa36043a loader: equipment is driver-less (drop Modbus placeholder, NULL DriverInstanceId) 2026-06-08 06:42:31 -04:00
Joseph Doherty 05a4a547f4 feat(loader): canonical EQ-+uuid EquipmentIds (passes OtOpcUa full DraftValidator); clean by UnsLine scope 2026-06-07 11:18:39 -04:00
Joseph Doherty 4d57e34ff3 docs(loader): record live-values verification + 396/1036 explanation for company overlay 2026-06-07 06:08:36 -04:00
Joseph Doherty b3d8990a0f fix(loader): keep empty folderPath distinct in vtag ids; dedupe verify args; readme wait-seconds 2026-06-07 05:07:00 -04:00
Joseph Doherty 5655b75fe6 feat(loader): company overlay as VirtualTags mirroring the galaxy mirror + verify --require-good 2026-06-07 04:59:51 -04:00
Joseph Doherty dce6f83488 loader: add populate-equipment (company-shape Equipment overlay) + scope verify-equipment
populate-equipment loads the Northwind Enterprise/Site/Area/Line/Equipment/Signal
shape from company-uns.json as a second Equipment-kind namespace (nw-uns) alongside
the galaxy mirror — 3 areas / 8 lines / 40 equipment / 1036 signals. Friendly
DisplayName, stable logical-Id NodeId. verify-equipment now scopes to the nw-area-*
overlay by default (--all for the whole tree). Verified live on :4840 against OtOpcUa
master's Equipment-namespace materialization (structure-only; leaves are
BadWaitingForInitialData). clean now drops the overlay too.
2026-06-06 16:19:53 -04:00
Joseph Doherty fd34e25cb1 feat(uns-loader): verify-equipment — recursive Equipment UNS tree browse + leaf count
browse_summary assumes the flat 2-level Galaxy hierarchy; the Equipment tree is deep
(Area/Line/Equipment/[FolderPath]/Signal). Add browse_tree (recursive leaf descent) + a
verify-equipment subcommand that reports/asserts the leaf signal count (--expect N), for
verifying OtOpcUa equipment-namespace structure materialisation. Smoke-tested against a live
:4840 (40 folders / 396 leaf signals).
2026-06-06 15:25:17 -04:00
Joseph Doherty eb26bf3248 Add Galaxy UNS artifacts + reloadable OtOpcUa loader tool
galaxy-hierarchy.json: full AVEVA Galaxy DEV hierarchy pulled live via the
MxGateway .NET client (129 objects, 14k attrs). company-uns.json/.tree.txt +
gen_uns.py: a fake-company (Northwind) ISA-95 UNS modeled on OtOpcUa's
Cluster->Namespace->Area->Line->Equipment->Tag schema, grounded in the 40
TestMachine instances. otopcua-uns-loader/: reloadable generate/populate/verify/
clean tool that recreates + verifies the galaxy mirror (396 live tags across 40
machines) in OtOpcUa's config DB after a rebuild.
2026-06-06 14:22:25 -04:00
Joseph Doherty e5a609be83 docs(theme): mark themeissues #6 resolved in 0.3.1
Interactive-render nav fix (CSS display:none-when-closed + nav-state.js
MutationObserver re-wire) shipped in 0.3.1 and verified — ScadaBridge Central UI
NavCollapseTests now pass. All six issues now resolved (5 fixed, 1 tradeoff).
2026-06-05 08:32:03 -04:00
Joseph Doherty f1efe6e081 fix(theme): 0.3.1 — interactive-render nav backstop (issue #6)
Under an interactive Blazor render mode the runtime replaces the prerendered
<details> after DOMContentLoaded, so nav-state.js (wired on load, re-run only on
'enhancedload') never wires the live rail — no aria sync, no persistence, no
active-reveal — and native <details> content-hiding is unreliable, leaving a
collapsed section's items visible. 0.3.1:
- nav-state.js: add a MutationObserver backstop that re-runs apply() when
  details.rail-section nodes are (re)inserted; idempotent via the per-element
  init guard, loop-safe (childList-only + active-reveal's !open guard).
- layout.css: explicit .rail-section:not([open]) > .rail-section-body{display:none}
  so visual collapse works across all render modes.
- themeissues.md: document issue #6; Directory.Build.props 0.3.0 -> 0.3.1.
48 bUnit tests green.
2026-06-05 07:18:30 -04:00
Joseph Doherty 0e41e7c2e4 fix(theme): resolve nav/login kit issues + bump 0.2.1 -> 0.3.0
Addresses ZB.MOM.WW.Theme/themeissues.md:
- #1 NavRailSection <summary> renders aria-expanded (SSR from Expanded),
  kept in sync by nav-state.js on restore + toggle.
- #2 nav-state.js auto-expands the section holding a.rail-link.active
  (transient via data-zbnav-transient — does not overwrite saved state).
- #3 nav-state.js re-applies on Blazor 'enhancedload' (idempotent via
  per-element init guard).
- #5 LoginCard wraps product in span.login-product + optional Heading
  override param.
- #4 documented as an accepted client-only-persistence tradeoff (no code change).

+4 bUnit tests (48 total, all green).
2026-06-05 04:42:24 -04:00
Joseph Doherty 5f97c9d1ed docs(glauth): point all dev/test LDAP at the shared GLAuth on 10.100.0.35
deployment.md / CLAUDE.md / env_vars.md: the per-app LDAP (scadabridge-ldap
container, OtOpcUa DevStubMode, per-box C:\publish\glauth) is replaced by one
shared zb-shared-glauth on 10.100.0.35:3893 (dc=zb,dc=local); source of truth
infra/glauth/. Fixed stale baseDNs (dc=lmxopcua/dc=otopcua -> dc=zb).
2026-06-04 16:37:52 -04:00
Joseph Doherty 9d373efbe0 docs(glauth): mark shared-GLAuth design implemented + all plan tasks complete 2026-06-04 16:21:13 -04:00
Joseph Doherty 4c0f1eaaf7 fix(glauth): rename OPC/Gw testers to avoid username/group case-collision
glauth exposes each group as cn=<Group> under ou=users, so a case-insensitive
(cn=x) search matched both the user and the group (2 entries -> the shared
ZB.MOM.WW.Auth.Ldap 'exactly one entry' rule failed the bind). Renamed the 4
colliding testers (readonly/writetune/alarmack/gwreader) + the 2 siblings for
consistency: opc-readonly/opc-writeop/opc-writetune/opc-writeconfig/opc-alarmack
and gw-viewer. Verified gw-viewer logs into the MxGateway dashboard as Viewer.
multi-role/admin/designer/etc. were never affected (no case-collision).
2026-06-04 16:19:33 -04:00
Joseph Doherty 0f2b2b8351 feat(glauth): merged shared dev GLAuth directory + compose + runbook (10.100.0.35)
Phase 0 of the shared-GLAuth standardization. config.toml = merged dc=zb,dc=local
directory (15 groups in partitioned 55xx/56xx/57xx families, 14 users incl.
multi-role spanning all groups, serviceaccount search account). compose runs one
glauth/glauth:latest on :3893. README is the deploy/verify runbook. Code-reviewed;
fixed scp -r idempotency in the deploy command (README + plan Task 4).
2026-06-04 15:45:41 -04:00
Joseph Doherty 5be0cec601 docs(glauth): implementation plan + tasks for shared GLAuth standardization
19 tasks across 5 phases: author scadaproj/infra/glauth/ (merged config + compose +
runbook) → deploy/verify on 10.100.0.35 (hard gate, access-prerequisite) → repoint
ScadaBridge (Mac), un-stub OtOpcUa docker-dev, repoint windev MxGateway + OtOpcUa →
retire old glauths → full cross-app verification. Co-located .tasks.json.
2026-06-04 15:37:06 -04:00
Joseph Doherty 106fb8b149 docs(glauth): shared GLAuth standardization design (dev/test consolidation onto 10.100.0.35)
Approved design: consolidate OtOpcUa, MxAccessGateway, ScadaBridge dev/test auth
onto one shared GLAuth at 10.100.0.35:3893 (dc=zb,dc=local, plaintext). App-neutral
source of truth in scadaproj/infra/glauth/; merged directory with gid families
partitioned 55xx/56xx/57xx + multi-role/admin/serviceaccount; per-app Server
repoints; incremental rollout keeping old glauths until verified.
2026-06-04 15:26:32 -04:00
Joseph Doherty b0fe7b15ca fix(theme): render app-shell on desktop Chromium via ::details-content (0.2.1)
Chromium >=121 wraps a <details>'s content in a generated ::details-content
box with content-visibility:hidden while closed. The SSR app-shell ships
closed (no JS) and hides its summary toggle at lg+, so on desktop the rail+page
were invisible and the flex-lg-row layout collapsed to a vertical stack.

Add '.app-shell::details-content { display: contents }' inside the lg+ media
query: dissolving the wrapper box reveals the content regardless of open state
and restores rail/page as direct flex children of .app-shell. Browsers without
::details-content support drop the invalid selector and fall back to the legacy
force-show. Mobile (<lg) and nested NavRailSection disclosures unaffected.

Bump 0.2.0 -> 0.2.1.
2026-06-04 10:23:05 -04:00
Joseph Doherty 3070169e5d docs(ui-theme): record post-adoption site.css prune + reconfirm 0.2.0 on feed
Audit follow-up: the deferred 'dead .sidebar/.nav-link residual' was broader than
logged (OtOpcUa's site.css duplicated and overrode the whole kit shell). Pruned
across all 3 apps on chore/theme-css-prune branches (-167/-95/-106 lines, builds
clean). Note the remaining deferred items (kit layout.css calc review; ScadaBridge
Host transitive kit ref) and reconfirm the Theme 0.2.0 publish is genuine.
2026-06-03 04:38:24 -04:00
Joseph Doherty ea4116cc5b docs(ui-theme): mark merged to local default + pushed to origin (in sync) 2026-06-03 04:15:20 -04:00
Joseph Doherty ca21615090 docs(ui-theme): record 0.2.0 publish + adoption across all 3 apps (local feat branches) 2026-06-03 04:06:20 -04:00
Joseph Doherty a474eb6bd6 chore(theme): bump 0.1.0 -> 0.2.0 (nav persistence + ThemeScripts) 2026-06-03 02:59:27 -04:00
Joseph Doherty 9e4dedc987 fix(theme): guard nav-state.js against duplicate toggle listeners 2026-06-03 02:58:34 -04:00
Joseph Doherty 6aa2ee8095 fix(theme): null/whitespace-safe NavRailSection slug + edge tests 2026-06-03 02:57:07 -04:00
Joseph Doherty e2749b7d69 feat(theme): ThemeScripts + localStorage nav-state enhancer 2026-06-03 02:55:35 -04:00
Joseph Doherty edd49765d6 feat(theme): NavRailSection data-nav-key for persistence 2026-06-03 02:53:15 -04:00
Joseph Doherty 7e11f9aac8 docs(ui-theme): implementation plan + task graph (26 tasks, Phases 0-4) 2026-06-03 02:50:31 -04:00
Joseph Doherty e6e9dbfedb docs(ui-theme): approved adoption design (publish 0.2.0 + full canonical cutover across 3 apps) 2026-06-03 02:35:00 -04:00
Joseph Doherty 6d262f7d7c docs: Auth+Audit normalization PUSHED to origin (gitea) 2026-06-03 — default branches in sync; feat/* kept locally 2026-06-03 00:36:55 -04:00
Joseph Doherty 4b90ebb588 docs: reflect final delivery — Auth+Audit normalization merged to each repo's LOCAL default (main/master) 2026-06-03, NOT pushed (origin untouched), feat/* branches kept 2026-06-03 00:31:07 -04:00
Joseph Doherty 4de61d29f5 docs: PROGRAM COMPLETE — Auth+Audit normalization adopted across all 3 repos (Phases 0-3); mark exit-gate (CLAUDE.md Auth/Audit rows + components/{auth,audit}/GAPS.md adopted, local-only/not-pushed); tasks #10/#30/#31 done 2026-06-02 15:42:23 -04:00
Joseph Doherty 1ec057a32a plan: Task 2.5 (ScadaBridge audit full re-arch C1-C7) DONE+reviewed -> PHASE 2 COMPLETE (audit adopted across all 3 repos, deep/canonical, local-only). Next = Phase 3 Actor->principal wiring 2026-06-02 15:10:54 -04:00
Joseph Doherty a591a9fb47 plan(2.5): ScadaBridge audit C5 done+reviewed (central migration, MSSQL-verified); C6 subsumed (consumer surfaces already canonical via C3 shims); C7 (perf re-baseline + cleanup) in progress 2026-06-02 14:24:32 -04:00
Joseph Doherty e9100d0b74 plan(2.5): ScadaBridge audit C4 done+reviewed (site sidecar); C5 (central migration) in progress 2026-06-02 13:34:12 -04:00
Joseph Doherty 672ac5ff04 plan(2.5): ScadaBridge audit C3 done+reviewed (record swap keystone); C4 (site sidecar) in progress 2026-06-02 13:07:32 -04:00
Joseph Doherty f073241f52 plan(2.5): ScadaBridge audit re-arch C1+C2 done (reviewed); C3 (atomic record swap) in progress 2026-06-02 11:54:57 -04:00
Joseph Doherty 98e957903f plan(2.5): ScadaBridge audit full-rearch design + C1-C7 decomposition (sidecar forwarding, new-table-copy central migration, persisted computed cols, canonical record everywhere) 2026-06-02 10:36:00 -04:00
Joseph Doherty ca2a9ac507 plan(phase2): OtOpcUa 2.1/2.2 + MxGateway 2.3 DONE (deep audit adoption, spec+code reviewed, local-only); ScadaBridge 2.5 pending variant decision 2026-06-02 10:26:55 -04:00
Joseph Doherty abe06a2163 plan(phase2): Task 2.0 gate DONE — verified plan specs materially off (MxGw store moved to lib, OtOpcUa path dormant, SB rename structurally impossible); user chose DEEP adopt + pause; corrected deep design in -phase2-deep.md; PAUSED for review 2026-06-02 09:13:09 -04:00
Joseph Doherty 95681ac0b2 plan(phase1): Tasks 1.5/1.6/1.7 done+reviewed — PHASE 1 COMPLETE across all 3 repos (claims/cookies, dev base DN dc=zb, canonical-six roles + SB SoD collapse + config-DB migrations); next = Phase 2 audit 2026-06-02 08:15:46 -04:00
Joseph Doherty d73762bf76 plan(phase1): ScadaBridge re-arch C5 done+reviewed; Task 1.3 (ApiKeys adopt) COMPLETE across all 3 repos; installer/secret catch noted 2026-06-02 05:51:10 -04:00
Joseph Doherty 02a84b074a plan(phase1): ScadaBridge re-arch C4 done+reviewed (TransportExport excludes keys); C5 (retire entity) next 2026-06-02 05:17:09 -04:00
Joseph Doherty 9b5535ea47 plan(phase1): ScadaBridge re-arch C3 done+reviewed (CentralUI onto seam); C4 next 2026-06-02 04:50:09 -04:00
Joseph Doherty 406ede19dd plan(phase1): ScadaBridge re-arch C2 done+reviewed (mgmt+CLI onto seam); C3 next 2026-06-02 04:25:02 -04:00
Joseph Doherty ba7b38a654 plan(phase1): ScadaBridge re-arch C1 done+reviewed; 2 pre-existing Host.Tests baseline reds fixed; C2 next 2026-06-02 04:03:31 -04:00
Joseph Doherty e69e9c635b plan(phase1): ScadaBridge re-arch discovered architecture (CentralUI direct-repo + TransportExport) + C1-C5 decomposition + transport=exclude-keys 2026-06-02 03:22:19 -04:00
Joseph Doherty a4f9968917 plan(phase1): Auth lib 0.1.3 published (SetScopes/SetEnabled); ScadaBridge re-arch C mapping 2026-06-02 03:14:29 -04:00
Joseph Doherty 290e85cb38 test(auth.apikeys): store-level arg guards + SetEnabledAsync idempotence (review M1/M2) 2026-06-02 03:12:24 -04:00
Joseph Doherty 468959ca8a feat(auth.apikeys): add IApiKeyAdminStore.SetScopesAsync + SetEnabledAsync (editable scopes + reversible enable, no schema change); bump 0.1.3 2026-06-02 03:08:19 -04:00
Joseph Doherty 30c60f9d5f plan(phase1): SB ApiKeys A+B foundation done+reviewed; C/D/E pending 2026-06-02 02:50:57 -04:00
Joseph Doherty d30cdea487 plan(phase1): ScadaBridge ApiKeys full-adopt re-arch spec + sub-task decomposition 2026-06-02 02:29:03 -04:00
Joseph Doherty f2b73367d5 plan(phase1): MxGateway 1.3 done+approved (lib 0.1.2); ScadaBridge 1.3 pending 2026-06-02 02:14:45 -04:00
Joseph Doherty da669bfc9b fix(auth.apikeys): stamp schema version 2 to match donor gateway DBs; bump 0.1.2
The store was extracted from MxAccessGateway, whose deployed gateway-auth.db
is at schema_version=2. The library capped at 1 and threw on a newer on-disk
version -> gateway would fail to boot. Final schema is byte-identical since v1;
stamp 2 so existing deployed DBs interoperate (no key re-issuance). +2 tests.
2026-06-02 01:45:57 -04:00
Joseph Doherty 2d50d5dcf0 plan(phase1): 1.2/1.4 done across 3 repos (lib 0.1.1); remaining 1.3/1.5-1.7 2026-06-02 01:38:50 -04:00
Joseph Doherty aecc106657 fix(auth.ldap): skip LdapOptionsValidator when Enabled=false; bump 0.1.1
A disabled LDAP provider's connection fields are inert — don't require
Server/SearchBase/ServiceAccountDn at startup when Enabled=false. Surfaced
by the MxGateway 1.2 review (dashboard LDAP can be disabled). +1 test.
2026-06-02 01:17:53 -04:00
Joseph Doherty 0586e64f64 plan(phase1): record Task 1.2 review findings + LdapOptionsValidator 0.1.1 question 2026-06-02 01:12:20 -04:00
Joseph Doherty 37c03e5fc2 plan(phase1): note Roles sub-namespace; Task 1.1 done+approved (3 repos) 2026-06-02 00:34:13 -04:00
Joseph Doherty bea08f9673 plan(phase1): lock resolved decisions (SB ApiKeys full adopt, roles, dev hatches) 2026-06-02 00:25:53 -04:00
Joseph Doherty 32fd953969 plan(phase1): Task 1.0 exploration findings + elaborated Auth cutover
Per-app cutover steps mapped to the library surface; flags 5 findings that
change the plan (OtOpcUa section is Security:Ldap not Authentication:Ldap;
singleton 'bug' already mitigated; ScadaBridge inbound API keys are a
re-architecture not a reformat; OtOpcUa config+DB mapping + DevStubMode +
2nd LDAP consumer; MxGateway ApiKeys is the low-risk donor path).
2026-06-02 00:24:03 -04:00
Joseph Doherty c715565bd2 build(audit): add Gitea push.sh mirroring Auth's 2026-06-02 00:13:24 -04:00
Joseph Doherty f98fa84e4a plan: implementation plan + task graph for Auth+Audit normalization
Phase 0 command-exact (publish + feed-map); Phases 1-3 decomposed into
bite-sized cutover tasks with files-to-edit contracts, classification,
parallelizability, and per-phase explore/elaborate gates. Co-located
.tasks.json mirrors native tasks #7-#31.
2026-06-02 00:11:48 -04:00
Joseph Doherty 6ec1ea7d65 docs: design for full Auth+Audit normalization across 3 sister projects
Approved brainstorming output: two-library program (publish + adopt
ZB.MOM.WW.Auth then ZB.MOM.WW.Audit across OtOpcUa, MxAccessGateway,
ScadaBridge), library-major waterfall, ending with audit Actor wired
from the Auth principal. Local-only delivery; verified feed/source state.
2026-06-02 00:04:33 -04:00
Joseph Doherty c3ab37523a docs: record ZB.MOM.WW.Configuration fleet-wide adoption + add design/plan
Configuration is now adopted across all three sister apps (local branches),
so flip the status lines in CLAUDE.md, components/configuration/GAPS.md, and the
lib README/CLAUDE.md from 'not adopted' to adopted (also corrects 27->42 tests).
Adds the brainstorm design doc + bite-sized implementation plan (+tasks.json)
under docs/plans/ that drove the adoption.
2026-06-01 23:18:02 -04:00
Joseph Doherty 2f124fa02c docs(observability): record telemetry follow-ons DONE (metric normalization, ScadaBridge instruments, OTLP opt-in, site metrics listener, Serilog alignment) 2026-06-01 17:16:46 -04:00
Joseph Doherty 6c2a43a238 docs: plan for ZB.MOM.WW.Telemetry follow-ons (A additive/hygiene, B metric normalization, C ScadaBridge instruments, D OTLP opt-in) 2026-06-01 16:32:57 -04:00
Joseph Doherty dee55aadc6 docs(observability): record ZB.MOM.WW.Telemetry adoption across 3 apps; correct false MxGateway logging-status claim
All 3 apps adopted on branch feat/adopt-zb-telemetry (behaviour-preserving).
Records the per-repo result + accepted scope deviations (ScadaBridge keeps
LoggerConfigurationFactory + TraceContextEnricher instead of AddZbSerilog;
MxGateway keeps GatewayLogScope, exposes redaction via ILogRedactor seam) and
deferred follow-ons (#6 ms->s, #7 meter rename, #9 app instruments, OTLP, and
the new ScadaBridge Site-node HTTP/1.1 metrics-listener item). Corrects the
prior false 'MxGateway logging adopted on its own branch' claim — that migration
actually landed in this pass.
2026-06-01 15:58:10 -04:00
Joseph Doherty 30425726d4 docs: implementation plan for ZB.MOM.WW.Telemetry adoption across the 3 sister apps
13 tasks: Task 0 publishes/verifies the 2 nupkgs on Gitea (gates all); then 3
independent per-repo phases — OtOpcUa (1-3), ScadaBridge (4-6), MxGateway (7-11,
incl. the high-risk MEL->Serilog swap) — and Task 12 scadaproj bookkeeping last.
Records two behaviour-preserving refinements vs the design: ScadaBridge keeps
LoggerConfigurationFactory (+TraceContextEnricher) instead of AddZbSerilog, and
MxGateway keeps GatewayLogScope as-is. Breaking items #6/#7 deferred.
2026-06-01 15:24:28 -04:00
Joseph Doherty 3729ff2152 docs: design for ZB.MOM.WW.Telemetry adoption across the 3 sister apps
Second cross-fleet shared-library adoption (after Health). Full scope:
AddZbTelemetry (OTel Resource identity triple + standard instrumentation +
Prometheus /metrics) on all 3, plus shared Serilog on all 3 — including the
MxGateway MEL->Serilog migration. Records the correction that MxGateway's
logging was NOT actually adopted on main despite the docs' claim. Behaviour-
preserving bar; breaking items (#6 unit, #7 rename) deferred.
2026-06-01 15:11:50 -04:00
Joseph Doherty 19f7ea5eeb docs(health): record ZB.MOM.WW.Health adoption across 3 apps + deferrals + accepted /health/active startup behaviour change 2026-06-01 13:50:09 -04:00
Joseph Doherty 1e91784ba3 docs(health-plan): publish done; fix source-mapping (two patterns); note user-level creds 2026-06-01 13:23:46 -04:00
Joseph Doherty 5a965639f9 docs: implementation plan for ZB.MOM.WW.Health adoption across the 3 sister apps
Detailed task-by-task plan (publish to Gitea, then per-repo behaviour-preserving
probe swaps) incorporating recon findings that revised the design: MxGateway worker
IPC is named pipes (custom SQLite readiness probe instead of gRPC), ScadaBridge
ActorSystem is not in DI (transient bridge), downstream gRPC probes + IDbContextFactory
switch + ScadaBridge seam unification deferred.
2026-06-01 13:15:48 -04:00
Joseph Doherty f72403d6f0 docs: design for ZB.MOM.WW.Health adoption across the 3 sister apps
Plan to integrate the built-but-unadopted Health library into OtOpcUa,
MxAccessGateway, and ScadaBridge: Gitea-registry distribution, per-repo
behaviour-preserving probe swaps (preset-based), canonical tiers + writer,
MxGateway-first sequencing.
2026-06-01 13:01:36 -04:00
193 changed files with 156457 additions and 72 deletions
+57 -17
View File
@@ -120,12 +120,12 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| Auth (login / identity / authz) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Auth (login / identity / authz) | Adopted (lib `0.1.3`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| UI Theme (layout / tokens / components) | Adopted (lib `0.2.0`; all 3 apps, merged to **local default** + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
| Config + validation (options / startup validation) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
| Audit (event model + writer seam) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
| Audit (event model + writer seam) | Adopted (lib `0.1.0`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
@@ -137,7 +137,14 @@ The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Auth/`](ZB
(its own nested git repo; .NET 10; 4 packages — `Abstractions`, `Ldap`, `ApiKeys`, `AspNetCore`;
172 tests; `dotnet pack` → 4 nupkgs @ 0.1.0). The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md).
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/auth/GAPS.md`](components/auth/GAPS.md) (#8).
**Adopted across all three apps on 2026-06-02** (auth GAPS #1#8) on each repo's `feat/adopt-zb-auth` branch —
committed + reviewed, then **fast-forward-merged into the repo's local default (main/master) and PUSHED to origin
(gitea) on 2026-06-03** (in sync; the `feat/*` branches kept locally as history). Cutover: shared `Auth.Ldap`,
`Auth.ApiKeys` (ScadaBridge inbound fully re-architected to the keyId/Bearer model), `IGroupRoleMapper<TRole>` seam,
`Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the
canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: OtOpcUa `0.1.1`,
MxGateway `0.1.2`, ScadaBridge `0.1.3`. Per-repo detail in [`components/auth/GAPS.md`](components/auth/GAPS.md) +
`docs/plans/2026-06-02-auth-audit-normalization*.md`.
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa).
@@ -149,10 +156,18 @@ backlog. Shared = Technical-Light tokens + IBM Plex fonts + side-rail shell + wi
per-project = each app's `site.css` page layout, route content, scoped `.razor.css`.
The shared RCL is **built and lives in this repo** at [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/)
(.NET 10 Razor Class Library; single package; 32 bUnit tests; `dotnet pack` → 1 nupkg @ 0.1.0).
The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md).
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
(.NET 10 Razor Class Library; single package; 44 bUnit tests; `dotnet pack` → 1 nupkg @ 0.2.0,
**published to the Gitea feed**). The build plan is at
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md);
the adoption plan at [`docs/plans/2026-06-03-ui-theme-adoption.md`](docs/plans/2026-06-03-ui-theme-adoption.md).
**Adopted across all three apps on 2026-06-03** (full canonical cutover, SPEC §7) on each repo's
`feat/adopt-zb-theme` branch — committed + spec/code-reviewed, then **fast-forward-merged into each repo's local
default (master/main) and PUSHED to origin (gitea)** (in sync; `feat/*` kept locally as history): OtOpcUa
`lmxopcua` `master`@`11de14d`, ScadaBridge `main`@`58352a6`, MxGateway→`mxaccessgw` `main`@`73e54e2`. The `0.1.0 → 0.2.0` bump first promoted nav-expand persistence
into the kit (`NavRailSection.Key`/`data-nav-key` + a localStorage `nav-state.js` enhancer emitted by a new
`<ThemeScripts/>`), so all three apps share one persistence mechanism (OtOpcUa's bespoke cookie/JS-interop nav
island retired); MxGateway additionally gained a net-new Blazor `<LoginCard>` `/login` page over its existing
hardened endpoint. Per-app result in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
Build/test from `ZB.MOM.WW.Theme/`: `dotnet test`. Consumer matrix: all three apps consume
the single `ZB.MOM.WW.Theme` package (OtOpcUa AdminUI, MxGateway Server, ScadaBridge Host + CentralUI).
@@ -183,9 +198,14 @@ enrichers, and redaction policies.
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/)
(.NET 10; 2 packages — `ZB.MOM.WW.Telemetry`, `ZB.MOM.WW.Telemetry.Serilog`; 19 tests;
`dotnet pack` → 2 nupkgs @ 0.1.0). **MxAccessGateway logging adopted** (MEL → Serilog migration done on
its own branch) — the one in-pass adoption. Broader OtOpcUa and ScadaBridge telemetry adoption is
follow-on, tracked in [`components/observability/GAPS.md`](components/observability/GAPS.md).
`dotnet pack` → 2 nupkgs @ 0.1.0). **Adopted across all three apps on 2026-06-01** (branch
`feat/adopt-zb-telemetry` per repo, behaviour-preserving): `AddZbTelemetry` (Resource + standard
instrumentation + Prometheus `/metrics`) everywhere; OtOpcUa + MxGateway on `AddZbSerilog` (MxGateway's
MEL→Serilog migration + metrics export both landed in this pass — they were *not* actually done
beforehand despite an earlier claim); ScadaBridge keeps its `LoggerConfigurationFactory` (min-level
governance) and only adds the shared `TraceContextEnricher`. Deferred: MxGateway `ms``s` + Meter
rename, ScadaBridge app instruments + Site-node HTTP/1.1 metrics listener, OTLP wiring. Per-repo
result tracked in [`components/observability/GAPS.md`](components/observability/GAPS.md).
Build/test from `ZB.MOM.WW.Telemetry/`: `dotnet test`. Consumer matrix: all three apps consume both
packages after adoption (OtOpcUa, MxGateway Server, ScadaBridge Host + any instrumented project).
@@ -203,7 +223,12 @@ The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Configurat
(.NET 10; single package `ZB.MOM.WW.Configuration`; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0).
The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md).
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/configuration/GAPS.md`](components/configuration/GAPS.md).
**Adopted across all three apps on 2026-06-01** (OtOpcUa, MxAccessGateway, ScadaBridge) on each repo's
local default branch (`main`/`master`) — merged, **not yet pushed** to remotes; the package was first
published to the Gitea feed. Behaviour-preserving onto `OptionsValidatorBase`/`AddValidatedOptions`
for MxGateway + ScadaBridge (validator messages byte-identical), `StartupValidator``ConfigPreflight`
for ScadaBridge, and net-new `Ldap`/`OpcUa` validators for OtOpcUa. Per-app result tracked in
[`components/configuration/GAPS.md`](components/configuration/GAPS.md).
Build/test from `ZB.MOM.WW.Configuration/`: `dotnet test`. Consumer matrix: all three apps consume the
single package; ScadaBridge is the heaviest adopter (per-module validators + `StartupValidator`
`ConfigPreflight`); OtOpcUa adoption is additive (it has no `IValidateOptions` usage today).
@@ -221,10 +246,20 @@ principal. `IAuditRedactor` is aligned with Telemetry's `ILogRedactor` seam conv
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/)
(.NET 10; 1 package — `ZB.MOM.WW.Audit`; only non-BCL dependency `Microsoft.Extensions.DependencyInjection.Abstractions`;
19 tests; `dotnet pack` → 1 nupkg @ 0.1.0). Repo: `https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit`.
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/audit/GAPS.md`](components/audit/GAPS.md).
**Adopted across all three apps on 2026-06-02** (audit GAPS #1#6) on each repo's `feat/adopt-zb-audit` branch
(stacked on `feat/adopt-zb-auth`) — committed + reviewed, then **merged into the repo's local default (main/master)
and PUSHED to origin (gitea) on 2026-06-03** (in sync). Depth =
**DEEP adopt** (the canonical 9-field `AuditEvent` is the record everywhere; domain fields ride in `DetailsJson`).
OtOpcUa: canonical record + `AuditWriterActor : IAuditWriter` + `Outcome` column/migration + `ClusterAudit` fix.
MxGateway: new canonical SQLite `audit_event` store + `IAuditWriter` + `IApiKeyAuditStore`→canonical adapter.
**ScadaBridge: a full audit-subsystem re-architecture** (the program's largest task) — canonical record everywhere via a
deterministic codec; site SQLite split into `audit_event` + an `audit_forward_state` forwarding sidecar; central
partitioned `dbo.AuditLog` collapsed to 10 canonical cols + persisted computed cols (`CollapseAuditLogToCanonical`
migration, MSSQL-verified). Phase 3 wires `Actor` from the Auth principal at authenticated emit sites (per-app
`IAuditActorAccessor`). Per-repo detail in [`components/audit/GAPS.md`](components/audit/GAPS.md) +
`docs/plans/2026-06-02-auth-audit-normalization-phase2-deep.md` + `…-scadabridge-audit-rearch.md`.
Build/test from `ZB.MOM.WW.Audit/`: `dotnet test`. Consumer matrix: all three apps consume the single
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge each map their own audit record/seam
onto the canonical type at the emit boundary).
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge — DEEP-adopted as the canonical record).
## Per-project primary commands
@@ -246,9 +281,14 @@ dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
# ScadaBridge (~/Desktop/ScadaBridge)
dotnet build ZB.MOM.WW.ScadaBridge.slnx
bash docker/deploy.sh # rebuild + redeploy the 8-node cluster
cd infra && docker compose up -d # local test services (LDAP, SQL, OPC UA, SMTP, REST, Traefik)
cd infra && docker compose up -d # local test services (SQL, OPC UA, SMTP, REST, Traefik) — LDAP is NOT here
```
> **Shared GLAuth (all three apps):** LDAP auth for every local dev/test stack is provided by a
> single `zb-shared-glauth` container on the Linux fixture host **`10.100.0.35:3893`**
> (`baseDN dc=zb,dc=local`, Transport=None). Source of truth and deploy runbook:
> [`scadaproj/infra/glauth/`](infra/glauth/) (`config.toml` + `docker-compose.yml` + `README.md`).
## Refreshing this index
This file is meant to be re-scanned when `scadaproj` is opened in Claude Code:
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# push.sh — pack and push the ZB.MOM.WW.Audit NuGet package to the Gitea feed.
#
# Required environment variables:
# GITEA_NUGET_SOURCE — full URL of the Gitea NuGet feed
# e.g. https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json
# GITEA_NUGET_KEY — Gitea access token with package:write permission
#
# Usage:
# export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
# export GITEA_NUGET_KEY="your-gitea-token"
# ./build/push.sh
set -euo pipefail
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
dotnet pack -c Release -o ./artifacts
dotnet nuget push "./artifacts/*.nupkg" \
--source "$GITEA_NUGET_SOURCE" \
--api-key "$GITEA_NUGET_KEY" \
--skip-duplicate
+1 -1
View File
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<Version>0.1.3</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
@@ -55,6 +55,12 @@ public interface IApiKeyAdminStore
Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct);
Task<bool> DeleteAsync(string keyId, CancellationToken ct);
/// <summary>Replaces the scope set on an existing key. Does not touch the secret. Returns false if the key does not exist.</summary>
Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct);
/// <summary>Enables (clears revoked_utc) or disables (sets revoked_utc) a key WITHOUT changing its secret. Returns false if the key does not exist.</summary>
Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct);
/// <summary>
/// Enumerates all API keys as hash-free <see cref="ApiKeyListItem"/> projections, newest first.
/// The secret hash is never selected, so callers cannot use this to recover secret material.
@@ -187,6 +187,53 @@ public sealed class ApiKeyAdminCommands
return new KeyActionResult(deleted, status);
}
/// <summary>
/// set-scopes: replaces the scope set on an existing key WITHOUT touching its secret, and
/// appends a <c>set-scopes</c> audit entry. Only the scope count is recorded in the audit
/// details — the scope values themselves are not logged verbatim.
/// All attempts are audited, including failures (key not found) — this is intentional to
/// maintain a complete security trail.
/// </summary>
public async Task<KeyActionResult> SetScopesAsync(
string keyId, IReadOnlySet<string> scopes, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentNullException.ThrowIfNull(scopes);
bool updated = await _adminStore.SetScopesAsync(keyId, scopes, ct).ConfigureAwait(false);
string status = updated ? "scopes-set" : "not-found";
// Record only the count, never the scope contents, to avoid leaking authority detail into audit.
await AppendAuditAsync(keyId, "set-scopes", remoteAddress, $"{status}; count={scopes.Count}", ct)
.ConfigureAwait(false);
return new KeyActionResult(updated, status);
}
/// <summary>
/// enable-key / disable-key: reversibly toggles a key's active state WITHOUT changing its
/// secret, and appends an <c>enable-key</c> (when enabling) or <c>disable-key</c> (when
/// disabling) audit entry.
/// All attempts are audited, including failures (key not found) — this is intentional to
/// maintain a complete security trail.
/// </summary>
public async Task<KeyActionResult> SetEnabledAsync(
string keyId, bool enabled, string? remoteAddress, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
DateTimeOffset now = _clock.GetUtcNow();
bool updated = await _adminStore.SetEnabledAsync(keyId, enabled, now, ct).ConfigureAwait(false);
string eventType = enabled ? "enable-key" : "disable-key";
string status = updated
? (enabled ? "enabled" : "disabled")
: "not-found";
await AppendAuditAsync(keyId, eventType, remoteAddress, status, ct).ConfigureAwait(false);
return new KeyActionResult(updated, status);
}
private string RequirePepper()
{
string? pepper = _pepperProvider.GetPepper();
@@ -4,7 +4,8 @@ using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// <summary>
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete).
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete,
/// set-scopes, enable/disable).
/// </summary>
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
{
@@ -85,6 +86,67 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentNullException.ThrowIfNull(scopes);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET scopes = $scopes
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(scopes));
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
// Reversible toggle: NO `revoked_utc IS NULL` guard (unlike RevokeAsync), so it works
// regardless of current state. Deliberately leaves secret_hash and last_used_utc untouched
// — that is what distinguishes re-enable from RotateAsync.
if (enabled)
{
command.CommandText = """
UPDATE api_keys
SET revoked_utc = NULL
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
}
else
{
command.CommandText = """
UPDATE api_keys
SET revoked_utc = $revoked_utc
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O"));
}
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string keyId, CancellationToken ct)
{
@@ -5,8 +5,15 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
/// </summary>
public static class SqliteAuthSchema
{
/// <summary>The schema version this build creates and supports.</summary>
public const int CurrentVersion = 1;
/// <summary>
/// The schema version this build creates and supports. This is <c>2</c>, not <c>1</c>,
/// to match the deployed databases of the donor (MxAccessGateway) this store was
/// extracted from: that store reached its final shape via a v1→v2 history and stamps
/// <c>version = 2</c> on disk. The final schema has been byte-identical since v1, so a
/// single-shot create stamped as 2 interoperates with existing <c>gateway-auth.db</c>
/// files (the migrator only refuses an on-disk version <em>newer</em> than this).
/// </summary>
public const int CurrentVersion = 2;
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
public const string SchemaVersionTable = "schema_version";
@@ -35,7 +35,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
}
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await ApplySchemaAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
@@ -78,7 +78,10 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
}
private static async Task ApplyVersionOneAsync(
// Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is
// idempotent against an already-provisioned database). The applied version is stamped
// separately by WriteSchemaVersionAsync.
private static async Task ApplySchemaAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
@@ -9,7 +9,9 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// low-level error on the first real login attempt.
/// </summary>
/// <remarks>
/// Four conditions are enforced:
/// Validation is skipped entirely when <see cref="LdapOptions.Enabled"/> is <c>false</c>
/// (a disabled provider's connection fields are inert). When enabled, four conditions
/// are enforced:
/// <list type="bullet">
/// <item>plaintext transport (<see cref="LdapTransport.None"/>) is rejected unless
/// <see cref="LdapOptions.AllowInsecure"/> is explicitly set (dev/test only);</item>
@@ -27,6 +29,14 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
{
ArgumentNullException.ThrowIfNull(options);
// When LDAP is disabled, its connection fields are inert — do not require them.
// A consumer that turns LDAP off should not have to supply a server/search-base/
// service-account just to satisfy startup validation.
if (!options.Enabled)
{
return ValidateOptionsResult.Success;
}
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
{
return ValidateOptionsResult.Fail(
@@ -292,6 +292,59 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
Assert.Equal(auditCountBefore, auditCountAfter);
}
// --- set-scopes / enable-disable ---
[Fact]
public async Task SetEnabledAsync_And_SetScopesAsync_AppendAuditEntries()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
await commands.CreateKeyAsync(
"key-1",
"Service A",
new HashSet<string>(["read"], StringComparer.Ordinal),
null,
null,
CancellationToken.None);
// Disable, then re-enable, then replace scopes.
KeyActionResult disabled =
await commands.SetEnabledAsync("key-1", enabled: false, "10.0.0.1", CancellationToken.None);
Assert.True(disabled.Succeeded);
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
KeyActionResult enabled =
await commands.SetEnabledAsync("key-1", enabled: true, "10.0.0.1", CancellationToken.None);
Assert.True(enabled.Succeeded);
Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
KeyActionResult scoped = await commands.SetScopesAsync(
"key-1",
new HashSet<string>(["read", "write"], StringComparer.Ordinal),
"10.0.0.1",
CancellationToken.None);
Assert.True(scoped.Succeeded);
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
Assert.Single(recent, e => e.EventType == "disable-key");
Assert.Single(recent, e => e.EventType == "enable-key");
Assert.Single(recent, e => e.EventType == "set-scopes");
IReadOnlyList<ApiKeyListItem> listed = await commands.ListKeysAsync(CancellationToken.None);
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["read", "write"], StringComparer.Ordinal)));
}
[Fact]
public async Task SetScopesAsync_NullScopes_Throws()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
commands.SetScopesAsync("key-1", null!, null, CancellationToken.None));
}
// --- delete-key ---
[Fact]
@@ -105,6 +105,87 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
Assert.False(result);
}
// --- SetScopes ---
[Fact]
public async Task SetScopesAsync_ReplacesScopes_AndReturnsTrue()
{
await _admin.CreateAsync(
SampleRecord("key-1") with { Scopes = new HashSet<string>(["a"], StringComparer.Ordinal) },
CancellationToken.None);
bool result = await _admin.SetScopesAsync(
"key-1",
new HashSet<string>(["b", "c"], StringComparer.Ordinal),
CancellationToken.None);
Assert.True(result);
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["b", "c"], StringComparer.Ordinal)));
}
[Fact]
public async Task SetScopesAsync_UnknownKey_ReturnsFalse()
{
bool result = await _admin.SetScopesAsync(
"missing",
new HashSet<string>(["b"], StringComparer.Ordinal),
CancellationToken.None);
Assert.False(result);
}
// --- SetEnabled ---
[Fact]
public async Task SetEnabledAsync_False_DisablesKey()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, when, CancellationToken.None);
Assert.True(result);
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.Equal(when, found!.RevokedUtc);
}
[Fact]
public async Task SetEnabledAsync_True_ReenablesKey_WithoutChangingSecret()
{
ApiKeyRecord original = SampleRecord("key-1");
await _admin.CreateAsync(original, CancellationToken.None);
// Record some usage so we can prove last_used_utc is left untouched on re-enable.
var used = new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero);
await _read.MarkUsedAsync("key-1", used, CancellationToken.None);
// Disable, then re-enable.
await _admin.SetEnabledAsync(
"key-1", enabled: false, new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
bool result = await _admin.SetEnabledAsync(
"key-1", enabled: true, new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
Assert.True(result);
// Active again, and the secret hash + last-used timestamp are unchanged.
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(active);
Assert.True(active!.SecretHash.SequenceEqual(original.SecretHash));
Assert.Null(active.RevokedUtc);
Assert.Equal(used, active.LastUsedUtc);
}
[Fact]
public async Task SetEnabledAsync_UnknownKey_ReturnsFalse()
{
bool result = await _admin.SetEnabledAsync(
"missing", enabled: false, DateTimeOffset.UtcNow, CancellationToken.None);
Assert.False(result);
}
// --- Delete ---
[Fact]
@@ -172,6 +253,73 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
() => _admin.DeleteAsync(keyId!, CancellationToken.None));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task SetScopesAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _admin.SetScopesAsync(
keyId!,
new HashSet<string>(["read"], StringComparer.Ordinal),
CancellationToken.None));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task SetEnabledAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
{
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _admin.SetEnabledAsync(keyId!, enabled: false, DateTimeOffset.UtcNow, CancellationToken.None));
}
[Fact]
public async Task SetScopesAsync_NullScopes_ThrowsArgumentNullException()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
await Assert.ThrowsAsync<ArgumentNullException>(
() => _admin.SetScopesAsync("key-1", null!, CancellationToken.None));
}
// --- SetEnabled idempotence ---
[Fact]
public async Task SetEnabledAsync_OnAlreadyActiveKey_ReturnsTrue()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
bool result = await _admin.SetEnabledAsync(
"key-1", enabled: true, DateTimeOffset.UtcNow, CancellationToken.None);
Assert.True(result);
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
Assert.NotNull(active);
Assert.Null(active!.RevokedUtc);
}
[Fact]
public async Task SetEnabledAsync_OnAlreadyDisabledKey_OverwritesTimestamp_ReturnsTrue()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
var t1 = new DateTimeOffset(2026, 5, 1, 10, 0, 0, TimeSpan.Zero);
var t2 = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
// Disable at t1.
await _admin.SetEnabledAsync("key-1", enabled: false, t1, CancellationToken.None);
// Disable again at a later t2 (idempotent overwrite — no guard on revoked_utc).
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, t2, CancellationToken.None);
Assert.True(result);
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
Assert.Equal(t2, item.RevokedUtc);
}
// --- Audit ---
[Fact]
@@ -34,6 +34,27 @@ public sealed class SqliteMigratorTests : IDisposable
Assert.Equal(1, await CountSchemaVersionRowsAsync());
}
[Fact]
public void CurrentVersion_Is2_ToMatchDonorGatewayDeployedSchema() =>
// The store was extracted from MxAccessGateway, whose deployed gateway-auth.db is
// stamped version 2. The library must stamp 2 (not reset to 1) so it does not refuse
// those existing databases on first boot. Locking this invariant.
Assert.Equal(2, SqliteAuthSchema.CurrentVersion);
[Fact]
public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndStaysAt2()
{
// The deployed-gateway scenario: a database already provisioned at version 2.
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
await SetVersionAsync(2);
await migrator.MigrateAsync(CancellationToken.None); // must not throw
Assert.Equal(2, await ReadVersionAsync());
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
}
[Fact]
public async Task MigrateAsync_FutureSchemaVersion_Throws()
{
@@ -72,4 +72,20 @@ public class LdapOptionsValidatorTests
Assert.False(new LdapOptionsValidator()
.Validate(null, Opts())
.Failed);
[Fact]
public void Validator_Skips_AllChecks_WhenDisabled() =>
// When LDAP is disabled its connection fields are inert; an otherwise-invalid
// config (plaintext + blank Server/SearchBase/ServiceAccountDn) must still pass.
Assert.False(new LdapOptionsValidator()
.Validate(null, new LdapOptions
{
Enabled = false,
Transport = LdapTransport.None,
AllowInsecure = false,
Server = "",
SearchBase = "",
ServiceAccountDn = "",
})
.Failed);
}
+2 -2
View File
@@ -4,7 +4,7 @@ Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtO
The library normalizes the three-project configuration-validation surface: a failure-accumulating `IValidateOptions` base, reusable rule primitives, a bind+validate+`ValidateOnStart` DI extension, and a pre-host `ConfigPreflight` aggregator for raw `IConfiguration` — so the plumbing is written once and domain rules stay per-project.
**Built at 0.1.0. Not yet adopted by OtOpcUa, MxAccessGateway, or ScadaBridge.** Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
**Built at 0.1.0. Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
---
@@ -66,7 +66,7 @@ ZB.MOM.WW.Configuration/
## Status
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Not yet adopted by OtOpcUa, MxAccessGateway, or ScadaBridge** — follow-on adoption is tracked in:
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes) — per-app result is tracked in:
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
+1 -1
View File
@@ -101,7 +101,7 @@ No third-party packages; no ASP.NET Core framework reference.
## Status
**Built at 0.1.0. Not yet adopted by the three apps.** Adoption is tracked in the component backlog:
**Built at 0.1.0. Adopted across all three apps on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption is tracked in the component backlog:
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
+6
View File
@@ -0,0 +1,6 @@
bin/
obj/
# identity-bearing / non-redistributable — never commit
*.ndjson
current/
aveva-install-*/
+173
View File
@@ -0,0 +1,173 @@
# ZB.MOM.WW.SPHistorianClient
Pure-managed .NET 10 client for **AVEVA System Platform Historian** (Wonderware), for the
ZB.MOM.WW SCADA family. This is a **library, not a service** — it is linked directly into the
consuming application and runs in-process alongside it.
The wire protocol is reverse-engineered and re-implemented in C#. There is **no native AVEVA
runtime dependency** — `aahClientManaged.dll` / `aahClient.dll` are not referenced or loaded.
The library runs on any OS for offline/unit testing; live WCF transports require Windows.
**Status: ported and rebranded into this repo; builds and 191 tests pass on macOS. Version 0.1.0.
The `RemoteGrpc` (2023 R2) read path is live-verified (2026-06-19). NOT yet packed/published to any
NuGet feed beyond the local `artifacts/` nupkg. NOT yet adopted by any consumer.**
---
## Supported operation surface
All operations are exposed via the public façade `HistorianClient`.
| Operation | Status |
|---|---|
| `ProbeAsync` | live-verified |
| `ReadRawAsync` | live-verified |
| `ReadAggregateAsync` | live-verified across the `RetrievalMode` enum (15 modes) |
| `ReadAtTimeAsync` | live-verified |
| `ReadBlocksAsync` | block history read |
| `ReadEventsAsync` | live-verified (typed event + property bag) |
| `BrowseTagNamesAsync` | live-verified |
| `GetTagMetadataAsync` | live-verified across many native data-type codes |
| `GetConnectionStatusAsync` | synthesized from authenticated probe |
| `GetStoreForwardStatusAsync` | synthesized defaults |
| `GetSystemParameterAsync` | live-verified |
| `EnsureTagAsync` | live-verified for analog `Float`; `Double`/`Int2`/`Int4`/`UInt4` supported (optional `ApplyScaling` persists distinct MinRaw/MaxRaw) |
| `DeleteTagAsync` | live-verified (known issue: server-side cascade may not always complete; use SMC as fallback to clean up sandbox tags) |
### Out of scope
- **Writing sample values** (`AddS2`) is architecturally blocked — the server runtime cache only
ingests from configured IOServer / Application Server pipelines, not from a standalone AddTag
client flow.
- Store-forward write, historian configuration changes, discrete/string tag creation (native
AddTag rejects them).
---
## Transport matrix
Configured via `HistorianClientOptions.Transport` (`HistorianTransport` enum).
| Transport | Protocol | Platform | Verification |
|---|---|---|---|
| `LocalPipe` | WCF/MDAS over Net.NamedPipe (local) | Windows-only | live-verified (read / browse / metadata / event / status) |
| `RemoteTcpIntegrated` | WCF/MDAS over Net.TCP + Windows transport auth | Windows-only | live-verified (full read / browse / metadata / event / status surface) |
| `RemoteTcpCertificate` | WCF/MDAS over Net.TCP + server-cert TLS | Windows-only | `ProbeAsync` live-verified; deeper coverage pending |
| `RemoteGrpc` | gRPC (2023 R2), Grpc.Net.Client/.Web | cross-platform | **live-verified 2026-06-19** against a 2023 R2 server — TLS + `StorageService.ValidateClientCredential` NTLM handshake + raw read returning correct values |
---
## DI registration
```csharp
services.AddZbSpHistorianClient(new HistorianClientOptions
{
Host = "localhost",
IntegratedSecurity = true,
Transport = HistorianTransport.RemoteTcpIntegrated,
});
```
`AddZbSpHistorianClient` registers the options instance (singleton) + `HistorianClient`
(transient). Because `HistorianClientOptions` uses `required`/`init`-only properties, the
consumer passes a fully-built instance. In a real app, bind it from configuration:
```csharp
services.AddZbSpHistorianClient(
config.GetSection("Historian").Get<HistorianClientOptions>()!);
```
The package depends only on `Microsoft.Extensions.DependencyInjection.Abstractions` — no
ASP.NET Core or framework reference required.
---
## Architecture
Three decoupled subsystems under `src/ZB.MOM.WW.SPHistorianClient/`:
| Subsystem | Path | Responsibility |
|---|---|---|
| Public façade | `HistorianClient.cs`, `HistorianClientOptions.cs` | Entry point; delegates to the transport layer |
| WCF/MDAS layer | `Wcf/` | Managed WCF transport; custom `MdasMessageEncoder`, binding factory, versioned service contracts |
| Binary frame layer | `Protocol/` | `Historian2020ProtocolDialect`; methods without protocol evidence throw `ProtocolEvidenceMissingException` |
| Public models | `Models/` | Public DTOs and enums (`HistorianSample`, `HistorianTagMetadata`, `RetrievalMode`, …) |
| gRPC transport | `Grpc/` | 2023 R2 gRPC transport; recovered `.proto` compiled by Grpc.Tools at build; wire contracts keep AVEVA's `ArchestrA.Grpc.Contract.*` namespaces |
---
## Build, test, and pack commands
```bash
# From ZB.MOM.WW.SPHistorianClient/
# Build
dotnet build ZB.MOM.WW.SPHistorianClient.slnx
dotnet build ZB.MOM.WW.SPHistorianClient.slnx -c Release
# Test (offline unit/golden-byte tests run on any OS;
# Windows-only WCF tests no-op off Windows;
# live integration tests skip when env vars are unset — see below)
dotnet test ZB.MOM.WW.SPHistorianClient.slnx
# Run a single test class
dotnet test ZB.MOM.WW.SPHistorianClient.slnx --filter "FullyQualifiedName~WcfDataQueryProtocolTests"
# Pack (one .nupkg lands in artifacts/)
dotnet pack ZB.MOM.WW.SPHistorianClient.slnx -c Release -o ./artifacts
```
### Test posture
All tests run offline by default; live integration tests are gated by environment variables
and skip cleanly when unset.
| Test type | Count |
|---|---|
| Offline unit / golden-byte tests | the bulk of the 191 total |
| WCF live integration (gated) | skipped off Windows or when `HISTORIAN_HOST` is unset |
| gRPC live integration (gated) | skipped when `HISTORIAN_GRPC_HOST` is unset |
| **Total** | **191** |
`GeneratePackageOnBuild` is off — pack explicitly with the command above.
### Live integration test environment variables
**WCF transports:**
| Variable | Required | Notes |
|---|---|---|
| `HISTORIAN_HOST` | yes (gates WCF tests) | Historian server hostname or IP |
| `HISTORIAN_PORT` | optional | Override the WCF TCP port (default 32568) |
| `HISTORIAN_TEST_TAG` | yes | A historized tag that exists on the server (use a system tag such as `SysTimeSec` for safe testing) |
| `HISTORIAN_USER` | optional | Omit to use Windows integrated security |
| `HISTORIAN_PASSWORD` | optional | Only used when `HISTORIAN_USER` is set |
| `HISTORIAN_TAG_FILTER` | optional | Browse filter pattern passed to `BrowseTagNamesAsync` |
**gRPC transport:**
| Variable | Required | Notes |
|---|---|---|
| `HISTORIAN_GRPC_HOST` | yes (gates gRPC tests) | 2023 R2 gRPC endpoint host |
| `HISTORIAN_GRPC_PORT` | optional | Default 32565 |
| `HISTORIAN_GRPC_TLS` | optional | Set `true` to enable TLS |
| `HISTORIAN_GRPC_DNSID` | optional | Override DNS identity for certificate validation |
---
## Status and provenance
**Version 0.1.0.** Ported from a reverse-engineering migration bundle and rebranded into this
repo. Builds and all 191 tests pass on macOS. NOT yet packed/published to the Gitea NuGet feed.
NOT yet adopted by any consumer (OtOpcUa, MxAccessGateway, ScadaBridge).
Production code is pure-managed .NET 10 with no native AVEVA reference. Reverse-engineering
tooling and proprietary decompilations from the source bundle were intentionally excluded from
this repo.
**Safety rules for this library (hard — never violate):**
- Never commit real server hostnames, IP addresses, or credentials.
- Never commit customer tag names or live capture data (`.gitignore` blocks `*.ndjson` and
similar raw-capture extensions).
- Use only generic placeholders (`localhost`, `<your-historized-tag>`) and built-in AVEVA
system tags (e.g., `SysTimeSec`) in all documentation and test defaults.
@@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
@@ -0,0 +1,31 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Historian SDK runtime deps (WCF/MDAS transports — Windows-only at runtime) -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
<PackageVersion Include="System.ServiceModel.NetNamedPipe" Version="10.0.652802" />
<PackageVersion Include="System.ServiceModel.NetTcp" Version="10.0.652802" />
<!-- 2023 R2 gRPC transport (cross-platform) -->
<PackageVersion Include="Google.Protobuf" Version="3.24.4" />
<PackageVersion Include="Grpc.Net.Client" Version="2.58.0" />
<PackageVersion Include="Grpc.Net.Client.Web" Version="2.58.0" />
<PackageVersion Include="Grpc.Tools" Version="2.59.0" />
<!-- ZB-idiomatic DI extension (only non-BCL lib dependency) -->
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<!-- Test -->
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
</ItemGroup>
</Project>
+96
View File
@@ -0,0 +1,96 @@
# ZB.MOM.WW.SPHistorianClient
Pure-managed .NET 10 client library for **AVEVA System Platform Historian** (Wonderware).
Part of the ZB.MOM.WW SCADA family.
No native AVEVA runtime dependency — `aahClientManaged.dll` / `aahClient.dll` are **not**
required. The wire protocol is re-implemented in managed C#. Live WCF transports require
Windows; offline tests and gRPC run cross-platform.
---
## Quick start
```csharp
using ZB.MOM.WW.SPHistorianClient;
using ZB.MOM.WW.SPHistorianClient.Models;
await using HistorianClient client = new(new HistorianClientOptions
{
Host = "localhost",
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromMinutes(10);
await foreach (HistorianSample sample in client.ReadRawAsync(
"SysTimeSec", startUtc, endUtc, maxValues: 100))
{
Console.WriteLine($"{sample.TimestampUtc:o} {sample.NumericValue} Q={sample.Quality}");
}
```
### DI registration
```csharp
// In Program.cs / Startup
services.AddZbSpHistorianClient(
config.GetSection("Historian").Get<HistorianClientOptions>()!);
// Resolves HistorianClient (transient) from the container
```
---
## Supported operations
| Operation | Status |
|---|---|
| `ProbeAsync` | live-verified |
| `ReadRawAsync` | live-verified |
| `ReadAggregateAsync` | live-verified across the `RetrievalMode` enum (15 modes) |
| `ReadAtTimeAsync` | live-verified |
| `ReadBlocksAsync` | block history read |
| `ReadEventsAsync` | live-verified (typed event + property bag) |
| `BrowseTagNamesAsync` | live-verified |
| `GetTagMetadataAsync` | live-verified across many native data-type codes |
| `GetConnectionStatusAsync` | synthesized from authenticated probe |
| `GetStoreForwardStatusAsync` | synthesized defaults |
| `GetSystemParameterAsync` | live-verified |
| `EnsureTagAsync` | live-verified for analog `Float`; `Double`/`Int2`/`Int4`/`UInt4` supported |
| `DeleteTagAsync` | live-verified (see note below) |
> **Note:** Writing sample values is architecturally blocked — the Historian server cache only
> ingests from configured IOServer / Application Server pipelines. `DeleteTagAsync` server-side
> cascade may not always complete; use SMC as a fallback to clean up sandbox tags.
---
## Transport matrix
| Transport | Protocol | Platform | Verification |
|---|---|---|---|
| `LocalPipe` | WCF/MDAS over Net.NamedPipe | Windows-only | live-verified |
| `RemoteTcpIntegrated` | WCF/MDAS over Net.TCP + Windows auth | Windows-only | live-verified |
| `RemoteTcpCertificate` | WCF/MDAS over Net.TCP + TLS | Windows-only | `ProbeAsync` live-verified; deeper coverage pending |
| `RemoteGrpc` | gRPC (2023 R2) | cross-platform | live-verified 2026-06-19 |
---
## Build and test
```bash
# From ZB.MOM.WW.SPHistorianClient/
dotnet build ZB.MOM.WW.SPHistorianClient.slnx
dotnet test ZB.MOM.WW.SPHistorianClient.slnx
# Pack
dotnet pack ZB.MOM.WW.SPHistorianClient.slnx -c Release -o ./artifacts
```
Offline unit tests (191 total) run on any OS. Live integration tests are gated by environment
variables (`HISTORIAN_HOST` for WCF, `HISTORIAN_GRPC_HOST` for gRPC) and skip cleanly when unset.
See `CLAUDE.md` for the full environment variable reference.
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.SPHistorianClient/ZB.MOM.WW.SPHistorianClient.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.SPHistorianClient.Tests/ZB.MOM.WW.SPHistorianClient.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.SPHistorianClient;
/// <summary>
/// ZB.MOM.WW DI registration for <see cref="HistorianClient"/>. Mirrors the family's
/// <c>AddZb*</c> convention. Because <see cref="HistorianClientOptions"/> is <c>required</c>/
/// <c>init</c>-only, callers pass a fully-built options instance (bind it from configuration in the
/// consuming app, e.g. <c>config.GetSection("Historian").Get&lt;HistorianClientOptions&gt;()</c>).
/// </summary>
public static class ZbSpHistorianClientServiceCollectionExtensions
{
public static IServiceCollection AddZbSpHistorianClient(
this IServiceCollection services,
HistorianClientOptions options)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(options);
if (string.IsNullOrWhiteSpace(options.Host))
{
throw new ArgumentException(
"HistorianClientOptions.Host must be set.", nameof(options));
}
services.AddSingleton(options);
// HistorianClient opens a fresh channel per operation and has a no-op DisposeAsync,
// so transient is safe and avoids assuming the shared dialect is concurrency-safe.
services.AddTransient<HistorianClient>();
return services;
}
}
@@ -0,0 +1,92 @@
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Grpc.Core;
using Grpc.Net.Client;
using Grpc.Net.Client.Web;
namespace ZB.MOM.WW.SPHistorianClient.Grpc;
/// <summary>
/// Builds a <see cref="GrpcChannel"/> for the 2023 R2 Historian Client Access Point,
/// replicating the stock <c>Archestra.Historian.GrpcClient.GrpcClientBase.InitializeBase</c>
/// transport shape: gRPC-Web (binary) over HTTP/1.1, optional TLS with an
/// untrusted-certificate bypass, and gzip request encoding.
/// </summary>
internal static class HistorianGrpcChannelFactory
{
/// <summary>
/// Resolves the effective gRPC port: when the caller left <see cref="HistorianClientOptions.Port"/>
/// at the WCF default (32568), the 2023 R2 gRPC default (32565) is substituted; otherwise the
/// explicit value is honoured.
/// </summary>
internal static int ResolvePort(HistorianClientOptions options) =>
options.Port == HistorianClientOptions.DefaultPort ? HistorianClientOptions.DefaultGrpcPort : options.Port;
/// <summary>
/// Builds the channel address. TLS uses <c>https://{ServerDnsIdentity|Host}:{port}</c> (the
/// DNS-identity override lets the URL match the server certificate name when connecting by IP);
/// plaintext uses <c>http://{Host}:{port}</c>.
/// </summary>
internal static string ResolveAddress(HistorianClientOptions options)
{
int port = ResolvePort(options);
if (options.GrpcUseTls)
{
string tlsHost = !string.IsNullOrEmpty(options.ServerDnsIdentity) ? options.ServerDnsIdentity! : options.Host;
return $"https://{tlsHost}:{port}";
}
return $"http://{options.Host}:{port}";
}
public static HistorianGrpcConnection Create(HistorianClientOptions options)
{
string address = ResolveAddress(options);
var httpHandler = new HttpClientHandler();
if (options.AllowUntrustedServerCertificate)
{
httpHandler.ServerCertificateCustomValidationCallback = AcceptAnyCertificate;
}
// gRPC-Web binary mode over HTTP/1.1 — matches the stock client (GrpcWebMode.GrpcWeb,
// HttpVersion 1.1). The 2023 R2 HCAP endpoint speaks gRPC-Web, not bare HTTP/2 gRPC.
var webHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, httpHandler)
{
HttpVersion = new Version(1, 1)
};
var channelOptions = new GrpcChannelOptions
{
HttpHandler = webHandler
};
GrpcChannel channel = GrpcChannel.ForAddress(address, channelOptions);
// The stock client always advertises gzip request encoding; honour the option so
// bandwidth-limited links can disable it.
var metadata = new Metadata();
if (options.Compression)
{
metadata.Add("grpc-internal-encoding-request", "gzip");
}
return new HistorianGrpcConnection(channel, metadata);
}
private static bool AcceptAnyCertificate(
HttpRequestMessage request,
X509Certificate2? certificate,
X509Chain? chain,
SslPolicyErrors errors) => true;
}
/// <summary>A live gRPC channel plus the per-call metadata header set.</summary>
internal sealed class HistorianGrpcConnection(GrpcChannel channel, Metadata metadata) : IDisposable
{
public GrpcChannel Channel { get; } = channel;
public Metadata Metadata { get; } = metadata;
public void Dispose() => Channel.Dispose();
}
@@ -0,0 +1,367 @@
using System.Runtime.CompilerServices;
using Google.Protobuf;
using Grpc.Core;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf;
using GrpcHistory = ArchestrA.Grpc.Contract.History;
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
namespace ZB.MOM.WW.SPHistorianClient.Grpc;
/// <summary>
/// 2023 R2 gRPC read orchestrator. Mirrors <see cref="HistorianWcfReadOrchestrator"/> over the
/// gRPC transport: the same native binary buffers travel inside protobuf <c>bytes</c> fields,
/// and the same serializers/parsers (<see cref="HistorianNativeHandshake"/>,
/// <see cref="HistorianDataQueryProtocol"/>) are reused unchanged.
///
/// Operation mapping (2020 WCF → 2023 R2 gRPC):
/// Hist.GetInterfaceVersion → HistoryService.GetInterfaceVersion
/// Hist.ValidateClientCredential (loop) → StorageService.ValidateClientCredential (loop)
/// Hist.OpenConnection2 → HistoryService.OpenConnection
/// Retr.StartQuery2 → RetrievalService.StartQuery
/// Retr.GetNextQueryResultBuffer2 (loop) → RetrievalService.GetNextQueryResultBuffer (loop)
/// Retr.EndQuery2 → RetrievalService.EndQuery
///
/// AUTH: the SSPI/Negotiate token loop runs through StorageService.ValidateClientCredential
/// (Handle + InBuff → Status + OutBuff) — per the 2023 R2 contract analysis, that op carries the
/// NTLM/SSPI tokens (the field names inBuff/outBuff match the 2020 native contract), whereas
/// HistoryService.ExchangeKey is a separate key-exchange/cert op (NOT the credential handshake).
/// OpenConnection and the retrieval chain stay on their original services; the server correlates
/// the validated context by the handshake GUID handle. Live-verified 2026-06-19 against a 2023 R2
/// server (wonder-sql-vd03) — earlier ExchangeKey wiring was rejected at token round 0.
/// </summary>
internal sealed class HistorianGrpcReadOrchestrator
{
private const ushort StartQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData;
private readonly HistorianClientOptions _options;
public HistorianGrpcReadOrchestrator(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public async IAsyncEnumerable<HistorianSample> ReadRawAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
int maxValues,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ValidateAuth();
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<HistorianSample> rows = await Task.Run(
() => RunRawChain(tag, startUtc, endUtc, maxValues, cancellationToken), cancellationToken).ConfigureAwait(false);
foreach (HistorianSample sample in rows)
{
cancellationToken.ThrowIfCancellationRequested();
yield return sample;
}
}
public async IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
RetrievalMode mode,
TimeSpan interval,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ValidateAuth();
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<HistorianAggregateSample> rows = await Task.Run(
() => RunAggregateChain(tag, startUtc, endUtc, mode, interval, cancellationToken), cancellationToken).ConfigureAwait(false);
foreach (HistorianAggregateSample sample in rows)
{
cancellationToken.ThrowIfCancellationRequested();
yield return sample;
}
}
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
string tag,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken)
{
ValidateAuth();
cancellationToken.ThrowIfCancellationRequested();
return Task.Run<IReadOnlyList<HistorianSample>>(() => RunAtTimeChain(tag, timestampsUtc, cancellationToken), cancellationToken);
}
private void ValidateAuth()
{
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
{
throw new ProtocolEvidenceMissingException(
"Managed gRPC read flow currently requires IntegratedSecurity or an explicit UserName + Password.");
}
}
private List<HistorianSample> RunRawChain(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(tag, startUtc, endUtc, maxValues);
return RunQuery(connection, clientHandle, request, maxValues, cancellationToken);
}
private List<HistorianAggregateSample> RunAggregateChain(
string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken)
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
return RunAggregateQuery(connection, clientHandle, tag, startUtc, endUtc, mode, interval, cancellationToken);
}
private List<HistorianSample> RunAtTimeChain(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
if (timestampsUtc.Count == 0)
{
return [];
}
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
uint clientHandle = OpenAuthenticatedConnection(connection, cancellationToken);
List<HistorianSample> results = new(timestampsUtc.Count);
foreach (DateTime ts in timestampsUtc)
{
cancellationToken.ThrowIfCancellationRequested();
DateTime tsUtc = ts.ToUniversalTime();
List<HistorianAggregateSample> aggregates = RunAggregateQuery(
connection,
clientHandle,
tag,
tsUtc - TimeSpan.FromTicks(1),
tsUtc + TimeSpan.FromTicks(1),
RetrievalMode.Interpolated,
TimeSpan.FromTicks(2),
cancellationToken);
if (aggregates.Count == 0)
{
continue;
}
HistorianAggregateSample chosen = aggregates[0];
results.Add(new HistorianSample(
TagName: chosen.TagName,
TimestampUtc: tsUtc,
NumericValue: chosen.Value,
StringValue: null,
Quality: chosen.Quality,
QualityDetail: chosen.QualityDetail,
OpcQuality: chosen.OpcQuality,
PercentGood: 100));
}
return results;
}
private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
historyClient.GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
HistorianNativeHandshake.RunTokenRounds(
(handle, wrapped, _) =>
{
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
connection.Metadata,
Deadline(),
cancellationToken);
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
bool success = response.Status?.BSuccess ?? false;
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
},
contextKey,
_options,
cancellationToken);
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
_options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
connection.Metadata,
Deadline(),
cancellationToken);
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
if (!(open2.Status?.BSuccess ?? false))
{
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
}
(uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
return clientHandle;
}
private List<HistorianSample> RunQuery(
HistorianGrpcConnection connection,
uint clientHandle,
HistorianDataQueryRequest request,
int maxValues,
CancellationToken cancellationToken)
{
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
uint queryHandle = StartQuery(retrievalClient, clientHandle, requestBuffer, "raw", cancellationToken);
try
{
List<HistorianSample> samples = [];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
(byte[] resultBuffer, byte[] errorBuffer) = GetNextResultBuffer(retrievalClient, clientHandle, queryHandle, "raw", cancellationToken);
if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(resultBuffer, errorBuffer, out IReadOnlyList<HistorianSample> rows, out bool hasMoreData))
{
throw new InvalidOperationException($"gRPC GetNextQueryResultBuffer returned an unparsable result buffer (length={resultBuffer.Length}).");
}
foreach (HistorianSample sample in rows)
{
samples.Add(sample);
if (samples.Count >= maxValues)
{
return samples;
}
}
if (!hasMoreData)
{
return samples;
}
}
}
finally
{
EndQuerySafely(retrievalClient, clientHandle, queryHandle);
}
}
private List<HistorianAggregateSample> RunAggregateQuery(
HistorianGrpcConnection connection,
uint clientHandle,
string tag,
DateTime startUtc,
DateTime endUtc,
RetrievalMode mode,
TimeSpan interval,
CancellationToken cancellationToken)
{
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildAggregateQueryRequest(tag, startUtc, endUtc, mode, interval);
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
uint queryHandle = StartQuery(retrievalClient, clientHandle, requestBuffer, $"aggregate {mode}", cancellationToken);
try
{
List<HistorianAggregateSample> samples = [];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
(byte[] resultBuffer, byte[] errorBuffer) = GetNextResultBuffer(retrievalClient, clientHandle, queryHandle, $"aggregate {mode}", cancellationToken);
if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferAggregateRows(
resultBuffer, errorBuffer, mode, interval, out IReadOnlyList<HistorianAggregateSample> rows, out bool hasMoreData))
{
throw new InvalidOperationException($"gRPC GetNextQueryResultBuffer (aggregate {mode}) returned an unparsable buffer (length={resultBuffer.Length}).");
}
samples.AddRange(rows);
if (!hasMoreData)
{
return samples;
}
}
}
finally
{
EndQuerySafely(retrievalClient, clientHandle, queryHandle);
}
}
private uint StartQuery(
GrpcRetrieval.RetrievalService.RetrievalServiceClient client,
uint clientHandle,
byte[] requestBuffer,
string label,
CancellationToken cancellationToken)
{
GrpcRetrieval.StartQueryResponse response = client.StartQuery(
new GrpcRetrieval.StartQueryRequest
{
UiHandle = clientHandle,
UiQueryRequestType = StartQueryRequestType,
BtRequestBuffer = ByteString.CopyFrom(requestBuffer)
},
null,
Deadline(),
cancellationToken);
if (!(response.Status?.BSuccess ?? false))
{
byte[] err = response.Status?.BtError?.ToByteArray() ?? [];
throw new InvalidOperationException($"gRPC StartQuery ({label}) failed (errorLen={err.Length}).");
}
return response.UiQueryHandle;
}
private (byte[] ResultBuffer, byte[] ErrorBuffer) GetNextResultBuffer(
GrpcRetrieval.RetrievalService.RetrievalServiceClient client,
uint clientHandle,
uint queryHandle,
string label,
CancellationToken cancellationToken)
{
GrpcRetrieval.GetNextQueryResultBufferResponse response = client.GetNextQueryResultBuffer(
new GrpcRetrieval.GetNextQueryResultBufferRequest { UiHandle = clientHandle, UiQueryHandle = queryHandle },
null,
Deadline(),
cancellationToken);
byte[] errorBuffer = response.Status?.BtError?.ToByteArray() ?? [];
if (!(response.Status?.BSuccess ?? false))
{
throw new InvalidOperationException($"gRPC GetNextQueryResultBuffer ({label}) failed (errorLen={errorBuffer.Length}).");
}
byte[] resultBuffer = response.BtQueryResult?.ToByteArray() ?? [];
return (resultBuffer, errorBuffer);
}
private void EndQuerySafely(GrpcRetrieval.RetrievalService.RetrievalServiceClient client, uint clientHandle, uint queryHandle)
{
try
{
client.EndQuery(
new GrpcRetrieval.EndQueryRequest { UiHandle = clientHandle, UiQueryHandle = queryHandle },
null,
Deadline(),
CancellationToken.None);
}
catch
{
// Best-effort cleanup; the read result is already collected.
}
}
private DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
}
@@ -0,0 +1,209 @@
// Recovered from HistoryService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
syntax = "proto3";
import "Status.proto";
option csharp_namespace = "ArchestrA.Grpc.Contract.History";
message CreateTagResponse {
bool bSuccess = 1;
bytes tagid = 2;
}
message GetInterfaceVersionRequest {
}
message GetInterfaceVersionResponse {
uint32 uiError = 1;
uint32 uiVersion = 2;
}
message OpenConnectionRequest {
bytes btConnectionRequest = 1;
}
message OpenConnectionResponse {
.Status status = 1;
bytes btConnectionResponse = 2;
}
message CloseConnectionRequest {
string strHandle = 1;
}
message CloseConnectionResponse {
.Status status = 1;
}
message UpdateClientStatusRequest {
string strHandle = 1;
bytes btClientStatus = 2;
}
message UpdateClientStatusResponse {
.Status status = 1;
bytes btServerStatus = 2;
}
message RegisterTagsRequest {
string strHandle = 1;
bytes btTagInfos = 2;
}
message RegisterTagsResponse {
.Status status = 1;
bytes btTagStatus = 2;
}
message EnsureTagsRequest {
string strHandle = 1;
bytes btTagInfos = 2;
uint32 elementCount = 3;
}
message EnsureTagsResponse {
.Status status = 1;
bytes btTagStatus = 2;
}
message AddStreamValuesRequest {
string strHandle = 1;
bytes btValues = 2;
}
message AddStreamValuesResponse {
.Status status = 1;
}
message TagExtendedProperty {
enum TagExtendedPropertyDataType {
String = 0;
Int16 = 1;
Int32 = 2;
Int64 = 3;
Double = 4;
Boolean = 5;
DateTimeOffset = 6;
Guid = 7;
Geography = 8;
Geometry = 9;
}
string PropertyName = 1;
.TagExtendedProperty.TagExtendedPropertyDataType type = 2;
bytes value = 3;
bool Facetable = 4;
bool Searchable = 5;
bool SubstringSearchable = 6;
}
message TagExtendedPropertyGroup {
string tagname = 1;
repeated .TagExtendedProperty TagExtendedProperties = 2;
}
message AddTagExtendedPropertyRequest {
string strHandle = 1;
repeated .TagExtendedPropertyGroup TagExtendedPropertyGroups = 2;
}
message AddTagExtendedPropertyResponse {
.Status status = 1;
}
message ExchangeKeyRequest {
string strHandle = 1;
bytes btInput = 2;
}
message ExchangeKeyResponse {
.Status status = 1;
bytes btOutput = 2;
}
message StartJobRequest {
string strHandle = 1;
bytes btInput = 2;
}
message StartJobResponse {
.Status status = 1;
string strJobid = 2;
}
message GetJobStatusRequest {
string strHandle = 1;
string strJobid = 2;
}
message GetJobStatusResponse {
.Status status = 1;
bytes btJobStatus = 2;
}
message AddTagExtendedPropertiesRequest {
string strHandle = 1;
bytes btTeps = 2;
}
message AddTagExtendedPropertiesResponse {
.Status status = 1;
}
message DeleteTagExtendedPropertiesRequest {
string strHandle = 1;
bytes btInput = 2;
}
message DeleteTagExtendedPropertiesResponse {
.Status status = 1;
}
message DeleteTagsRequest {
uint32 uiHandle = 1;
bytes btTagnames = 2;
}
message DeleteTagsResponse {
.Status status = 1;
bytes btDeleteTagStatus = 2;
}
message AddTagLocalizedPropertiesRequest {
string strHandle = 1;
bytes btInput = 2;
}
message AddTagLocalizedPropertiesResponse {
.Status status = 1;
}
message DeleteTagLocalizedPropertiesRequest {
string strHandle = 1;
bytes btInput = 2;
}
message DeleteTagLocalizedPropertiesResponse {
.Status status = 1;
}
service HistoryService {
rpc GetInterfaceVersion (.GetInterfaceVersionRequest) returns (.GetInterfaceVersionResponse);
rpc ExchangeKey (.ExchangeKeyRequest) returns (.ExchangeKeyResponse);
rpc OpenConnection (.OpenConnectionRequest) returns (.OpenConnectionResponse);
rpc CloseConnection (.CloseConnectionRequest) returns (.CloseConnectionResponse);
rpc UpdateClientStatus (.UpdateClientStatusRequest) returns (.UpdateClientStatusResponse);
rpc RegisterTags (.RegisterTagsRequest) returns (.RegisterTagsResponse);
rpc EnsureTags (.EnsureTagsRequest) returns (.EnsureTagsResponse);
rpc AddStreamValues (.AddStreamValuesRequest) returns (.AddStreamValuesResponse);
rpc AddTagExtendedPropertyGroups (.AddTagExtendedPropertyRequest) returns (.AddTagExtendedPropertyResponse);
rpc AddTagExtendedProperties (.AddTagExtendedPropertiesRequest) returns (.AddTagExtendedPropertiesResponse);
rpc StartJob (.StartJobRequest) returns (.StartJobResponse);
rpc GetJobStatus (.GetJobStatusRequest) returns (.GetJobStatusResponse);
rpc DeleteTagExtendedProperties (.DeleteTagExtendedPropertiesRequest) returns (.DeleteTagExtendedPropertiesResponse);
rpc DeleteTags (.DeleteTagsRequest) returns (.DeleteTagsResponse);
rpc AddTagLocalizedProperties (.AddTagLocalizedPropertiesRequest) returns (.AddTagLocalizedPropertiesResponse);
rpc DeleteTagLocalizedProperties (.DeleteTagLocalizedPropertiesRequest) returns (.DeleteTagLocalizedPropertiesResponse);
}
@@ -0,0 +1,186 @@
// Recovered from RetrievalService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
syntax = "proto3";
import "Status.proto";
option csharp_namespace = "ArchestrA.Grpc.Contract.Retrieval";
message GetRetrievalInterfaceVersionRequest {
}
message GetRetrievalInterfaceVersionResponse {
uint32 uiError = 1;
uint32 uiVersion = 2;
}
message StartQueryRequest {
uint32 uiHandle = 1;
uint32 uiQueryRequestType = 2;
bytes btRequestBuffer = 3;
}
message StartQueryResponse {
.Status status = 1;
uint32 uiQueryHandle = 2;
bytes btResponseBuffer = 3;
}
message GetNextQueryResultBufferRequest {
uint32 uiHandle = 1;
uint32 uiQueryHandle = 2;
}
message GetNextQueryResultBufferResponse {
.Status status = 1;
bytes btQueryResult = 2;
}
message EndQueryRequest {
uint32 uiHandle = 1;
uint32 uiQueryHandle = 2;
}
message EndQueryResponse {
.Status status = 1;
}
message GetShardTagidsByTagnameAndSourceRequest {
string strHandle = 1;
bytes btTagnameAndSource = 2;
}
message GetShardTagidsByTagnameAndSourceResponse {
.Status status = 1;
bytes btShardTagids = 2;
}
message GetTagInfosFromNameRequest {
string strHandle = 1;
bytes btTagNames = 2;
uint32 uiSequence = 3;
}
message GetTagInfosFromNameResponse {
.Status status = 1;
bytes btTagInfos = 2;
uint32 uiSequence = 3;
}
message GetTagExtendedPropertiesFromNameRequest {
string strHandle = 1;
bytes btTagNames = 2;
uint32 uiSequence = 3;
}
message GetTagExtendedPropertiesFromNameResponse {
.Status status = 1;
bytes btTeps = 2;
uint32 uiSequence = 3;
}
message ExecuteSqlCommandRequest {
string strHandle = 1;
string StrCommand = 2;
uint32 uiOption = 3;
uint32 uiQueryHandle = 4;
}
message ExecuteSqlCommandResponse {
.Status status = 1;
int32 iRetValue = 2;
uint32 uiQueryHandle = 3;
}
message StartEventQueryRequest {
uint32 uiHandle = 1;
uint32 uiQueryRequestType = 2;
bytes btRequest = 3;
uint32 uiQueryHandle = 4;
}
message StartEventQueryResponse {
.Status status = 1;
uint32 uiQueryHandle = 2;
bytes btResonse = 3;
}
message GetNextEventQueryResultBufferRequest {
uint32 uiHandle = 1;
uint32 uiQueryHandle = 2;
}
message GetNextEventQueryResultBufferResponse {
.Status status = 1;
bytes btResult = 2;
}
message EndEventQueryRequest {
uint32 uiHandle = 1;
uint32 uiQueryHandle = 2;
}
message EndEventQueryResponse {
.Status status = 1;
}
message StartTagQueryRequest {
string strHandle = 1;
bytes btRequest = 2;
}
message StartTagQueryResponse {
.Status status = 1;
bytes btResponse = 2;
}
message QueryTagRequest {
string strHandle = 1;
uint32 uiQueryHandle = 2;
bytes btRequest = 3;
}
message QueryTagResponse {
.Status status = 1;
bytes btResonse = 2;
}
message EndTagQueryRequest {
string strHandle = 1;
uint32 uiQueryHandle = 2;
}
message EndTagQueryResponse {
.Status status = 1;
}
message GetTagLocalizedPropertiesFromNameRequest {
string strHandle = 1;
bytes btTagNames = 2;
uint32 uiSequence = 3;
}
message GetTagLocalizedPropertiesFromNameResponse {
.Status status = 1;
uint32 uiSequence = 2;
bytes btOutBuffer = 3;
}
service RetrievalService {
rpc GetRetrievalInterfaceVersion (.GetRetrievalInterfaceVersionRequest) returns (.GetRetrievalInterfaceVersionResponse);
rpc StartQuery (.StartQueryRequest) returns (.StartQueryResponse);
rpc GetNextQueryResultBuffer (.GetNextQueryResultBufferRequest) returns (.GetNextQueryResultBufferResponse);
rpc EndQuery (.EndQueryRequest) returns (.EndQueryResponse);
rpc GetShardTagidsByTagnameAndSource (.GetShardTagidsByTagnameAndSourceRequest) returns (.GetShardTagidsByTagnameAndSourceResponse);
rpc GetTagInfosFromName (.GetTagInfosFromNameRequest) returns (.GetTagInfosFromNameResponse);
rpc GetTagExtendedPropertiesFromName (.GetTagExtendedPropertiesFromNameRequest) returns (.GetTagExtendedPropertiesFromNameResponse);
rpc ExecuteSqlCommand (.ExecuteSqlCommandRequest) returns (.ExecuteSqlCommandResponse);
rpc StartEventQuery (.StartEventQueryRequest) returns (.StartEventQueryResponse);
rpc GetNextEventQueryResultBuffer (.GetNextEventQueryResultBufferRequest) returns (.GetNextEventQueryResultBufferResponse);
rpc EndEventQuery (.EndEventQueryRequest) returns (.EndEventQueryResponse);
rpc StartTagQuery (.StartTagQueryRequest) returns (.StartTagQueryResponse);
rpc QueryTag (.QueryTagRequest) returns (.QueryTagResponse);
rpc EndTagQuery (.EndTagQueryRequest) returns (.EndTagQueryResponse);
rpc GetTagLocalizedPropertiesFromName (.GetTagLocalizedPropertiesFromNameRequest) returns (.GetTagLocalizedPropertiesFromNameResponse);
}
@@ -0,0 +1,12 @@
// Recovered from Status.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
syntax = "proto3";
option csharp_namespace = "ArchestrA.Grpc.Contract.RequestStatus";
message Status {
bool bSuccess = 1;
bytes btError = 2;
}
@@ -0,0 +1,215 @@
// Recovered from StatusService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
syntax = "proto3";
import "Status.proto";
option csharp_namespace = "ArchestrA.Grpc.Contract.Status";
message GetStatusInterfaceVersionRequest {
}
message GetStatusInterfaceVersionResponse {
uint32 uiError = 1;
uint32 uiVersion = 2;
}
message GetSystemParameterRequest {
uint32 uiHandle = 1;
string strParameterName = 2;
}
message GetSystemParameterResponse {
.Status status = 1;
string strParameterValue = 2;
}
message SendInfoRequest {
string strHandle = 1;
string strPipeName = 2;
uint32 uiOption = 3;
bytes btReqBuff = 4;
string strInfoID = 5;
}
message SendInfoResponse {
.Status status = 1;
string strInfoID = 2;
bytes btRespBuff = 3;
}
message RequestInfoRequest {
string strHandle = 1;
string strInfoID = 2;
uint32 uiOffset = 3;
}
message RequestInfoResponse {
.Status status = 1;
bytes btRespBuff = 2;
}
message DeleteInfoRequest {
string strHandle = 1;
string strInfoID = 2;
}
message DeleteInfoResponse {
.Status status = 1;
}
message GetHistorianInfoRequest {
string strHandle = 1;
bytes btRequest = 2;
}
message GetHistorianInfoResponse {
.Status status = 1;
bytes btHistorianInfo = 2;
}
message StartProcessRequest {
string strHandle = 1;
string strPipeName = 2;
string strPath = 3;
string strAuguments = 4;
uint32 uiKeepAliveInterval = 5;
uint32 uiKeepAliveMethod = 6;
}
message StartProcessResponse {
.Status status = 1;
}
message StopProcessRequest {
string strHandle = 1;
string StrPipeName = 2;
}
message StopProcessResponse {
.Status status = 1;
}
message PingServerRequest {
string strHandle = 1;
string strPipeName = 2;
uint32 uiTimeout = 3;
}
message PingServerResponse {
.Status status = 1;
}
message PingPipeRequest {
string strHandle = 1;
string strPipeName = 2;
}
message PingPipeResponse {
.Status status = 1;
}
message ConfigureAutoStartProcessRequest {
string strHandle = 1;
string strPipeName = 2;
string strPath = 3;
string strAuguments = 4;
uint32 uiKeepAliveInterval = 5;
uint32 uiKeepAliveMethod = 6;
uint32 uiStartupFlags = 7;
}
message ConfigureAutoStartProcessResponse {
.Status status = 1;
}
message GetHistorianConsoleStatusRequest {
string strHandle = 1;
}
message GetHistorianConsoleStatusResponse {
.Status status = 1;
uint32 uiConsoleStatus = 2;
}
message GetRuntimeParameterRequest {
string strHandle = 1;
bytes btRequest = 2;
}
message GetRuntimeParameterResponse {
.Status status = 1;
bytes btResponse = 2;
}
message GetSystemTimeZoneNameRequest {
uint32 uiHandle = 1;
}
message GetSystemTimeZoneNameResponse {
.Status status = 1;
string strSystemTimeZoneName = 2;
}
message SetHistorianConsoleStatusRequest {
string strHandle = 1;
uint32 uiStatus = 2;
uint32 uiOption = 3;
}
message SetHistorianConsoleStatusResponse {
.Status status = 1;
}
message CanUpdateAreaHierarchyRequest {
uint32 uiHandle = 1;
}
message CanUpdateAreaHierarchyResponse {
.Status status = 1;
bool canUpdate = 2;
}
message UpdateAreaHierarchyRequest {
uint32 uiHandle = 1;
string guid = 2;
uint32 sequence = 3;
bytes buffer = 4;
}
message UpdateAreaHierarchyResponse {
.Status status = 1;
}
message UpdateObjectHierarchyRequest {
uint32 uiHandle = 1;
string guid = 2;
uint32 sequence = 3;
bytes buffer = 4;
}
message UpdateObjectHierarchyResponse {
.Status status = 1;
}
service StatusService {
rpc GetStatusInterfaceVersion (.GetStatusInterfaceVersionRequest) returns (.GetStatusInterfaceVersionResponse);
rpc GetSystemParameter (.GetSystemParameterRequest) returns (.GetSystemParameterResponse);
rpc SendInfo (.SendInfoRequest) returns (.SendInfoResponse);
rpc RequestInfo (.RequestInfoRequest) returns (.RequestInfoResponse);
rpc DeleteInfo (.DeleteInfoRequest) returns (.DeleteInfoResponse);
rpc GetHistorianInfo (.GetHistorianInfoRequest) returns (.GetHistorianInfoResponse);
rpc StartProcess (.StartProcessRequest) returns (.StartProcessResponse);
rpc StopProcess (.StopProcessRequest) returns (.StopProcessResponse);
rpc PingServer (.PingServerRequest) returns (.PingServerResponse);
rpc PingPipe (.PingPipeRequest) returns (.PingPipeResponse);
rpc ConfigureAutoStartProcess (.ConfigureAutoStartProcessRequest) returns (.ConfigureAutoStartProcessResponse);
rpc GetHistorianConsoleStatus (.GetHistorianConsoleStatusRequest) returns (.GetHistorianConsoleStatusResponse);
rpc GetRuntimeParameter (.GetRuntimeParameterRequest) returns (.GetRuntimeParameterResponse);
rpc GetSystemTimeZoneName (.GetSystemTimeZoneNameRequest) returns (.GetSystemTimeZoneNameResponse);
rpc SetHistorianConsoleStatus (.SetHistorianConsoleStatusRequest) returns (.SetHistorianConsoleStatusResponse);
rpc CanUpdateAreaHierarchy (.CanUpdateAreaHierarchyRequest) returns (.CanUpdateAreaHierarchyResponse);
rpc UpdateAreaHierarchy (.UpdateAreaHierarchyRequest) returns (.UpdateAreaHierarchyResponse);
rpc UpdateObjectHierarchy (.UpdateObjectHierarchyRequest) returns (.UpdateObjectHierarchyResponse);
}
@@ -0,0 +1,417 @@
// Recovered from StorageService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
syntax = "proto3";
import "Status.proto";
option csharp_namespace = "ArchestrA.Grpc.Contract.Storage";
message GetInterfaceVersionRequest {
}
message GetInterfaceVersionResponse {
uint32 uiError = 1;
uint32 uiVersion = 2;
}
message OpenStorageConnectionRequest {
string HostName = 1;
string EnginePath = 2;
uint32 FreeDiskSpace = 3;
string ProcessName = 4;
uint32 ProcessId = 5;
string UserName = 6;
bytes Password = 7;
uint32 PwdLength = 8;
uint32 ClientType = 9;
uint32 ClientVersion = 10;
uint32 ConnectionMode = 11;
uint32 ConnectionTimeout = 12;
string StorageSessionId = 13;
}
message OpenStorageConnectionResponse {
.Status status = 1;
string StorageSessionId = 2;
uint32 Handle = 3;
uint64 ConnectionTime = 4;
uint32 ServerStatus = 5;
}
message CloseStorageConnectionRequest {
uint32 Handle = 1;
}
message CloseStorageConnectionResponse {
.Status status = 1;
}
message PingRequest {
uint32 Handle = 1;
}
message PingResponse {
.Status status = 1;
uint32 OutByteCount = 2;
bytes OutBuff = 3;
}
message AddTagsRequest {
uint32 Handle = 1;
uint32 ElementCount = 2;
uint32 InByteCount = 3;
bytes InBuff = 4;
}
message AddTagsResponse {
.Status status = 1;
uint32 OutByteCount = 2;
bytes OutBuff = 3;
}
message RegisterTagsRequest {
uint32 Handle = 1;
uint32 ElementCount = 2;
uint32 InByteCount = 3;
bytes InBuff = 4;
}
message RegisterTagsResponse {
.Status status = 1;
uint32 OutByteCount = 2;
bytes OutBuff = 3;
}
message AddStreamValuesRequest {
uint32 Handle = 1;
uint32 Size = 2;
bytes Buffer = 3;
}
message AddStreamValuesResponse {
.Status status = 1;
}
message GetTagIdsRequest {
uint32 Handle = 1;
uint32 Sequence = 2;
}
message GetTagIdsResponse {
.Status status = 1;
uint32 Sequence = 2;
uint32 Size = 3;
bytes TagIds = 4;
}
message GetTagsRequest {
uint32 Handle = 1;
uint32 TagIdsSize = 2;
bytes TagIds = 3;
uint32 Sequence = 4;
}
message GetTagsResponse {
.Status status = 1;
uint32 Sequence = 2;
uint32 TagInfosSize = 3;
bytes TagInfos = 4;
}
message FlushMetadataRequest {
uint32 Handle = 1;
uint32 TagIdsSize = 2;
bytes TagIds = 3;
}
message FlushMetadataResponse {
.Status status = 1;
}
message FlushDataRequest {
uint32 Handle = 1;
}
message FlushDataResponse {
.Status status = 1;
}
message LoadBlocksRequest {
uint32 Handle = 1;
uint32 Sequence = 2;
}
message LoadBlocksResponse {
.Status status = 1;
uint32 Sequence = 2;
uint32 HistoryBlockSize = 3;
bytes HistoryBlocks = 4;
}
message GetSnapshotsRequest {
uint32 Handle = 1;
uint64 BlockStartTime = 2;
uint32 Sequence = 3;
}
message GetSnapshotsResponse {
.Status status = 1;
uint32 Sequence = 2;
uint32 SnapshotSize = 3;
bytes Snapshot = 4;
}
message StartQuerySnapshotRequest {
uint32 Handle = 1;
uint64 BlockStartTime = 2;
uint32 SnapshotInfoSize = 3;
bytes SnapshotInfo = 4;
uint32 SnapshotQueryId = 5;
}
message StartQuerySnapshotResponse {
.Status status = 1;
uint32 SnapshotQueryId = 2;
}
message NextQuerySnapshotRequest {
uint32 Handle = 1;
uint32 SnapshotQueryId = 2;
uint32 Sequence = 3;
}
message NextQuerySnapshotResponse {
.Status status = 1;
uint32 Sequence = 2;
uint32 SnapshotSize = 3;
bytes Snapshot = 4;
}
message EndSnapshotRequest {
uint32 Handle = 1;
uint32 SnapshotQueryId = 2;
uint64 BlockStartTime = 3;
uint32 SnapshotInfoSize = 4;
bytes SnapshotInfo = 5;
bool IsDeleteSnapshot = 6;
}
message EndSnapshotResponse {
.Status status = 1;
}
message StopRequest {
uint32 Handle = 1;
}
message StopResponse {
.Status status = 1;
}
message ClearTagidPairsRequest {
uint32 Handle = 1;
}
message ClearTagidPairsResponse {
.Status status = 1;
}
message AddTagidPairsRequest {
uint32 Handle = 1;
uint32 ElementCount = 2;
uint32 InByteCount = 3;
bytes InBuff = 4;
}
message AddTagidPairsResponse {
.Status status = 1;
}
message GetSFParameterRequest {
uint32 Handle = 1;
string ParameterName = 2;
}
message GetSFParameterResponse {
.Status status = 1;
string ParamaterValue = 2;
}
message SetSFParameterRequest {
uint32 Handle = 1;
string ParamaterName = 2;
string ParamaterValue = 3;
}
message SetSFParameterResponse {
.Status status = 1;
}
message SendSnapshotBeginRequest {
uint32 Handle = 1;
uint64 TotalSize = 2;
uint64 StartTime = 3;
uint64 EndTime = 4;
string StorageSessionId = 5;
}
message SendSnapshotBeginResponse {
.Status status = 1;
string StorageSessionId = 2;
uint32 QueryId = 3;
}
message SendSnapshotEndRequest {
uint32 Handle = 1;
string StorageSessionId = 2;
uint32 QueryId = 3;
uint32 TimeRangeSize = 4;
bytes TimeRangeBytes = 5;
}
message SendSnapshotEndResponse {
.Status status = 1;
}
message SendSnapshotRequest {
uint32 Handle = 1;
string StorageSessionId = 2;
uint32 QueryId = 3;
uint32 Size = 4;
uint64 SnapShotChunkOffset = 5;
bytes Buffer = 6;
}
message SendSnapshotResponse {
.Status status = 1;
}
message DeleteSnapshotRequest {
uint32 Handle = 1;
uint64 StartTime = 2;
uint32 SnapshotInfoSize = 3;
bytes SnapshotInfo = 4;
}
message DeleteSnapshotResponse {
.Status status = 1;
}
message AddStreamValues2Request {
uint32 Handle = 1;
string ShardId = 2;
bytes Buffer = 3;
}
message AddStreamValues2Response {
.Status status = 1;
}
message ClearShardTagidsRequest {
uint32 Handle = 1;
}
message ClearShardTagidsResponse {
.Status status = 1;
}
message AddShardTagidsRequest {
uint32 Handle = 1;
bytes Buffer = 2;
}
message AddShardTagidsResponse {
.Status status = 1;
}
message SplitUnknownShardsRequest {
uint32 Handle = 1;
}
message SplitUnknownShardsResponse {
.Status status = 1;
}
message GetRemainingSnapshotsSizeRequest {
uint32 Handle = 1;
}
message GetRemainingSnapshotsSizeResponse {
.Status status = 1;
uint64 SnapshotSize = 2;
}
message DeleteTagsRequest {
uint32 Handle = 1;
bytes Buffer = 2;
}
message DeleteTagsResponse {
.Status status = 1;
}
message OpenStorageConnection2Request {
bytes InParameters = 1;
}
message OpenStorageConnection2Response {
.Status status = 1;
bytes OutParmaters = 2;
}
message ValidateClientCredentialRequest {
string Handle = 1;
bytes InBuff = 2;
}
message ValidateClientCredentialResponse {
.Status status = 1;
bytes OutBuff = 2;
}
message GetInfoRequest {
string Request = 1;
}
message GetInfoResponse {
.Status status = 1;
bytes info = 2;
}
service StorageService {
rpc GetInterfaceVersion (.GetInterfaceVersionRequest) returns (.GetInterfaceVersionResponse);
rpc OpenStorageConnection (.OpenStorageConnectionRequest) returns (.OpenStorageConnectionResponse);
rpc CloseStorageConnection (.CloseStorageConnectionRequest) returns (.CloseStorageConnectionResponse);
rpc Ping (.PingRequest) returns (.PingResponse);
rpc AddTags (.AddTagsRequest) returns (.AddTagsResponse);
rpc RegisterTags (.RegisterTagsRequest) returns (.RegisterTagsResponse);
rpc AddStreamValues (.AddStreamValuesRequest) returns (.AddStreamValuesResponse);
rpc GetTagIds (.GetTagIdsRequest) returns (.GetTagIdsResponse);
rpc GetTags (.GetTagsRequest) returns (.GetTagsResponse);
rpc FlushMetadata (.FlushMetadataRequest) returns (.FlushMetadataResponse);
rpc FlushData (.FlushDataRequest) returns (.FlushDataResponse);
rpc LoadBlocks (.LoadBlocksRequest) returns (.LoadBlocksResponse);
rpc GetSnapshots (.GetSnapshotsRequest) returns (.GetSnapshotsResponse);
rpc StartQuerySnapshot (.StartQuerySnapshotRequest) returns (.StartQuerySnapshotResponse);
rpc NextQuerySnapshot (.NextQuerySnapshotRequest) returns (.NextQuerySnapshotResponse);
rpc EndSnapshot (.EndSnapshotRequest) returns (.EndSnapshotResponse);
rpc Stop (.StopRequest) returns (.StopResponse);
rpc ClearTagidPairs (.ClearTagidPairsRequest) returns (.ClearTagidPairsResponse);
rpc AddTagidPairs (.AddTagidPairsRequest) returns (.AddTagidPairsResponse);
rpc GetSFParameter (.GetSFParameterRequest) returns (.GetSFParameterResponse);
rpc SetSFParameter (.SetSFParameterRequest) returns (.SetSFParameterResponse);
rpc SendSnapshotBegin (.SendSnapshotBeginRequest) returns (.SendSnapshotBeginResponse);
rpc SendSnapshotEnd (.SendSnapshotEndRequest) returns (.SendSnapshotEndResponse);
rpc SendSnapshot (.SendSnapshotRequest) returns (.SendSnapshotResponse);
rpc DeleteSnapshot (.DeleteSnapshotRequest) returns (.DeleteSnapshotResponse);
rpc AddStreamValues2 (.AddStreamValues2Request) returns (.AddStreamValues2Response);
rpc ClearShardTagids (.ClearShardTagidsRequest) returns (.ClearShardTagidsResponse);
rpc AddShardTagids (.AddShardTagidsRequest) returns (.AddShardTagidsResponse);
rpc SplitUnknownShards (.SplitUnknownShardsRequest) returns (.SplitUnknownShardsResponse);
rpc GetRemainingSnapshotsSize (.GetRemainingSnapshotsSizeRequest) returns (.GetRemainingSnapshotsSizeResponse);
rpc DeleteTags (.DeleteTagsRequest) returns (.DeleteTagsResponse);
rpc OpenStorageConnection2 (.OpenStorageConnection2Request) returns (.OpenStorageConnection2Response);
rpc ValidateClientCredential (.ValidateClientCredentialRequest) returns (.ValidateClientCredentialResponse);
rpc GetInfo (.GetInfoRequest) returns (.GetInfoResponse);
}
@@ -0,0 +1,92 @@
// Recovered from TransactionService.proto (AVEVA Historian SDK 2023 R2, Archestra.Grpc.Contract).
// Reconstructed from the embedded protobuf FileDescriptor; field numbers are authoritative.
syntax = "proto3";
import "Status.proto";
option csharp_namespace = "ArchestrA.Grpc.Contract.Transaction";
message ForwardSnapshotRequest {
string strHandle = 1;
string strSessionID = 2;
uint32 queryID = 3;
uint64 snapShotChunkOffset = 4;
bytes btInput = 5;
}
message ForwardSnapshotResponse {
.Status status = 1;
}
message ForwardSnapshotBeginRequest {
string strHandle = 1;
uint64 totalSize = 2;
uint64 startTime = 3;
uint64 endTime = 4;
}
message ForwardSnapshotBeginResponse {
string strSessionID = 1;
uint32 queryID = 2;
.Status status = 3;
}
message ForwardSnapshotEndRequest {
string strHandle = 1;
string strSessionID = 2;
uint32 queryID = 3;
bytes timeRange = 4;
}
message ForwardSnapshotEndResponse {
bytes tagIds = 1;
.Status status = 2;
}
message GetTransactionInterfaceVersionRequest {
}
message GetTransactionInterfaceVersionResponse {
uint32 error = 1;
uint32 version = 2;
}
message AddNonStreamValuesBeginRequest {
string strHandle = 1;
}
message AddNonStreamValuesBeginResponse {
.Status status = 1;
string strTransactionId = 2;
}
message AddNonStreamValuesRequest {
string strHandle = 1;
string strTransactionId = 2;
bytes btInput = 3;
}
message AddNonStreamValuesResponse {
.Status status = 1;
}
message AddNonStreamValuesEndRequest {
string strHandle = 1;
string strTransactionId = 2;
bool bCommit = 3;
}
message AddNonStreamValuesEndResponse {
.Status status = 1;
}
service TransactionService {
rpc ForwardSnapshot (.ForwardSnapshotRequest) returns (.ForwardSnapshotResponse);
rpc ForwardSnapshotBegin (.ForwardSnapshotBeginRequest) returns (.ForwardSnapshotBeginResponse);
rpc ForwardSnapshotEnd (.ForwardSnapshotEndRequest) returns (.ForwardSnapshotEndResponse);
rpc GetTransactionInterfaceVersion (.GetTransactionInterfaceVersionRequest) returns (.GetTransactionInterfaceVersionResponse);
rpc AddNonStreamValuesBegin (.AddNonStreamValuesBeginRequest) returns (.AddNonStreamValuesBeginResponse);
rpc AddNonStreamValues (.AddNonStreamValuesRequest) returns (.AddNonStreamValuesResponse);
rpc AddNonStreamValuesEnd (.AddNonStreamValuesEndRequest) returns (.AddNonStreamValuesEndResponse);
}
@@ -0,0 +1,165 @@
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Protocol;
using ZB.MOM.WW.SPHistorianClient.Transport;
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient;
public sealed class HistorianClient : IAsyncDisposable
{
private readonly HistorianClientOptions _options;
private readonly IHistorianTransportFactory _transportFactory;
private readonly Historian2020ProtocolDialect _protocol;
public HistorianClient(HistorianClientOptions options)
: this(options, TcpHistorianTransport.Factory)
{
}
internal HistorianClient(HistorianClientOptions options, IHistorianTransportFactory transportFactory)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_transportFactory = transportFactory ?? throw new ArgumentNullException(nameof(transportFactory));
_protocol = new Historian2020ProtocolDialect(_options);
}
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
{
return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
}
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
int maxValues,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxValues);
ValidateTimeRange(startUtc, endUtc);
return _protocol.ReadRawAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
}
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
RetrievalMode mode,
TimeSpan interval,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(interval, TimeSpan.Zero);
ValidateTimeRange(startUtc, endUtc);
return _protocol.ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
}
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
string tag,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ArgumentNullException.ThrowIfNull(timestampsUtc);
if (timestampsUtc.Count == 0)
{
return Task.FromResult<IReadOnlyList<HistorianSample>>(Array.Empty<HistorianSample>());
}
return _protocol.ReadAtTimeAsync(tag, timestampsUtc, cancellationToken);
}
public IAsyncEnumerable<HistorianBlock> ReadBlocksAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
ValidateTimeRange(startUtc, endUtc);
return _protocol.ReadBlocksAsync(tag, startUtc, endUtc, cancellationToken);
}
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
DateTime startUtc,
DateTime endUtc,
CancellationToken cancellationToken = default)
{
ValidateTimeRange(startUtc, endUtc);
return _protocol.ReadEventsAsync(startUtc, endUtc, cancellationToken);
}
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
return HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
}
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
return HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
}
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
{
return _protocol.GetConnectionStatusAsync(cancellationToken);
}
public Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(CancellationToken cancellationToken = default)
{
return _protocol.GetStoreForwardStatusAsync(cancellationToken);
}
public Task<string?> GetSystemParameterAsync(string name, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return _protocol.GetSystemParameterAsync(name, cancellationToken);
}
/// <summary>
/// Creates or updates the named tag in the Historian Runtime database via
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
/// live-verified. Note: writing data values to the new tag (via a separate
/// AddStreamedValue/AddS2 path) is NOT supported by the SDK — see
/// <c>docs/plans/write-commands-reverse-engineering.md</c> for the architectural
/// finding.
/// </summary>
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(definition);
return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
}
/// <summary>
/// Deletes the named tag via <c>DeleteTags</c>. **Known issue (2026-05-04):**
/// the SDK's DelT call returns true but the server-side cascading deletion does
/// not always complete (the row remains in <c>Runtime.dbo.Tag</c>). The
/// captured native flow's DelT removes the tag cleanly, so additional priming
/// or a side call between WCF DelT and server cascade is missing. Use the SMC
/// fallback to clean up sandbox tags until this is resolved.
/// </summary>
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
private static void ValidateTimeRange(DateTime startUtc, DateTime endUtc)
{
if (startUtc.ToUniversalTime() > endUtc.ToUniversalTime())
{
throw new ArgumentException("Start time must be less than or equal to end time.");
}
}
}
@@ -0,0 +1,65 @@
using ZB.MOM.WW.SPHistorianClient.Models;
namespace ZB.MOM.WW.SPHistorianClient;
public sealed class HistorianClientOptions
{
public const int DefaultPort = 32568;
/// <summary>Default TCP port of the 2023 R2 Historian Client Access Point gRPC endpoint.</summary>
public const int DefaultGrpcPort = 32565;
public required string Host { get; init; }
public int Port { get; init; } = DefaultPort;
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30);
public string UserName { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public bool IntegratedSecurity { get; init; }
public bool Compression { get; init; }
public HistorianConnectionKind ConnectionKind { get; init; } = HistorianConnectionKind.Process;
public HistorianTransport Transport { get; init; } = HistorianTransport.LocalPipe;
public string TargetSpn { get; init; } = @"NT SERVICE\aahClientAccessPoint";
/// <summary>
/// When true, the WCF channel factories used by the SDK accept the server's
/// X.509 certificate without chain validation. Useful when connecting to a
/// development / on-prem Historian whose <c>/HistCert</c> endpoint presents an
/// installer-generated self-signed cert that isn't in the local trust store
/// (notably .NET WCF on Linux ignores the system CA bundle for its own
/// X509Chain checks). Default false; do not enable in production where the
/// server's identity matters.
/// </summary>
public bool AllowUntrustedServerCertificate { get; init; }
/// <summary>
/// Overrides the expected DNS identity in the endpoint address — set this to
/// whatever DNS name the server's certificate actually claims (often
/// <c>localhost</c> on installer-generated AVEVA Historian certificates) when
/// connecting via IP address or a hostname that doesn't match the cert SAN/CN.
/// Without this override WCF rejects the channel with
/// "Identity check failed for outgoing message". Has no effect on transports
/// that don't validate a server certificate.
/// </summary>
public string? ServerDnsIdentity { get; init; }
/// <summary>
/// For <see cref="HistorianTransport.RemoteGrpc"/>: when true the channel uses TLS
/// (<c>https://</c>); when false it uses plaintext (<c>http://</c>). Matches the stock
/// 2023 R2 client's <c>securedConnection</c> flag. The TLS host is taken from
/// <see cref="ServerDnsIdentity"/> when set (to match the server certificate's name),
/// otherwise <see cref="Host"/>. When <see cref="AllowUntrustedServerCertificate"/> is
/// true the server certificate chain is not validated. Default false.
/// </summary>
public bool GrpcUseTls { get; init; }
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.SPHistorianClient;
public enum HistorianTransport
{
LocalPipe = 0,
RemoteTcpIntegrated = 1,
RemoteTcpCertificate = 2,
/// <summary>
/// 2023 R2 gRPC transport (Historian Client Access Point gRPC-Web endpoint, default
/// TCP port 32565). Carries the same native binary payloads as the WCF transports inside
/// protobuf <c>bytes</c> fields. See <c>Grpc/HistorianGrpcReadOrchestrator</c>.
/// </summary>
RemoteGrpc = 3
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public enum AggregationType
{
Minimum,
Maximum,
Average,
Total,
Percent,
MinContained,
MaxContained,
TotalContained,
AverageContained,
PercentContained
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public sealed record HistorianAggregateSample(
string TagName,
DateTime StartTimeUtc,
DateTime EndTimeUtc,
double Value,
ushort Quality,
uint QualityDetail,
ushort OpcQuality,
RetrievalMode RetrievalMode,
TimeSpan Resolution);
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public sealed record HistorianBlock(
string TagName,
DateTime StartTimeUtc,
DateTime EndTimeUtc,
IReadOnlyList<HistorianSample> Samples);
@@ -0,0 +1,10 @@
using System;
namespace ZB.MOM.WW.SPHistorianClient.Models;
[Flags]
public enum HistorianConnectionKind
{
Process = 1,
Event = 2
}
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public sealed record HistorianConnectionStatus(
string ServerName,
bool Pending,
bool ErrorOccurred,
string? Error,
bool ConnectedToServer,
bool ConnectedToServerStorage,
bool ConnectedToStoreForward,
HistorianConnectionKind ConnectionKind);
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
/// <summary>
/// AVEVA Historian native tag data types. Existing values (0..10, 13) match the
/// numeric layout the wrapper has historically used; new values (14+) extend the
/// model with types recovered from the native CDataType predicate IL — they aren't
/// part of the original wrapper enum but cover the full native type space.
/// </summary>
public enum HistorianDataType
{
Int1 = 0,
Int2 = 2,
UInt2 = 3,
Int4 = 4,
UInt4 = 5,
Float = 6,
Double = 7,
SingleByteString = 8,
DoubleByteString = 9,
Event = 10,
Structure = 13,
/// <summary>1-byte unsigned integer (native code 0x08).</summary>
UInt1 = 14,
/// <summary>8-byte signed integer (native code 0x19).</summary>
Int8 = 15,
/// <summary>8-byte unsigned integer (native code 0x39).</summary>
UInt8 = 16,
/// <summary>16-byte GUID (native code 0x10, matches CDataType.IsGuid).</summary>
Guid = 17,
/// <summary>Windows FILETIME (8 bytes, 100-ns ticks since 1601-01-01 UTC; native code 0x18, matches CDataType.IsFileTime).</summary>
FileTime = 18
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public sealed record HistorianDataValue(
string TagName,
DateTime TimestampUtc,
double? NumericValue,
string? StringValue,
ushort Quality = 192,
uint QualityDetail = 0);
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public sealed record HistorianEvent(
Guid Id,
DateTime EventTimeUtc,
DateTime ReceivedTimeUtc,
string Type,
string SourceName,
string Namespace,
ushort RevisionVersion,
IReadOnlyDictionary<string, object?> Properties);
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public sealed record HistorianSample(
string TagName,
DateTime TimestampUtc,
double? NumericValue,
string? StringValue,
ushort Quality,
uint QualityDetail,
ushort OpcQuality,
double PercentGood);
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
/// <summary>
/// Storage strategy for historized samples. Maps to <c>Tag.StorageType</c> in the
/// Runtime DB. Values match the captured native enum and the server-persisted
/// integer column.
/// </summary>
public enum HistorianStorageType
{
/// <summary>
/// Sample on a fixed cadence (see <c>HistorianTagDefinition.StorageRateMs</c>).
/// </summary>
Cyclic = 1,
/// <summary>
/// Sample only on value change (with optional value/time/rate deadbands).
/// </summary>
Delta = 2,
}
@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public sealed record HistorianStoreForwardStatus(
string ServerName,
bool Pending,
bool ErrorOccurred,
string? Error,
bool DataStored,
bool Storing,
HistorianConnectionKind ConnectionKind);
@@ -0,0 +1,84 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
/// <summary>
/// Input model for <see cref="HistorianClient.EnsureTagAsync"/>. Live-verified data
/// types: Float, Double, Int2, Int4, UInt4 (probed 2026-05-04 via instrument-wcf-writemessage).
/// String/Int1/Int8/UInt8 types failed at native AddTag — likely require a different
/// path and are intentionally not supported. MinEU/MaxEU/MinRaw/MaxRaw are now encoded
/// into the wire payload (see <c>HistorianTagWriteProtocol</c>).
///
/// Semantics: <c>EnsureTagAsync</c> is an upsert. Calling it twice on the same
/// <see cref="TagName"/> with different fields succeeds both times; the second call
/// updates Description, MinEU, MaxEU, MinRaw, MaxRaw, and AnalogTag.Scaling on the
/// existing row (verified 2026-05-04 by direct SQL inspection after sequential calls).
/// </summary>
public sealed record HistorianTagDefinition
{
/// <summary>Tag name (ASCII; up to 255 chars per server limit).</summary>
public required string TagName { get; init; }
/// <summary>Tag description (free text; up to 255 chars).</summary>
public string? Description { get; init; }
/// <summary>Engineering unit label (e.g. "Seconds", "kPa"). Required for analog tags.</summary>
public string? EngineeringUnit { get; init; }
/// <summary>Native data type. Float, Double, Int2, Int4, UInt4 are live-verified.</summary>
public HistorianDataType DataType { get; init; } = HistorianDataType.Float;
/// <summary>Engineering-units lower bound. Default 0.</summary>
public double MinEU { get; init; }
/// <summary>Engineering-units upper bound. Default 100.</summary>
public double MaxEU { get; init; } = 100.0;
/// <summary>
/// Raw lower bound (pre-scaling). Default 0. Persisted distinctly only when
/// <see cref="ApplyScaling"/> is true; with ApplyScaling=false the server mirrors
/// this to MinEU on EnsureTags2 (verified 2026-05-04 against both native and
/// managed clients).
/// </summary>
public double MinRaw { get; init; }
/// <summary>
/// Raw upper bound (pre-scaling). Default 100. See <see cref="MinRaw"/> for the
/// ApplyScaling caveat.
/// </summary>
public double MaxRaw { get; init; } = 100.0;
/// <summary>
/// When true, the server persists <see cref="MinRaw"/> / <see cref="MaxRaw"/> as
/// distinct values from <see cref="MinEU"/> / <see cref="MaxEU"/> and sets
/// <c>AnalogTag.Scaling</c> = 1. When false (default), the server mirrors MinRaw
/// to MinEU and MaxRaw to MaxEU and sets <c>AnalogTag.Scaling</c> = 0.
/// </summary>
public bool ApplyScaling { get; init; }
/// <summary>
/// Storage rate in milliseconds. Default 1000ms. The server only accepts
/// quantized values (observed valid set: 1000, 5000, 10000, 60000, 300000) —
/// non-quantized values cause <see cref="HistorianClient.EnsureTagAsync"/> to
/// return false.
/// </summary>
public uint StorageRateMs { get; init; } = 1000u;
/// <summary>
/// Storage strategy. Default <see cref="HistorianStorageType.Cyclic"/> samples
/// on the configured <see cref="StorageRateMs"/> cadence. <see cref="HistorianStorageType.Delta"/>
/// samples only on value change. The server persists this to <c>Tag.StorageType</c>
/// (Cyclic = 1, Delta = 2).
/// </summary>
public HistorianStorageType StorageType { get; init; } = HistorianStorageType.Cyclic;
/// <summary>
/// Divisor applied when storing integral values for trend integration. Default 1.0.
/// Wire bytes flip correctly per the captured native serializer, but live testing
/// 2026-05-05 showed the server stores <c>IntegralDivisor</c> on
/// <c>EngineeringUnit</c> (shared across all tags using that EU) rather than
/// per-tag — so a non-default value sent here is accepted on the wire but does
/// not visibly persist in <c>EngineeringUnit.IntegralDivisor</c> for the test
/// EU. Exposed for completeness and forward-compatibility; check your server's
/// behavior before relying on it.
/// </summary>
public double IntegralDivisor { get; init; } = 1.0;
}
@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public sealed record HistorianTagMetadata(
string Name,
uint? Key,
HistorianDataType DataType,
string? Description = null,
string? EngineeringUnit = null,
double? MinRaw = null,
double? MaxRaw = null);
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public enum InterpolationType
{
StairStep = 0,
Linear = 1,
SystemDefault = 254,
None = 255
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public enum QualityRule
{
Extended,
Good,
None,
Optimistic
}
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public enum RetrievalMode
{
Cyclic,
Delta,
Full,
Interpolated,
BestFit,
TimeWeightedAverage,
MinimumWithTime,
MaximumWithTime,
Integral,
Slope,
Counter,
ValueState,
RoundTrip,
StartBound,
EndBound
}
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public enum TimestampRule
{
Start,
End,
None
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.SPHistorianClient.Models;
public enum ValueSelector
{
Auto = 1,
First,
Last,
Integral,
StandardDeviation,
Minimum,
Maximum,
Average
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
internal sealed class FrameFormatException : Exception
{
public FrameFormatException(string message)
: base(message)
{
}
}
@@ -0,0 +1,81 @@
using ZB.MOM.WW.SPHistorianClient.Grpc;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf;
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
internal sealed class Historian2020ProtocolDialect
{
private readonly HistorianClientOptions _options;
public Historian2020ProtocolDialect(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
private bool UseGrpc => _options.Transport == HistorianTransport.RemoteGrpc;
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
{
return UseGrpc
? new HistorianGrpcReadOrchestrator(_options).ReadRawAsync(tag, startUtc, endUtc, maxValues, cancellationToken)
: new HistorianWcfReadOrchestrator(_options).ReadRawAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
}
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken)
{
return UseGrpc
? new HistorianGrpcReadOrchestrator(_options).ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, cancellationToken)
: new HistorianWcfReadOrchestrator(_options).ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
}
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return UseGrpc
? new HistorianGrpcReadOrchestrator(_options).ReadAtTimeAsync(tag, timestampsUtc, cancellationToken)
: new HistorianWcfReadOrchestrator(_options).ReadAtTimeAsync(tag, timestampsUtc, cancellationToken);
}
public IAsyncEnumerable<HistorianBlock> ReadBlocksAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
{
return Missing<HistorianBlock>("StartBlockRetrievalQuery", cancellationToken);
}
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
{
HistorianWcfEventOrchestrator orchestrator = new(_options);
return orchestrator.ReadEventsAsync(startUtc, endUtc, cancellationToken);
}
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken);
}
public Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
}
public Task<string?> GetSystemParameterAsync(string name, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
}
private static async IAsyncEnumerable<T> Missing<T>(
string operation,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
throw new ProtocolEvidenceMissingException(operation);
#pragma warning disable CS0162
yield break;
#pragma warning restore CS0162
}
}
@@ -0,0 +1,50 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
internal static class HistorianBinaryPrimitives
{
public static long ToFileTimeUtc(DateTime value)
{
return value.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(value, DateTimeKind.Utc).ToFileTimeUtc()
: value.ToUniversalTime().ToFileTimeUtc();
}
public static void WriteUInt16LittleEndian(Stream stream, ushort value)
{
Span<byte> buffer = stackalloc byte[sizeof(ushort)];
BinaryPrimitives.WriteUInt16LittleEndian(buffer, value);
stream.Write(buffer);
}
public static void WriteUInt32LittleEndian(Stream stream, uint value)
{
Span<byte> buffer = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
stream.Write(buffer);
}
public static void WriteUInt64LittleEndian(Stream stream, ulong value)
{
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
stream.Write(buffer);
}
public static void WriteFileTimeUtc(Stream stream, DateTime value)
{
WriteUInt64LittleEndian(stream, unchecked((ulong)ToFileTimeUtc(value)));
}
public static void WriteUtf16NullTerminated(Stream stream, string value)
{
ArgumentNullException.ThrowIfNull(value);
byte[] bytes = Encoding.Unicode.GetBytes(value);
stream.Write(bytes);
stream.WriteByte(0);
stream.WriteByte(0);
}
}
@@ -0,0 +1,75 @@
using ZB.MOM.WW.SPHistorianClient.Transport;
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
internal sealed class HistorianConnection : IAsyncDisposable
{
private readonly HistorianClientOptions _options;
private readonly IHistorianTransport _transport;
public HistorianConnection(HistorianClientOptions options, IHistorianTransport transport)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
}
public async ValueTask ConnectTcpAsync(CancellationToken cancellationToken)
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_options.ConnectTimeout);
await _transport.ConnectAsync(_options, timeout.Token).ConfigureAwait(false);
}
public ValueTask OpenProtocolSessionAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
throw new ProtocolEvidenceMissingException("OpenConnection handshake");
}
public async ValueTask SendFrameAsync(HistorianFrame frame, CancellationToken cancellationToken)
{
byte[] buffer = HistorianFrameWriter.ToArray(frame);
await _transport.SendAsync(buffer, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<HistorianFrame> ReceiveFrameAsync(CancellationToken cancellationToken)
{
using MemoryStream frameBytes = new();
byte[] header = new byte[HistorianFrameReader.HeaderSize];
await ReadTransportExactlyAsync(header, cancellationToken).ConfigureAwait(false);
frameBytes.Write(header);
int frameLength = BitConverter.ToInt32(header, 0);
if (frameLength < HistorianFrameReader.HeaderSize || frameLength > HistorianFrameReader.MaxFrameSize)
{
throw new FrameFormatException($"Invalid frame length {frameLength}.");
}
byte[] payload = new byte[frameLength - HistorianFrameReader.HeaderSize];
await ReadTransportExactlyAsync(payload, cancellationToken).ConfigureAwait(false);
frameBytes.Write(payload);
frameBytes.Position = 0;
return await HistorianFrameReader.ReadAsync(frameBytes, cancellationToken).ConfigureAwait(false);
}
public ValueTask DisposeAsync()
{
return _transport.DisposeAsync();
}
private async ValueTask ReadTransportExactlyAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
int offset = 0;
while (offset < buffer.Length)
{
int read = await _transport.ReceiveAsync(buffer[offset..], cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw new EndOfStreamException("Unexpected end of stream from Historian transport.");
}
offset += read;
}
}
}
@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
internal readonly record struct HistorianFrame(
HistorianMessageType MessageType,
uint CorrelationId,
ReadOnlyMemory<byte> Payload);
@@ -0,0 +1,45 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
internal static class HistorianFrameReader
{
public const int HeaderSize = 10;
public const int MaxFrameSize = 16 * 1024 * 1024;
public static async ValueTask<HistorianFrame> ReadAsync(Stream stream, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(stream);
byte[] header = new byte[HeaderSize];
await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false);
int frameLength = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(0, 4));
if (frameLength < HeaderSize || frameLength > MaxFrameSize)
{
throw new FrameFormatException($"Invalid frame length {frameLength}.");
}
ushort messageType = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(4, 2));
uint correlationId = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(6, 4));
byte[] payload = new byte[frameLength - HeaderSize];
await ReadExactlyAsync(stream, payload, cancellationToken).ConfigureAwait(false);
return new HistorianFrame((HistorianMessageType)messageType, correlationId, payload);
}
private static async ValueTask ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken cancellationToken)
{
int offset = 0;
while (offset < buffer.Length)
{
int read = await stream.ReadAsync(buffer[offset..], cancellationToken).ConfigureAwait(false);
if (read == 0)
{
throw new EndOfStreamException("Unexpected end of stream while reading Historian frame.");
}
offset += read;
}
}
}
@@ -0,0 +1,27 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
internal static class HistorianFrameWriter
{
public static void Write(Stream stream, HistorianFrame frame)
{
ArgumentNullException.ThrowIfNull(stream);
int frameLength = HistorianFrameReader.HeaderSize + frame.Payload.Length;
Span<byte> header = stackalloc byte[HistorianFrameReader.HeaderSize];
BinaryPrimitives.WriteInt32LittleEndian(header[0..4], frameLength);
BinaryPrimitives.WriteUInt16LittleEndian(header[4..6], (ushort)frame.MessageType);
BinaryPrimitives.WriteUInt32LittleEndian(header[6..10], frame.CorrelationId);
stream.Write(header);
stream.Write(frame.Payload.Span);
}
public static byte[] ToArray(HistorianFrame frame)
{
using MemoryStream stream = new();
Write(stream, frame);
return stream.ToArray();
}
}
@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
internal enum HistorianMessageType : ushort
{
Unknown = 0
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.SPHistorianClient.Protocol;
internal static class HistorianProtocolFacts
{
public const int DefaultTcpPort = 32568;
public const int DataQueryResultRowSizeBytes = 544;
public const int EventQueryFiltersSizeBytes = 72;
public const string QueryTimezone = "UTC";
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.SPHistorianClient;
public sealed class ProtocolEvidenceMissingException : NotSupportedException
{
public ProtocolEvidenceMissingException(string operation)
: base($"Protocol evidence for '{operation}' has not been captured yet. Add sanitized fixtures before enabling this operation.")
{
Operation = operation;
}
public string Operation { get; }
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.SPHistorianClient;
public sealed class ProtocolNotImplementedException : NotImplementedException
{
public ProtocolNotImplementedException(string message)
: base(message)
{
}
}
@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.SPHistorianClient.Transport;
internal interface IHistorianTransport : IAsyncDisposable
{
ValueTask ConnectAsync(HistorianClientOptions options, CancellationToken cancellationToken);
ValueTask SendAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken);
ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.SPHistorianClient.Transport;
internal interface IHistorianTransportFactory
{
IHistorianTransport Create();
}
@@ -0,0 +1,55 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.SPHistorianClient.Transport;
internal sealed class TcpHistorianTransport : IHistorianTransport
{
public static readonly IHistorianTransportFactory Factory = new FactoryImpl();
private TcpClient? _client;
private NetworkStream? _stream;
public async ValueTask ConnectAsync(HistorianClientOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
_client = new TcpClient();
await _client.ConnectAsync(options.Host, options.Port, cancellationToken).ConfigureAwait(false);
_stream = _client.GetStream();
}
public async ValueTask SendAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
{
if (_stream is null)
{
throw new InvalidOperationException("Transport is not connected.");
}
await _stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
if (_stream is null)
{
throw new InvalidOperationException("Transport is not connected.");
}
return await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
}
public ValueTask DisposeAsync()
{
_stream?.Dispose();
_client?.Dispose();
return ValueTask.CompletedTask;
}
private sealed class FactoryImpl : IHistorianTransportFactory
{
public IHistorianTransport Create()
{
return new TcpHistorianTransport();
}
}
}
@@ -0,0 +1,79 @@
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.History, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IHistoryServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract(Name = "Open")]
uint OpenConnection(
string HostName,
string ProcessName,
uint ProcessId,
string UserName,
byte[] Password,
[MessageParameter(Name = "pwdLength")] ushort passwordLength,
byte clientType,
ushort clientVersion,
[MessageParameter(Name = "ConnectionMode")] uint connectionMode,
[MessageParameter(Name = "ConnectionTimeout")] uint connectionTimeout,
ref string StorageSessionId,
out uint Handle,
out long ConnectTime,
out uint ServerStatus);
[OperationContract(Name = "Close")]
uint CloseConnection([MessageParameter(Name = "handle")] uint clientHandle);
[OperationContract(Name = "VldC")]
uint ValidateClient(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "HostName")] string hostName,
[MessageParameter(Name = "ProcessName")] string processName,
[MessageParameter(Name = "ProcessId")] uint processId,
[MessageParameter(Name = "UserName")] string userName,
[MessageParameter(Name = "ConnectTime")] ref long connectTime,
[MessageParameter(Name = "ServerStatus")] out uint serverStatus);
[OperationContract(Name = "UpdC")]
uint UpdateClientStatus(
[MessageParameter(Name = "Hnd")] uint handle,
[MessageParameter(Name = "Stat")] uint status,
[MessageParameter(Name = "TCnt")] uint tagCount,
[MessageParameter(Name = "VCnt")] long valueCount,
[MessageParameter(Name = "VRate")] float valueRate,
[MessageParameter(Name = "SStat")] out uint serverStatus);
[OperationContract(Name = "AddT")]
uint AddTags(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "ElementCount")] uint elementCount,
[MessageParameter(Name = "InByteCount")] uint inByteCount,
[MessageParameter(Name = "pInBuff")] byte[] inputBuffer,
[MessageParameter(Name = "OutByteCount")] out uint outByteCount,
[MessageParameter(Name = "pOutBuff")] out byte[] outputBuffer);
[OperationContract(Name = "RTag")]
uint RegisterTags(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "ElementCount")] uint elementCount,
[MessageParameter(Name = "InByteCount")] uint inByteCount,
[MessageParameter(Name = "pInBuff")] byte[] inputBuffer,
[MessageParameter(Name = "OutByteCount")] out uint outByteCount,
[MessageParameter(Name = "pOutBuff")] out byte[] outputBuffer);
[OperationContract(Name = "AddS")]
uint AddStreamValues(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "Size")] uint size,
[MessageParameter(Name = "pBuf")] byte[] buffer);
[OperationContract(Name = "SetT")]
uint SetClientTimeOut(
[MessageParameter(Name = "Handle")] uint handle,
[MessageParameter(Name = "TimeOut")] int timeout,
[MessageParameter(Name = "pRet")] out uint returnValue);
}
@@ -0,0 +1,140 @@
using System.Runtime.InteropServices;
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.History, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IHistoryServiceContract2 : IHistoryServiceContract
{
[OperationContract(Name = "UpdC2")]
uint UpdateClientStatus2(uint handle, uint clientStatus, uint tagCount, long valueCount, float valueRate, out long areaVersion, out uint serverStatus);
[OperationContract(Name = "EnsT")]
uint EnsureTags(
[MessageParameter(Name = "Handle")] uint handle,
uint elementCount,
[MessageParameter(Name = "InByteCount")] uint inByteCount,
[MessageParameter(Name = "InBuff")] byte[] inBuffer,
[MessageParameter(Name = "OutByteCount")] out uint outByteCount,
[MessageParameter(Name = "OutBuff")] out byte[] outBuffer);
[OperationContract(Name = "DelT")]
[return: MarshalAs(UnmanagedType.U1)]
bool DeleteTags(
uint handle,
uint tagNamesSize,
byte[] tagNames,
ref uint statusSize,
ref byte[] status,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract(Name = "UpdC3")]
[return: MarshalAs(UnmanagedType.U1)]
bool UpdateClientStatus3(
string handle,
uint clientStatusSize,
ref byte[] clientStatus,
out uint serverStatusSize,
out byte[] serverStatus,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract(Name = "Open2")]
[return: MarshalAs(UnmanagedType.U1)]
bool OpenConnection2(
[MessageParameter(Name = "inParameters")] ref byte[] inParameters,
[MessageParameter(Name = "outParameters")] out byte[] outParameters,
[MessageParameter(Name = "err")] out byte[] err);
[OperationContract(Name = "Close2")]
[return: MarshalAs(UnmanagedType.U1)]
bool CloseConnection2(string handle, out byte[] errorBuffer);
[OperationContract(Name = "VldC2")]
[return: MarshalAs(UnmanagedType.U1)]
bool ValidateClient2(
string handle,
[MessageParameter(Name = "HostName")] string hostName,
[MessageParameter(Name = "ProcessName")] string processName,
[MessageParameter(Name = "ProcessId")] uint processId,
[MessageParameter(Name = "UserName")] string userName,
[MessageParameter(Name = "ConnectTime")] ref long connectTime,
[MessageParameter(Name = "ServerStatus")] out uint serverStatus,
out byte[] errorBuffer);
[OperationContract(Name = "RTag2")]
[return: MarshalAs(UnmanagedType.U1)]
bool RegisterTags2(
string handle,
[MessageParameter(Name = "ElementCount")] uint elementCount,
[MessageParameter(Name = "pInBuff")] byte[] inputBuffer,
[MessageParameter(Name = "outBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "AddS2")]
[return: MarshalAs(UnmanagedType.U1)]
bool AddStreamValues2(
string handle,
[MessageParameter(Name = "pBuf")] byte[] buffer,
out byte[] errorBuffer);
[OperationContract(Name = "EnsT2")]
[return: MarshalAs(UnmanagedType.U1)]
bool EnsureTags2(
[MessageParameter(Name = "Handle")] string handle,
uint elementCount,
[MessageParameter(Name = "InBuff")] byte[] inputBuffer,
[MessageParameter(Name = "OutBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "ExKey")]
[return: MarshalAs(UnmanagedType.U1)]
bool ExchangeKey(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
[MessageParameter(Name = "OutBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "AddTEx")]
[return: MarshalAs(UnmanagedType.U1)]
bool AddTagExtendedProperties(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "DelTep")]
[return: MarshalAs(UnmanagedType.U1)]
bool DeleteTagExtendedProperties(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "StJb")]
[return: MarshalAs(UnmanagedType.U1)]
bool StartJob(
string handle,
byte[] jobBuffer,
[MessageParameter(Name = "strJobid")] out string jobId,
out byte[] errorBuffer);
[OperationContract(Name = "GtJb")]
[return: MarshalAs(UnmanagedType.U1)]
bool GetJobStatus(
string handle,
[MessageParameter(Name = "strJobid")] string jobId,
[MessageParameter(Name = "jobstatus")] out byte[] jobStatus,
out byte[] errorBuffer);
[OperationContract(Name = "ValCl")]
[return: MarshalAs(UnmanagedType.U1)]
bool ValidateClientCredential(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
[MessageParameter(Name = "outBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "GetI")]
[return: MarshalAs(UnmanagedType.U1)]
bool GetInfo(string request, out byte[] info, out byte[] errorBuffer);
}
@@ -0,0 +1,57 @@
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
internal enum InsqlTagType
{
All = 0
}
[ServiceContract(Name = HistorianWcfServiceNames.Retrieval, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IRetrievalServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract]
uint StartQuery(
uint clientHandle,
ushort queryRequestType,
uint requestSize,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
out uint responseSize,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
ref uint queryHandle);
[OperationContract]
uint GetNextQueryResultBuffer(
uint clientHandle,
uint queryHandle,
out uint resultSize,
[MessageParameter(Name = "pResultBuff")] out byte[] resultBuffer,
out uint errorCode);
[OperationContract]
uint EndQuery(uint clientHandle, uint queryHandle);
[OperationContract]
uint GetTagTypeFromName(uint clientHandle, string tagName, out uint tagType);
[OperationContract]
uint IsOriginalAllowed(uint clientHandle, out bool isAllowed);
[OperationContract]
uint IsManualTag(uint clientHandle, string tagName, out bool isManual);
[OperationContract]
uint IsTagnameValid(uint clientHandle, string tagName, bool isWide, InsqlTagType tagType, out bool isValid);
[OperationContract]
uint StartLikeTagNameSearch(uint clientHandle, string tagNameFilter, uint tagType, bool isNotLike);
[OperationContract]
uint GetLikeTagnames(uint clientHandle, out byte[] tagNameBuffer, out uint tagNameBufferSize, out bool isMore);
[OperationContract]
uint GetTagInfoFromName(uint clientHandle, string tagName, out uint tagMetadataByteCount, out byte[] tagMetadata);
}
@@ -0,0 +1,41 @@
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Retrieval, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IRetrievalServiceContract2 : IRetrievalServiceContract
{
[OperationContract(Name = "GetTg")]
uint GetTagInfosFromId(uint handle, uint tagIdsSize, byte[] tagIds, ref uint sequence, out uint tagInfosSize, out byte[] tagInfos);
[OperationContract(Name = "GetTgByNm")]
uint GetTagInfosFromName(uint handle, uint tagNamesSize, byte[] tagNames, ref uint sequence, out uint tagInfosSize, out byte[] tagInfos);
[OperationContract]
bool StartQuery2(
uint clientHandle,
ushort queryRequestType,
uint requestSize,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
out uint responseSize,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
ref uint queryHandle,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool GetNextQueryResultBuffer2(
uint clientHandle,
uint queryHandle,
out uint resultSize,
[MessageParameter(Name = "pResultBuff")] out byte[] resultBuffer,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool EndQuery2(
uint clientHandle,
uint queryHandle,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
}
@@ -0,0 +1,45 @@
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Retrieval, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IRetrievalServiceContract3 : IRetrievalServiceContract2
{
[OperationContract(Name = "ExeC")]
bool ExecuteSqlCommand(
string handle,
string command,
uint option,
ref uint queryHandle,
[MessageParameter(Name = "retValue")] out int returnValue,
out uint errorSize,
out byte[] errorBuffer);
[OperationContract(Name = "GetR")]
bool GetRecordSetByteStream(
string handle,
uint queryHandle,
ref uint sequence,
out uint resultSize,
[MessageParameter(Name = "pResultBuff")] out byte[] resultBuffer,
out uint errorSize,
out byte[] errorBuffer);
[OperationContract(Name = "QTB")]
bool StartTagQuery(
string handle,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "QTG")]
bool QueryTag(
string handle,
ref uint queryId,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "QTE")]
bool EndTagQuery(string handle, ref uint queryId, out byte[] errorBuffer);
}
@@ -0,0 +1,51 @@
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Retrieval, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IRetrievalServiceContract4 : IRetrievalServiceContract3
{
[OperationContract]
bool StartEventQuery(
uint clientHandle,
ushort queryRequestType,
uint requestSize,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
out uint responseSize,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
ref uint queryHandle,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool GetNextEventQueryResultBuffer(
uint clientHandle,
uint queryHandle,
out uint resultSize,
[MessageParameter(Name = "pResultBuff")] out byte[] resultBuffer,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool EndEventQuery(
uint clientHandle,
uint queryHandle,
[MessageParameter(Name = "errSize")] out uint errorSize,
[MessageParameter(Name = "err")] out byte[] errorBuffer);
[OperationContract]
bool GetTagidsByTagnameAndSource(string handle, byte[] tagNameIds, out byte[] tagIds, out byte[] errorBuffer);
[OperationContract]
bool GetShardTagidsByTagnameAndSource(
string handle,
byte[] tagNameIds,
[MessageParameter(Name = "shardTagids")] out byte[] shardTagIds,
out byte[] errorBuffer);
[OperationContract(Name = "GetTgByNm2")]
bool GetTagInfosFromName2(string handle, byte[] tagNames, ref uint sequence, out byte[] tagInfos, out byte[] errorBuffer);
[OperationContract(Name = "GetTepByNm")]
bool GetTagExtendedPropertiesFromName(string handle, byte[] tagNames, ref uint sequence, out byte[] tagExtendedProperties, out byte[] errorBuffer);
}
@@ -0,0 +1,37 @@
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Status, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IStatusServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract]
uint GetServerTime(out byte[] systemTime, out uint systemTimeSize);
[OperationContract]
uint LogError(
uint clientHandle,
int errorLevel,
int destination,
int queueTime,
int errorCode,
int lineNumber,
int hasParam,
int moduleId,
int systemError,
string hostName,
string file,
string stringParameter);
[OperationContract]
uint GetTimeZoneInfo(uint handle, string timeZoneName, out bool isDaylight, out byte[] timeZoneInfo);
[OperationContract]
uint IsDBCaseSensitive(uint handle, out bool isCaseSensitive);
[OperationContract]
uint GetSystemTimeZoneName(uint clientHandle, out string systemTimeZoneName);
}
@@ -0,0 +1,39 @@
using System.Runtime.InteropServices;
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Status, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IStatusServiceContract2 : IStatusServiceContract
{
[OperationContract]
uint GetTimeZoneNames(uint clientHandle, ref uint sequence, out uint bufferSize, out byte[] buffer);
[OperationContract]
uint IsLicenseFeatureEnabled(uint clientHandle, int feature, out bool isEnabled);
[OperationContract]
[return: MarshalAs(UnmanagedType.U1)]
bool GetSystemParameter(
uint clientHandle,
string parameterName,
out string parameterValue,
out uint errorSize,
out byte[] errorBuffer);
[OperationContract(Name = "GETHI")]
[return: MarshalAs(UnmanagedType.U1)]
bool GetHistorianInfo(
string handle,
[MessageParameter(Name = "pRequestBuff")] byte[] requestBuffer,
[MessageParameter(Name = "pResponseBuff")] out byte[] responseBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "PNGS")]
[return: MarshalAs(UnmanagedType.U1)]
bool PingServer(string handle, string pipeName, uint timeout, ref byte[] errorBuffer);
[OperationContract(Name = "PNGP")]
[return: MarshalAs(UnmanagedType.U1)]
bool PingPipe(string handle, string pipeName, ref byte[] errorBuffer);
}
@@ -0,0 +1,129 @@
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Storage, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface IStorageServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract(Name = "Open")]
uint OpenStorageConnection(
string hostName,
string enginePath,
uint freeDiskSpace,
string processName,
uint processId,
string userName,
byte[] password,
ushort passwordLength,
byte clientType,
ushort clientVersion,
uint connectionMode,
uint connectionTimeout,
ref string storageSessionId,
out uint handle,
out long connectTime,
out uint storageStatus);
[OperationContract(Name = "Close")]
uint CloseStorageConnection(uint handle);
[OperationContract(Name = "Ping")]
uint Ping(uint handle, out uint outByteCount, out byte[] outputBuffer);
[OperationContract(Name = "AddT")]
uint AddTags(uint handle, uint elementCount, uint inByteCount, byte[] inputBuffer, out uint outByteCount, out byte[] outputBuffer);
[OperationContract(Name = "RTag")]
uint RegisterTags(uint handle, uint elementCount, uint inByteCount, byte[] inputBuffer, out uint outByteCount, out byte[] outputBuffer);
[OperationContract(Name = "AddS")]
uint AddStreamValues(uint handle, uint size, byte[] buffer);
[OperationContract(Name = "GetId")]
uint GetTagIds(uint handle, ref uint sequence, out uint tagIdsSize, out byte[] tagIds);
[OperationContract(Name = "GetTg")]
uint GetTags(uint handle, uint tagIdsSize, byte[] tagIds, ref uint sequence, out uint tagInfosSize, out byte[] tagInfos);
[OperationContract(Name = "FlshMD")]
uint FlushMetadata(uint handle, uint tagIdsSize, byte[] tagIds);
[OperationContract(Name = "Flush")]
uint FlushData(uint handle);
[OperationContract(Name = "LoadB")]
uint LoadBlocks(uint handle, ref uint sequence, out uint historyBlocksSize, out byte[] historyBlocks);
[OperationContract(Name = "GetSS")]
uint GetSnapshots(uint handle, long blockStartTime, ref uint sequence, out uint snapshotSize, out byte[] snapshot);
[OperationContract(Name = "QSS")]
uint StartQuerySnapshot(uint handle, long blockStartTime, uint snapshotInfoSize, ref byte[] snapshotInfo, ref uint snapshotQueryId);
[OperationContract(Name = "NxtQSS")]
uint NextQuerySnapshot(uint handle, uint snapshotQueryId, ref uint sequence, out uint snapshotSize, out byte[] snapshot);
[OperationContract(Name = "EndSS")]
uint EndSnapshot(uint handle, uint snapshotQueryId, long blockStartTime, uint snapshotInfoSize, ref byte[] snapshotInfo, bool isDeleteSnapshot);
[OperationContract(Name = "Stop")]
uint Stop(uint handle);
[OperationContract(Name = "ClrTP")]
uint ClearTagIdPairs(uint handle);
[OperationContract(Name = "AddTP")]
uint AddTagIdPairs(uint handle, uint elementCount, uint inByteCount, byte[] inputBuffer);
[OperationContract(Name = "GetSFP")]
bool GetStoreForwardParameter(uint clientHandle, string parameterName, out string parameterValue, out uint errorSize, out byte[] error);
[OperationContract(Name = "SetSFP")]
bool SetStoreForwardParameter(uint clientHandle, string parameterName, ref string parameterValue, out uint errorSize, out byte[] error);
[OperationContract]
bool SendSnapshotBegin(uint handle, ulong totalSize, ulong startTime, ulong endTime, ref string storageSessionIdString, ref uint queryId, out uint errorSize, out byte[] error);
[OperationContract]
bool SendSnapshotEnd(uint handle, string storageSessionIdString, uint queryId, uint timeRangeSize, byte[] timeRangeBytes, out uint errorSize, out byte[] error);
[OperationContract]
bool SendSnapshot(uint handle, string storageSessionIdString, uint queryId, uint size, ulong snapshotChunkOffset, byte[] buffer, out uint errorSize, out byte[] error);
[OperationContract]
bool DeleteSnapshot(uint clientHandle, ulong startTime, uint snapshotInfoSize, ref byte[] snapshotInfo, out uint errorSize, out byte[] errorBuffer);
[OperationContract(Name = "AddS2")]
bool AddStreamValues2(uint handle, string shardIdString, byte[] buffer, out byte[] errorBuffer);
[OperationContract(Name = "ClrST")]
bool ClearShardTagIds(uint handle, out byte[] errorBuffer);
[OperationContract(Name = "AddST")]
bool AddShardTagIds(uint handle, byte[] buffer, out byte[] errorBuffer);
[OperationContract(Name = "SpltS")]
bool SplitUnknownShards(uint handle, out byte[] errorBuffer);
[OperationContract(Name = "GetR")]
bool GetRemainingSnapshotsSize(uint handle, ref ulong snapshotSize, out byte[] errorBuffer);
[OperationContract(Name = "DelT")]
bool DeleteTags(uint handle, byte[] buffer, out byte[] errorBuffer);
[OperationContract(Name = "Open2")]
bool OpenStorageConnection2(ref byte[] inputParameters, out byte[] outputParameters, out byte[] error);
[OperationContract(Name = "ValCl")]
bool ValidateClientCredential(
string handle,
[MessageParameter(Name = "inBuff")] byte[] inputBuffer,
[MessageParameter(Name = "outBuff")] out byte[] outputBuffer,
out byte[] errorBuffer);
[OperationContract(Name = "GetI")]
bool GetInfo(string request, out byte[] info, out byte[] errorBuffer);
}
@@ -0,0 +1,66 @@
using System.Runtime.InteropServices;
using System.ServiceModel;
namespace ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
[ServiceContract(Name = HistorianWcfServiceNames.Transaction, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface ITransactionServiceContract
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract]
uint ForwardSnapshotBegin(uint handle, ulong totalSize, ulong startTime, ulong endTime, ref string storageSessionIdString, ref uint queryId);
[OperationContract]
uint ForwardSnapshotEnd(uint handle, string storageSessionIdString, uint queryId, uint timeRangeSize, byte[] timeRangeBytes);
[OperationContract]
uint ForwardSnapshot(uint handle, string storageSessionIdString, uint queryId, uint size, ulong snapshotChunkOffset, byte[] buffer);
[OperationContract]
uint AddNonStreamValuesBegin(uint handle, out string transactionId);
[OperationContract]
uint AddNonStreamValues(uint handle, string transactionId, uint size, byte[] buffer);
[OperationContract]
uint AddNonStreamValuesEnd(uint handle, string transactionId, bool commit);
}
/// <remarks>
/// V2 surface — discovered by inspecting CHistoryConnectionWCF.AddNonStreamValuesBegin's
/// IL (token 0x06004051), which calls
/// <c>ITransactionServiceContract2::AddNonStreamValuesBegin2(string, ref string, ref byte[])</c>
/// before falling back to V1. The V2 ops use the GUID-string handle pattern matching
/// other V2 ops on /Hist (EnsT2, AddS2, RTag2) plus an out-byte[] errorBuffer.
/// </remarks>
[ServiceContract(Name = HistorianWcfServiceNames.Transaction, Namespace = HistorianWcfServiceNames.Namespace)]
internal interface ITransactionServiceContract2
{
[OperationContract(Name = "GetV")]
uint GetInterfaceVersion(out uint version);
[OperationContract]
[return: MarshalAs(UnmanagedType.U1)]
bool AddNonStreamValuesBegin2(
string handle,
out string transactionId,
out byte[] errorBuffer);
[OperationContract]
[return: MarshalAs(UnmanagedType.U1)]
bool AddNonStreamValues2(
string handle,
string transactionId,
[MessageParameter(Name = "pBuf")] byte[] buffer,
out byte[] errorBuffer);
[OperationContract]
[return: MarshalAs(UnmanagedType.U1)]
bool AddNonStreamValuesEnd2(
string handle,
string transactionId,
[MarshalAs(UnmanagedType.U1)] bool commit,
out byte[] errorBuffer);
}
@@ -0,0 +1,94 @@
using System.Text;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// CTagMetadata serialiser for the CM_EVENT default-event-tag registration that the AVEVA
/// native wrapper performs via <c>IHistoryServiceContract2.EnsureTags2</c> (WCF op
/// <c>EnsT2</c>) before any event read can return rows. The action URI on the wire is
/// <c>aa/Hist/EnsT2</c>, not the previously-suspected <c>aa/Hist/AddT</c>. Layout
/// captured byte-for-byte from a successful native event read via the
/// <c>instrument-wcf-writemessage</c> IL-rewrite tooling on
/// <c>aahMDASEncoder.ClientMessageEncoder.WriteMessage</c>:
///
/// <code>
/// byte version = 3
/// ushort optional-mask = 0x0086
/// byte CDataType = 5
/// 16 bytes tag id GUID = 353b8145-5df0-4d46-a253-871aef49b321
/// compact ASCII tag name "CM_EVENT"
/// compact ASCII description "AnE Event"
/// 7 bytes 0x02 0x02 0x01 0x00 0x00 0x00 0x01 (storage type 2 + flags; LAST BYTE IS 0x01)
/// uint32 storage rate = 0
/// int64 created FILETIME UTC
/// 16 bytes common Archestra event type GUID = 5f59ae42-3bb6-4760-91a5-ab0be01f9f02
/// (note: this differs from the previously-documented ...e01f2f27 — the captured
/// native bytes use ...9f02. The earlier docs were inferred from
/// ConvertEventTagToTagMetadata IL inspection without the wire capture.)
/// 3 trailing bytes 0x2F 0x27 0x01 (purpose unknown; appears stable across captures)
/// </code>
///
/// Earlier probe attempts via the (wrong) <c>AddT</c> WCF op + a payload with the
/// (wrong) trailer order returned server failures. Routing through <c>EnsT2</c> with
/// this exact byte layout is the path the native wrapper uses.
/// </remarks>
internal static class HistorianAddTagsProtocol
{
public static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321");
/// <remarks>
/// Captured native byte sequence is `42 AE 59 5F B6 3B 60 47 91 A5 AB 0B E0 1F 9F 02`,
/// which decodes to GUID `5f59ae42-3bb6-4760-91a5-ab0be01f9f02`. Prior notes documented
/// `5f59ae42-3bb6-4760-91a5-ab0be01f2f27` from IL inspection — the wire capture is the
/// authoritative value.
/// </remarks>
public static readonly Guid CommonArchestraEventTypeId = new("5f59ae42-3bb6-4760-91a5-ab0be01f9f02");
public static byte[] SerializeCmEventCTagMetadata(DateTime createdUtc)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((byte)3);
writer.Write((ushort)0x0086);
writer.Write((byte)5);
writer.Write(CmEventTagId.ToByteArray());
WriteCompressedHistorianString(writer, "CM_EVENT");
WriteCompressedHistorianString(writer, "AnE Event");
writer.Write(new byte[] { 0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01 });
writer.Write(0u);
writer.Write(createdUtc.ToUniversalTime().ToFileTimeUtc());
writer.Write(CommonArchestraEventTypeId.ToByteArray());
// 5-byte tail captured byte-for-byte from native: 2F 27 01 01 01.
writer.Write(new byte[] { 0x2F, 0x27, 0x01, 0x01, 0x01 });
return stream.ToArray();
}
private static void WriteCompressedHistorianString(BinaryWriter writer, string value)
{
if (value.Length == 0)
{
writer.Write((byte)0);
return;
}
if (value.Length > byte.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(value), "Compact CTagMetadata strings only support short ASCII payloads.");
}
writer.Write((byte)0x09);
writer.Write((byte)value.Length);
writer.Write((byte)0);
foreach (char character in value)
{
if (character > byte.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(value), "Compact CTagMetadata strings only support ASCII characters.");
}
writer.Write((byte)character);
}
}
}
@@ -0,0 +1,379 @@
using System.Buffers.Binary;
using System.Text;
using ZB.MOM.WW.SPHistorianClient.Models;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianDataQueryProtocol
{
public const ushort QueryRequestTypeData = 1;
private const ushort GetNextResultBufferVersion = 9;
private const int GetNextResultBufferHeaderSize = 6;
private const int GetNextResultRowFixedTailSize = 75;
private const byte TerminalErrorType = 4;
private const uint TerminalErrorCodeNoMoreData = 30;
/// <remarks>
/// Walks the WCF GetNextQueryResultBuffer2 result body for raw/Full retrieval. Layout (decoded from
/// the canonical OtOpcUaParityTest_001.Counter capture, 4 rows × 141 bytes inside a 570-byte body):
/// header is UInt16 version=9 + UInt32 rowCount; each row is UInt32 tagKey + UInt32 tagNameLen +
/// (tagNameLen × 2) UTF-16 chars + UInt32 sampleCount + Int64 startUtc FILETIME + UInt32 quality +
/// UInt32 qualityDetail + UInt32 opcQuality + Double numericValue + Double percentGood + 35-byte
/// trailing block. The 5-byte error/terminal buffer accompanying the result decodes as
/// `04 1E 00 00 00` = type 4, code 30 = "no more data"; any other shape leaves
/// <paramref name="hasMoreData"/> true.
///
/// Trailing 35 bytes (cross-tag verified 2026-05-04 against SysTimeSec — structure is
/// tag-independent, server-internal sample metadata):
/// bytes 0-2 constant 0x00 0x00 0x01 (sample-format marker)
/// bytes 3-10 Int64 FILETIME UTC — duplicate of startTime for raw rows;
/// aggregate parser reads it as the interval start (offset row+tail+43)
/// bytes 11-18 zeros (reserved — likely end-time slot, populated by aggregate variants)
/// bytes 19-26 varies row-to-row even for identical Quality/Value; likely a storage
/// block sequence ID or snapshot offset. No user-facing meaning surfaced.
/// bytes 27,29 flag bytes (0/1 and 0/4 observed); semantics undecoded
/// bytes 28, 30-34 zeros (reserved)
/// No public HistorianSample fields map to bytes 19-34 — they look like server-internal
/// storage metadata. If a customer ever needs them surfaced, capture more rows with
/// known-distinct properties (force-store, backfill, version-replace) to narrow down.
/// </remarks>
public static bool TryParseGetNextQueryResultBufferRows(
ReadOnlySpan<byte> result,
ReadOnlySpan<byte> errorTerminal,
out IReadOnlyList<HistorianSample> rows,
out bool hasMoreData)
{
rows = [];
hasMoreData = !IsTerminalNoMoreData(errorTerminal);
if (result.Length == 0)
{
return true;
}
if (result.Length < GetNextResultBufferHeaderSize)
{
return false;
}
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(result[..2]);
if (version != GetNextResultBufferVersion)
{
return false;
}
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(result.Slice(2, 4));
int cursor = GetNextResultBufferHeaderSize;
List<HistorianSample> parsed = new(checked((int)rowCount));
for (uint i = 0; i < rowCount; i++)
{
if (cursor + 8 > result.Length)
{
return false;
}
uint tagNameChars = BinaryPrimitives.ReadUInt32LittleEndian(result.Slice(cursor + 4, 4));
int tagNameByteLength = checked((int)(tagNameChars * 2));
int rowSize = checked(8 + tagNameByteLength + GetNextResultRowFixedTailSize);
if (cursor + rowSize > result.Length)
{
return false;
}
ReadOnlySpan<byte> row = result.Slice(cursor, rowSize);
string tagName = Encoding.Unicode.GetString(row.Slice(8, tagNameByteLength));
int tail = 8 + tagNameByteLength;
long startTimeFileTimeUtc = BinaryPrimitives.ReadInt64LittleEndian(row.Slice(tail + 4, 8));
uint quality = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 12, 4));
uint qualityDetail = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 16, 4));
uint opcQuality = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 20, 4));
double numericValue = BinaryPrimitives.ReadDoubleLittleEndian(row.Slice(tail + 24, 8));
double percentGood = BinaryPrimitives.ReadDoubleLittleEndian(row.Slice(tail + 32, 8));
parsed.Add(new HistorianSample(
TagName: tagName,
TimestampUtc: DateTime.FromFileTimeUtc(startTimeFileTimeUtc),
NumericValue: numericValue,
StringValue: null,
Quality: checked((ushort)quality),
QualityDetail: qualityDetail,
OpcQuality: checked((ushort)opcQuality),
PercentGood: percentGood));
cursor += rowSize;
}
rows = parsed;
return true;
}
/// <remarks>
/// Same wire layout as the raw parser, but interprets FILETIME #1 at row offset
/// `8 + tagNameLen*2 + 4` as the interval END timestamp and FILETIME #2 at trailer
/// offset 2 (row offset `8 + tagNameLen*2 + 43`) as the interval START. Native struct
/// evidence (`getnextrow-interpolated-memory-latest.json` /
/// `getnextrow-timeweightedaverage-memory-latest.json`) maps `+0x28 = EndDateTime`
/// and `+0x150 = StartDateTime`; the wire FILETIME #1 sits in the EndDateTime slot
/// after marshaling. For raw rows where Start == End the two values are equal, which
/// is consistent with the captured fixture. Live aggregate verification will
/// confirm or correct this orientation.
/// </remarks>
public static bool TryParseGetNextQueryResultBufferAggregateRows(
ReadOnlySpan<byte> result,
ReadOnlySpan<byte> errorTerminal,
Models.RetrievalMode mode,
TimeSpan resolution,
out IReadOnlyList<HistorianAggregateSample> rows,
out bool hasMoreData)
{
rows = [];
hasMoreData = !IsTerminalNoMoreData(errorTerminal);
if (result.Length == 0)
{
return true;
}
if (result.Length < GetNextResultBufferHeaderSize)
{
return false;
}
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(result[..2]);
if (version != GetNextResultBufferVersion)
{
return false;
}
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(result.Slice(2, 4));
int cursor = GetNextResultBufferHeaderSize;
List<HistorianAggregateSample> parsed = new(checked((int)rowCount));
for (uint i = 0; i < rowCount; i++)
{
if (cursor + 8 > result.Length)
{
return false;
}
uint tagNameChars = BinaryPrimitives.ReadUInt32LittleEndian(result.Slice(cursor + 4, 4));
int tagNameByteLength = checked((int)(tagNameChars * 2));
int rowSize = checked(8 + tagNameByteLength + GetNextResultRowFixedTailSize);
if (cursor + rowSize > result.Length)
{
return false;
}
ReadOnlySpan<byte> row = result.Slice(cursor, rowSize);
string tagName = Encoding.Unicode.GetString(row.Slice(8, tagNameByteLength));
int tail = 8 + tagNameByteLength;
long endTimeFileTimeUtc = BinaryPrimitives.ReadInt64LittleEndian(row.Slice(tail + 4, 8));
uint quality = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 12, 4));
uint qualityDetail = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 16, 4));
uint opcQuality = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(tail + 20, 4));
double aggregateValue = BinaryPrimitives.ReadDoubleLittleEndian(row.Slice(tail + 24, 8));
long startTimeFileTimeUtc = BinaryPrimitives.ReadInt64LittleEndian(row.Slice(tail + 43, 8));
parsed.Add(new HistorianAggregateSample(
TagName: tagName,
StartTimeUtc: DateTime.FromFileTimeUtc(startTimeFileTimeUtc),
EndTimeUtc: DateTime.FromFileTimeUtc(endTimeFileTimeUtc),
Value: aggregateValue,
Quality: checked((ushort)quality),
QualityDetail: qualityDetail,
OpcQuality: checked((ushort)opcQuality),
RetrievalMode: mode,
Resolution: resolution));
cursor += rowSize;
}
rows = parsed;
return true;
}
private static bool IsTerminalNoMoreData(ReadOnlySpan<byte> errorTerminal)
{
if (errorTerminal.Length != 5 || errorTerminal[0] != TerminalErrorType)
{
return false;
}
return BinaryPrimitives.ReadUInt32LittleEndian(errorTerminal[1..]) == TerminalErrorCodeNoMoreData;
}
public static byte[] SerializeFullHistoryRequest(HistorianDataQueryRequest request)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
bool noOption = string.Equals(request.Option, "NoOption", StringComparison.Ordinal);
writer.Write(noOption ? (ushort)3 : (ushort)9);
writer.Write((uint)request.QueryType);
writer.Write(request.QueryFormat);
writer.Write(request.SummaryType);
writer.Write(request.StartUtc.ToFileTimeUtc());
writer.Write(request.EndUtc.ToFileTimeUtc());
writer.Write((double)request.Resolution.Ticks);
writer.Write(request.ValueDeadband);
writer.Write(request.TimeDeadband);
WriteHistorianString(writer, request.TimeZone);
writer.Write(request.VersionType);
writer.Write(request.ResultBufferSize);
writer.Write(PackQueryTimeInterpolationFlags(request));
if (!noOption)
{
WriteHistorianString(writer, request.Option);
}
WriteHistorianString(writer, request.Filter);
writer.Write((ushort)request.ValueSelector);
writer.Write((ushort)request.AggregationType);
writer.Write((ushort)1);
writer.Write(request.ColumnSelectorFlags);
WriteStringVector(writer, request.TagNames);
writer.Write(request.MaxStates);
WriteMetadataNamespace(writer, request.MetadataNamespace);
writer.Write(request.ClientVersion);
writer.Write(request.SkipRows);
writer.Write(request.ReservedAfterSkipRows);
WriteRedundantEndpoint(writer, request.MdsEndpoint);
WriteRedundantEndpoint(writer, request.StorageEndpoint);
writer.Write(checked(request.Resolution.Ticks * 10_000L));
WriteStringVector(writer, request.SliceByTagNames);
writer.Write(request.TimeoutQueryProcessingMilliseconds);
WriteAutoSummaryParameters(writer);
return stream.ToArray();
}
private static void WriteMetadataNamespace(BinaryWriter writer, HistorianMetadataNamespace metadataNamespace)
{
writer.Write((byte)1);
WriteScrambledHistorianString(writer, metadataNamespace.Namespace);
WriteScrambledHistorianString(writer, metadataNamespace.TagPrefix);
WriteScrambledHistorianString(writer, metadataNamespace.PropertyPrefix);
}
private static void WriteStringVector(BinaryWriter writer, IReadOnlyList<string> values)
{
writer.Write((uint)values.Count);
foreach (string value in values)
{
WriteHistorianString(writer, value);
}
}
private static void WriteRedundantEndpoint(BinaryWriter writer, HistorianRedundantEndpoint endpoint)
{
writer.Write((ushort)1);
WriteHistorianString(writer, endpoint.EndpointName);
checked
{
writer.Write((ushort)endpoint.Endpoints.Count);
}
foreach (HistorianEndpoint candidate in endpoint.Endpoints)
{
WriteHistorianString(writer, candidate.NodeName);
WriteHistorianString(writer, candidate.PipeName);
}
}
private static void WriteAutoSummaryParameters(BinaryWriter writer)
{
writer.Write((ushort)1);
writer.Write(0L);
writer.Write(0L);
for (int index = 0; index < 5; index++)
{
writer.Write((byte)0);
}
writer.Write(0u);
}
private static ushort PackQueryTimeInterpolationFlags(HistorianDataQueryRequest request)
{
ushort interpolation = request.InterpolationType == 254 ? (ushort)255 : request.InterpolationType;
return checked((ushort)((request.QualityRule << 12) | (request.TimestampRule << 8) | interpolation));
}
private static void WriteHistorianString(BinaryWriter writer, string value)
{
writer.Write((uint)value.Length);
if (value.Length > 0)
{
writer.Write(Encoding.Unicode.GetBytes(value));
}
}
private static void WriteScrambledHistorianString(BinaryWriter writer, string value)
{
if (value.Length == 0)
{
writer.Write((ushort)1);
writer.Write((byte)0);
return;
}
ushort scrambleKey = 1;
foreach (char c in value)
{
if (c >= scrambleKey)
{
scrambleKey = checked((ushort)(c + 1));
}
}
writer.Write(scrambleKey);
writer.Write((byte)1);
writer.Write((byte)value.Length);
foreach (char c in value)
{
writer.Write((ushort)(c ^ scrambleKey));
}
}
}
internal sealed record HistorianDataQueryRequest(
IReadOnlyList<string> TagNames,
DateTime StartUtc,
DateTime EndUtc,
ushort MaxStates,
uint BatchSize,
string Option)
{
public uint QueryType { get; init; } = 2;
public uint QueryFormat { get; init; }
public uint SummaryType { get; init; }
public TimeSpan Resolution { get; init; } = TimeSpan.Zero;
public float ValueDeadband { get; init; }
public uint TimeDeadband { get; init; }
public string TimeZone { get; init; } = "UTC";
public uint VersionType { get; init; } = 1;
public uint ResultBufferSize { get; init; } = 65_536;
public ushort InterpolationType { get; init; } = 255;
public ushort TimestampRule { get; init; } = 1;
public ushort QualityRule { get; init; }
public ulong ColumnSelectorFlags { get; init; } = 0x0000_8182_0007_82FF;
public string Filter { get; init; } = "NoFilter";
public uint ValueSelector { get; init; } = 1;
public uint AggregationType { get; init; } = 3;
public HistorianMetadataNamespace MetadataNamespace { get; init; } = HistorianMetadataNamespace.Empty;
public ushort ClientVersion { get; init; } = 9;
public uint SkipRows { get; init; }
public uint ReservedAfterSkipRows { get; init; }
public HistorianRedundantEndpoint MdsEndpoint { get; init; } = HistorianRedundantEndpoint.Empty;
public HistorianRedundantEndpoint StorageEndpoint { get; init; } = HistorianRedundantEndpoint.Empty;
public IReadOnlyList<string> SliceByTagNames { get; init; } = [];
public uint TimeoutQueryProcessingMilliseconds { get; init; }
public uint MaxQueryMemoryConsumptionInMb { get; init; }
}
internal sealed record HistorianRedundantEndpoint(string EndpointName, IReadOnlyList<HistorianEndpoint> Endpoints)
{
public static HistorianRedundantEndpoint Empty { get; } = new(string.Empty, []);
}
internal sealed record HistorianEndpoint(string NodeName, string PipeName);
@@ -0,0 +1,157 @@
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianEventQueryProtocol
{
public const ushort QueryRequestTypeEvent = 3;
public static IReadOnlyList<HistorianEventQueryAttempt> CreateStartEventQueryAttempts(DateTime startUtc, DateTime endUtc, uint eventCount)
{
List<HistorianEventQueryAttempt> attempts = [];
attempts.Add(CreateNativeEmptyFilterAttempt(startUtc, endUtc, eventCount));
return attempts;
}
private static HistorianEventQueryAttempt CreateNativeEmptyFilterAttempt(DateTime startUtc, DateTime endUtc, uint eventCount)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((ushort)5);
writer.Write(startUtc.ToFileTimeUtc());
writer.Write(endUtc.ToFileTimeUtc());
writer.Write(eventCount);
writer.Write(0u);
writer.Write((ushort)0);
writer.Write((ushort)1);
WriteNativeEmptyFilterBlock(writer);
writer.Write(65_536u);
WriteHistorianString(writer, "UTC");
WriteMetadataNamespace(writer);
writer.Write(0u);
byte[] request = stream.ToArray();
return new HistorianEventQueryAttempt(
"native-empty-filter-version5",
5,
request,
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
}
private static HistorianEventQueryAttempt CreateAttempt(
string shape,
ushort version,
DateTime startUtc,
DateTime endUtc,
uint eventCount,
Action<BinaryWriter> writeFilters,
bool writeTimeZoneBeforeFilter)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write(version);
writer.Write(startUtc.ToFileTimeUtc());
writer.Write(endUtc.ToFileTimeUtc());
writer.Write(eventCount);
writer.Write(0u);
writer.Write((ushort)0);
writer.Write((ushort)1);
if (writeTimeZoneBeforeFilter)
{
WriteHistorianString(writer, "UTC");
writeFilters(writer);
}
else
{
writeFilters(writer);
WriteHistorianString(writer, "UTC");
}
byte[] request = stream.ToArray();
return new HistorianEventQueryAttempt(
$"{shape}-version{version}",
version,
request,
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
}
private static void WriteFilterBlockV1(BinaryWriter writer)
{
writer.Write((ushort)1);
writer.Write((byte)0);
writer.Write(0L);
writer.Write(Guid.Empty.ToByteArray());
writer.Write(0u);
}
private static void WriteNativeEmptyFilterBlock(BinaryWriter writer)
{
writer.Write((ushort)0);
writer.Write(0u);
writer.Write((byte)0);
}
private static void WriteMetadataNamespace(BinaryWriter writer)
{
writer.Write((byte)1);
WriteScrambledHistorianString(writer, string.Empty);
WriteScrambledHistorianString(writer, string.Empty);
WriteScrambledHistorianString(writer, string.Empty);
}
private static void WriteScrambledHistorianString(BinaryWriter writer, string value)
{
if (value.Length == 0)
{
writer.Write((ushort)1);
writer.Write((byte)0);
return;
}
ushort scrambleKey = 1;
foreach (char c in value)
{
if (c >= scrambleKey)
{
scrambleKey = checked((ushort)(c + 1));
}
}
writer.Write(scrambleKey);
writer.Write((byte)1);
writer.Write((byte)value.Length);
foreach (char c in value)
{
writer.Write((ushort)(c ^ scrambleKey));
}
}
private static void WriteFilterBlockContinuationOnly(BinaryWriter writer)
{
writer.Write((byte)0);
writer.Write(0L);
writer.Write(Guid.Empty.ToByteArray());
writer.Write(0u);
}
private static void WriteFilterBlockCountOnly(BinaryWriter writer)
{
writer.Write(0u);
}
private static void WriteHistorianString(BinaryWriter writer, string value)
{
writer.Write((uint)value.Length);
if (value.Length > 0)
{
writer.Write(Encoding.Unicode.GetBytes(value));
}
}
}
internal sealed record HistorianEventQueryAttempt(string Name, ushort Version, byte[] RequestBuffer, string RequestSha256);
@@ -0,0 +1,255 @@
using System.Buffers.Binary;
using System.Text;
using ZB.MOM.WW.SPHistorianClient.Models;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// Parser for the version-9 event-row buffer the Historian server returns from
/// <c>/Retr/GetNextEventQueryResultBuffer.pResultBuff</c>. Wire shape decoded from a captured
/// native event read (instrument-wcf-readmessage record 24, two rows for Alarm.Set + Alarm.Clear):
///
/// <code>
/// UInt16 version = 9
/// UInt32 rowCount
/// rowCount × Row {
/// UInt32 rowMarker = 0x1E
/// UInt16 rowFormat = 7
/// Int64 eventTimeUtcFiletime
/// UInt16 × 8 // purpose unclear (slot offsets?)
/// compact ASCII string // event type (Alarm.Set, Alarm.Clear, ...)
/// UInt16 propertyCount
/// propertyCount × Property {
/// compact ASCII string // property name
/// Value {
/// UInt8 typeMarker
/// UInt8 length // bytes of value following status
/// UInt8 status // observed 0x00 in successful captures
/// length × byte // encoding determined by typeMarker:
/// 0x02 → Boolean (1 byte: 0/1)
/// 0x10 → GUID (16 bytes)
/// 0x18 → FILETIME UTC (Int64)
/// 0x31 → Int32 little-endian
/// 0x43 → UTF-16 string: UInt16 charCount + charCount × UInt16 chars
/// }
/// }
/// }
/// </code>
///
/// Compact ASCII string: <c>0x09 LEN 0x00 LEN×ASCII bytes</c> (same encoding as
/// CTagMetadata strings).
/// </remarks>
internal static class HistorianEventRowProtocol
{
public const ushort EventRowProtocolVersion = 9;
public const uint RowMarker = 0x0000001Eu;
public const ushort RowFormatV9 = 7;
private const int HeaderSize = 6;
private const int RowFixedHeaderSize = 4 + 2 + 8 + 16;
private const byte ValueTypeBool = 0x02;
private const byte ValueTypeGuid = 0x10;
private const byte ValueTypeFiletime = 0x18;
private const byte ValueTypeInt32 = 0x31;
private const byte ValueTypeUtf16String = 0x43;
public static IReadOnlyList<HistorianEvent> Parse(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < HeaderSize)
{
return [];
}
ushort version = BinaryPrimitives.ReadUInt16LittleEndian(buffer[..2]);
if (version != EventRowProtocolVersion)
{
return [];
}
uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(2, 4));
if (rowCount == 0)
{
return [];
}
List<HistorianEvent> events = new(checked((int)rowCount));
int cursor = HeaderSize;
for (uint rowIndex = 0; rowIndex < rowCount; rowIndex++)
{
if (!TryReadRow(buffer, ref cursor, out HistorianEvent? row))
{
break;
}
events.Add(row);
}
return events;
}
private static bool TryReadRow(ReadOnlySpan<byte> buffer, ref int cursor, out HistorianEvent row)
{
row = null!;
if (cursor + RowFixedHeaderSize > buffer.Length)
{
return false;
}
uint marker = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(cursor, 4));
if (marker != RowMarker)
{
return false;
}
ushort format = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(cursor + 4, 2));
if (format != RowFormatV9)
{
return false;
}
long filetime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(cursor + 6, 8));
DateTime eventTimeUtc = DateTime.FromFileTimeUtc(filetime);
int afterFixedHeader = cursor + RowFixedHeaderSize;
if (!TryReadCompactAsciiString(buffer, afterFixedHeader, out string eventType, out int afterType))
{
return false;
}
if (afterType + 2 > buffer.Length)
{
return false;
}
ushort propertyCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(afterType, 2));
int propertyCursor = afterType + 2;
Dictionary<string, object?> properties = new(propertyCount, StringComparer.OrdinalIgnoreCase);
for (int p = 0; p < propertyCount; p++)
{
if (!TryReadCompactAsciiString(buffer, propertyCursor, out string name, out int afterName))
{
return false;
}
if (!TryReadValue(buffer, afterName, out object? value, out int afterValue))
{
return false;
}
properties[name] = value;
propertyCursor = afterValue;
}
row = BuildEvent(eventTimeUtc, eventType, properties);
cursor = propertyCursor;
return true;
}
private static HistorianEvent BuildEvent(DateTime eventTimeUtc, string eventType, Dictionary<string, object?> properties)
{
Guid id = TryGetGuid(properties, "alarm_id") ?? Guid.Empty;
DateTime receivedTime = TryGetFiletime(properties, "receivedtime") ?? eventTimeUtc;
string sourceName = TryGetString(properties, "source_processvariable") ?? TryGetString(properties, "source_object") ?? string.Empty;
string ns = TryGetString(properties, "namespace") ?? TryGetString(properties, "provider_system") ?? string.Empty;
ushort revisionVersion = TryGetInt32(properties, "revisionversion") is int rv && rv is >= 0 and <= ushort.MaxValue
? (ushort)rv
: (ushort)0;
return new HistorianEvent(
Id: id,
EventTimeUtc: eventTimeUtc,
ReceivedTimeUtc: receivedTime,
Type: eventType,
SourceName: sourceName,
Namespace: ns,
RevisionVersion: revisionVersion,
Properties: properties);
}
private static Guid? TryGetGuid(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is Guid g ? g : null;
private static DateTime? TryGetFiletime(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is DateTime dt ? dt : null;
private static string? TryGetString(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is string s ? s : null;
private static int? TryGetInt32(Dictionary<string, object?> properties, string key) =>
properties.TryGetValue(key, out object? value) && value is int i ? i : null;
/// <summary>
/// Compact ASCII string encoding: <c>0x09 LEN 0x00 LEN×ASCII bytes</c>.
/// </summary>
private static bool TryReadCompactAsciiString(ReadOnlySpan<byte> buffer, int offset, out string value, out int afterOffset)
{
value = string.Empty;
afterOffset = offset;
if (offset + 3 > buffer.Length || buffer[offset] != 0x09)
{
return false;
}
byte length = buffer[offset + 1];
int payloadStart = offset + 3;
if (payloadStart + length > buffer.Length)
{
return false;
}
value = Encoding.ASCII.GetString(buffer.Slice(payloadStart, length));
afterOffset = payloadStart + length;
return true;
}
/// <summary>
/// Value encoding: <c>typeMarker(1) + length(1) + status(1) + length×value bytes</c>.
/// Decodes the value by typeMarker; unknown markers preserve the raw bytes as a
/// <see cref="byte[]"/> in the property bag.
/// </summary>
private static bool TryReadValue(ReadOnlySpan<byte> buffer, int offset, out object? value, out int afterOffset)
{
value = null;
afterOffset = offset;
if (offset + 3 > buffer.Length)
{
return false;
}
byte typeMarker = buffer[offset];
byte length = buffer[offset + 1];
// buffer[offset + 2] is the status byte (observed 0x00 in successful captures).
int valueStart = offset + 3;
if (valueStart + length > buffer.Length)
{
return false;
}
ReadOnlySpan<byte> valueBytes = buffer.Slice(valueStart, length);
value = typeMarker switch
{
ValueTypeBool when length >= 1 => valueBytes[0] != 0,
ValueTypeGuid when length == 16 => new Guid(valueBytes),
ValueTypeFiletime when length == 8 => DateTime.FromFileTimeUtc(BinaryPrimitives.ReadInt64LittleEndian(valueBytes)),
ValueTypeInt32 when length == 4 => BinaryPrimitives.ReadInt32LittleEndian(valueBytes),
ValueTypeUtf16String when length >= 2 => DecodeUtf16String(valueBytes),
_ => valueBytes.ToArray()
};
afterOffset = valueStart + length;
return true;
}
private static string DecodeUtf16String(ReadOnlySpan<byte> valueBytes)
{
ushort charCount = BinaryPrimitives.ReadUInt16LittleEndian(valueBytes[..2]);
int byteCount = checked(charCount * 2);
if (byteCount > valueBytes.Length - 2)
{
byteCount = valueBytes.Length - 2;
}
return Encoding.Unicode.GetString(valueBytes.Slice(2, byteCount));
}
}
@@ -0,0 +1,165 @@
using System.Buffers.Binary;
using System.Diagnostics;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <summary>
/// Transport-agnostic pieces of the native Historian connect handshake: building the
/// OpenConnection3 v6 request buffer, running the SSPI/NTLM token-exchange rounds, and
/// decoding the OpenConnection response. Shared by the WCF/MDAS path
/// (<see cref="HistorianWcfAuthChainHelper"/>) and the 2023 R2 gRPC path
/// (<c>Grpc.HistorianGrpcReadOrchestrator</c>). The byte payloads are identical across
/// transports — only the envelope (WCF operation vs gRPC method) differs.
/// </summary>
internal static class HistorianNativeHandshake
{
private const int CredentialBlockSizeBytes = 1026;
private const int OpenConnectionMinResponseLength = 5;
private const int MaxTokenRounds = 8;
private const string ClientNodeNameFallback = "ZB.MOM.WW.SPHistorianClient";
private const string ClientDataSourceId = "2020.406.2652.2";
private const string ClientDllVersionString = "2020.406.2652.2";
private const byte NativeClientType = 4;
private const byte NativeClientCommonInfoFormatVersion = 4;
private const ushort NativeHcalVersion = 17;
private const uint NativeClientVersionInt = 999_999;
private const ushort NativeOpen2ClientVersion = 9;
/// <summary>Result of one transport-level credential-token exchange.</summary>
internal readonly record struct TokenExchangeResult(bool Success, byte[] ServerOutput, byte[] Error);
/// <summary>
/// Performs a single credential-token round on the wire. <paramref name="handle"/> is the
/// upper-case context-key GUID, <paramref name="wrappedToken"/> is the AVEVA-wrapped SSPI
/// token (round byte + length + token). The WCF path maps this to
/// <c>Hist.ValidateClientCredential</c>; the gRPC path maps it to
/// <c>HistoryService.ExchangeKey</c> (the renamed handshake op).
/// </summary>
internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
/// <summary>
/// Drives the SSPI/NTLM negotiate loop against the supplied <paramref name="exchange"/>
/// delegate until the server signals terminal success. Mirrors the native two-round
/// (69→239, 93→1) sequence.
/// </summary>
public static void RunTokenRounds(
TokenExchange exchange,
Guid contextKey,
HistorianClientOptions options,
CancellationToken cancellationToken)
{
using HistorianSspiClient sspi = options.IntegratedSecurity
? new HistorianSspiClient(options.TargetSpn)
: new HistorianSspiClient(options.TargetSpn, ParseDomain(options.UserName), ParseUserName(options.UserName), options.Password);
string handle = contextKey.ToString("D").ToUpperInvariant();
byte[] incoming = [];
for (int round = 0; round < MaxTokenRounds; round++)
{
cancellationToken.ThrowIfCancellationRequested();
HistorianSspiStepResult step = sspi.Next(incoming);
byte[] outgoing = step.Token;
HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(outgoing);
byte[] wrapped = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(round == 0, outgoing);
TokenExchangeResult result = exchange(handle, wrapped, round);
byte[] serverOutput = result.ServerOutput ?? [];
byte[] error = result.Error ?? [];
if (!result.Success)
{
throw new InvalidOperationException($"Credential token round {round} rejected (errorLen={error.Length}).");
}
ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput);
if (response is null || !response.Continue)
{
return;
}
incoming = response.Token;
if (step.IsCompleted && incoming.Length == 0)
{
return;
}
}
throw new InvalidOperationException($"Credential token exchange exceeded {MaxTokenRounds} rounds without terminal success.");
}
/// <summary>
/// Builds the native OpenConnection3 (Open2) version-6 request buffer. Identical bytes are
/// sent over WCF (<c>Hist.OpenConnection2</c>) and gRPC
/// (<c>HistoryService.OpenConnection.btConnectionRequest</c>).
/// </summary>
public static byte[] BuildOpenConnection3Request(string host, Guid contextKey, uint connectionMode)
{
Process current = Process.GetCurrentProcess();
string machineName = Environment.MachineName;
string processName = string.IsNullOrEmpty(current.ProcessName) ? ClientNodeNameFallback : current.ProcessName;
_ = host; // host reserved for remote-orchestrator extension
HistorianOpen2Request open2 = new(
HostName: machineName,
ProcessName: string.Empty,
ProcessId: checked((uint)current.Id),
UserName: string.Empty,
Password: [],
ClientType: NativeClientType,
ClientVersion: NativeOpen2ClientVersion,
ConnectionMode: connectionMode,
MetadataNamespace: HistorianMetadataNamespace.Empty);
HistorianClientCommonInfo commonInfo = new(
FormatVersion: NativeClientCommonInfoFormatVersion,
ServerNodeName: machineName,
ClientNodeName: processName,
ProcessId: checked((uint)current.Id),
HcalVersion: NativeHcalVersion,
ProcessName: string.Empty,
Proxy: string.Empty,
DataSourceId: ClientDataSourceId,
ShardId: Guid.Empty,
ClientVersion: NativeClientVersionInt,
ClientTimestamp: (ulong)DateTime.UtcNow.ToFileTimeUtc(),
ClientDllVersion: ClientDllVersionString);
return HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
open2,
commonInfo,
contextKey,
credentialBlock: new byte[CredentialBlockSizeBytes]);
}
/// <summary>
/// Decodes the OpenConnection response blob: byte 0 = protocol version, bytes 1..4 =
/// transient /Retr client handle (UInt32 LE), bytes 5..20 = storage session GUID.
/// </summary>
public static (uint ClientHandle, Guid StorageSessionId) ParseOpenConnectionResponse(ReadOnlySpan<byte> response)
{
if (response.Length < OpenConnectionMinResponseLength)
{
throw new InvalidOperationException($"OpenConnection response too short (ResponseLen={response.Length}).");
}
uint clientHandle = BinaryPrimitives.ReadUInt32LittleEndian(response.Slice(1, 4));
Guid storageSessionId = response.Length >= 21 ? new Guid(response.Slice(5, 16)) : Guid.Empty;
return (clientHandle, storageSessionId);
}
private static string ParseDomain(string userName)
{
if (string.IsNullOrEmpty(userName)) return string.Empty;
int slash = userName.IndexOf('\\');
return slash > 0 ? userName[..slash] : string.Empty;
}
private static string ParseUserName(string userName)
{
if (string.IsNullOrEmpty(userName)) return string.Empty;
int slash = userName.IndexOf('\\');
return slash > 0 ? userName[(slash + 1)..] : userName;
}
}
@@ -0,0 +1,275 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianOpen2Protocol
{
public static byte[] SerializeLegacyVersion1(HistorianOpen2Request request)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((ushort)1);
WriteHistorianString(writer, request.HostName);
WriteHistorianString(writer, request.ProcessName);
writer.Write(request.ProcessId);
WriteHistorianString(writer, request.UserName);
writer.Write((uint)request.Password.Length);
writer.Write(request.Password);
writer.Write(request.ClientType);
writer.Write(request.ClientVersion);
writer.Write(request.ConnectionMode);
WriteMetadataNamespace(writer, request.MetadataNamespace);
return stream.ToArray();
}
public static byte[] SerializeNativeVersion3(HistorianOpen2Request request, HistorianClientCommonInfo commonInfo)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((byte)3);
WriteNativeOpenConnectionContent(writer, request, commonInfo);
return stream.ToArray();
}
public static byte[] SerializeNativeOpenConnection3Version6(
HistorianOpen2Request request,
HistorianClientCommonInfo commonInfo,
Guid clientKey,
byte[]? credentialBlock = null)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write((byte)6);
writer.Write(clientKey.ToByteArray());
writer.Write((byte)0);
WriteNativeOpenConnectionContent(writer, request, commonInfo, credentialBlock, useCompactMetadataNamespace: true);
return stream.ToArray();
}
private static void WriteNativeOpenConnectionContent(
BinaryWriter writer,
HistorianOpen2Request request,
HistorianClientCommonInfo commonInfo,
byte[]? credentialBlock = null,
bool useCompactMetadataNamespace = false)
{
byte[] secretBytes = credentialBlock ?? request.Password;
WriteHistorianString(writer, request.HostName);
checked
{
writer.Write((ushort)secretBytes.Length);
}
writer.Write(secretBytes);
writer.Write(request.ClientType);
writer.Write(request.ConnectionMode);
if (useCompactMetadataNamespace)
{
WriteCompactMetadataNamespace(writer, request.MetadataNamespace);
}
else
{
WriteMetadataNamespace(writer, request.MetadataNamespace);
}
WriteHistorianString(writer, string.Empty);
WriteHistorianString(writer, string.Empty);
WriteClientCommonInfo(writer, commonInfo);
}
public static HistorianNativeError? TryReadNativeError(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 5)
{
return null;
}
byte type = buffer[0];
uint code = BinaryPrimitives.ReadUInt32LittleEndian(buffer[1..5]);
return new HistorianNativeError(type, code, GetKnownErrorName(code));
}
public static HistorianLegacyOpen2Output? TryReadLegacyOpen2Output(ReadOnlySpan<byte> buffer)
{
if (buffer.Length != 32)
{
return null;
}
uint handle = BinaryPrimitives.ReadUInt32LittleEndian(buffer[..4]);
Guid storageSessionId = new(buffer.Slice(4, 16));
long connectTime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(20, 8));
uint serverStatus = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(28, 4));
return new HistorianLegacyOpen2Output(handle, storageSessionId, connectTime, serverStatus);
}
public static HistorianNativeOpen3Output? TryReadNativeOpen3Output(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 29)
{
return null;
}
byte protocolVersion = buffer[0];
if (protocolVersion is not (2 or 3))
{
return null;
}
int minimumLength = protocolVersion >= 3 ? 37 : 29;
if (buffer.Length < minimumLength)
{
return null;
}
uint handle = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(1, 4));
Guid storageSessionId = new(buffer.Slice(5, 16));
long connectTime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(21, 8));
long? serverTime = null;
if (protocolVersion >= 3)
{
serverTime = BinaryPrimitives.ReadInt64LittleEndian(buffer.Slice(29, 8));
}
byte[] trailingBytes = buffer[minimumLength..].ToArray();
return new HistorianNativeOpen3Output(
protocolVersion,
handle,
storageSessionId,
connectTime,
serverTime,
trailingBytes);
}
public static byte[] EncodeWidePassword(string password)
{
return string.IsNullOrEmpty(password) ? [] : Encoding.Unicode.GetBytes(password);
}
private static void WriteMetadataNamespace(BinaryWriter writer, HistorianMetadataNamespace metadataNamespace)
{
writer.Write(metadataNamespace.HasValue ? (byte)1 : (byte)0);
WriteHistorianString(writer, metadataNamespace.Namespace);
WriteHistorianString(writer, metadataNamespace.TagPrefix);
WriteHistorianString(writer, metadataNamespace.PropertyPrefix);
}
private static void WriteCompactMetadataNamespace(BinaryWriter writer, HistorianMetadataNamespace metadataNamespace)
{
if (!metadataNamespace.HasValue
|| metadataNamespace.Namespace.Length != 0
|| metadataNamespace.TagPrefix.Length != 0
|| metadataNamespace.PropertyPrefix.Length != 0)
{
throw new ProtocolEvidenceMissingException("OpenConnection3 non-empty metadata namespace");
}
writer.Write((byte)1);
WriteCompactEmptyString(writer);
WriteCompactEmptyString(writer);
WriteCompactEmptyString(writer);
}
private static void WriteCompactEmptyString(BinaryWriter writer)
{
writer.Write((ushort)1);
writer.Write((byte)0);
}
private static void WriteHistorianString(BinaryWriter writer, string value)
{
writer.Write((uint)value.Length);
if (value.Length > 0)
{
writer.Write(Encoding.Unicode.GetBytes(value));
}
}
private static void WriteClientCommonInfo(BinaryWriter writer, HistorianClientCommonInfo commonInfo)
{
writer.Write(commonInfo.FormatVersion);
WriteHistorianString(writer, commonInfo.ServerNodeName);
WriteHistorianString(writer, commonInfo.ClientNodeName);
writer.Write(commonInfo.ProcessId);
writer.Write(commonInfo.HcalVersion);
WriteHistorianString(writer, commonInfo.ProcessName);
WriteHistorianString(writer, commonInfo.Proxy);
WriteHistorianString(writer, commonInfo.DataSourceId);
writer.Write(commonInfo.ShardId.ToByteArray());
writer.Write(commonInfo.ClientVersion);
if (commonInfo.FormatVersion >= 3)
{
writer.Write(commonInfo.ClientTimestamp);
}
if (commonInfo.FormatVersion >= 4)
{
WriteHistorianString(writer, commonInfo.ClientDllVersion);
}
}
private static string? GetKnownErrorName(uint code)
{
return code switch
{
1 => "Failure",
73 => "InvalidPacketVersion",
171 => "AuthenticationFailed",
_ => null
};
}
}
internal sealed record HistorianOpen2Request(
string HostName,
string ProcessName,
uint ProcessId,
string UserName,
byte[] Password,
byte ClientType,
ushort ClientVersion,
uint ConnectionMode,
HistorianMetadataNamespace MetadataNamespace);
internal sealed record HistorianMetadataNamespace(
bool HasValue,
string Namespace,
string TagPrefix,
string PropertyPrefix)
{
public static HistorianMetadataNamespace Empty { get; } = new(true, string.Empty, string.Empty, string.Empty);
}
internal sealed record HistorianNativeError(byte Type, uint Code, string? Name);
internal sealed record HistorianLegacyOpen2Output(
uint Handle,
Guid StorageSessionId,
long ConnectTimeFileTimeUtc,
uint ServerStatus);
internal sealed record HistorianNativeOpen3Output(
byte ProtocolVersion,
uint Handle,
Guid StorageSessionId,
long ConnectTimeFileTimeUtc,
long? ServerTimeFileTimeUtc,
byte[] TrailingBytes);
internal sealed record HistorianClientCommonInfo(
byte FormatVersion,
string ServerNodeName,
string ClientNodeName,
uint ProcessId,
ushort HcalVersion,
string ProcessName,
string Proxy,
string DataSourceId,
Guid ShardId,
uint ClientVersion,
ulong ClientTimestamp,
string ClientDllVersion);
@@ -0,0 +1,115 @@
using System.Net;
using System.Net.Security;
using System.Security.Authentication.ExtendedProtection;
using System.Security.Principal;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// Cross-platform Negotiate / NTLM token producer for the Historian's `Hist.ValCl`
/// authentication exchange. Uses <see cref="NegotiateAuthentication"/> under the hood
/// (Windows: SSPI; Linux/macOS: GSSAPI via <c>libgssapi_krb5</c> / <c>gss-ntlmssp</c>).
///
/// The native AVEVA wrapper passes specific request flags to
/// <c>InitializeSecurityContextW</c>: <c>IDENTIFY | CONNECTION | CONFIDENTIALITY |
/// SEQUENCE_DETECT | REPLAY_DETECT</c> on round 0 and the same minus IDENTIFY on
/// rounds 1+. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation;
/// without it AcceptSecurityContext rejects the type-3 token with
/// SEC_E_INVALID_TOKEN. <c>RequiredProtectionLevel.EncryptAndSign</c> in
/// NegotiateAuthentication implicitly requests SEQUENCE + REPLAY +
/// CONFIDENTIALITY, and <c>AllowedImpersonationLevel = Identification</c> requests
/// IDENTIFY — together these produce a request flag set that AcceptSecurityContext
/// accepts on the server side.
///
/// The constants and request-flag selection helpers below are preserved for the
/// existing unit tests in <c>HistorianSspiClientTests</c> — they document the
/// captured native flag values rather than driving the underlying API today.
/// </remarks>
internal sealed class HistorianSspiClient : IDisposable
{
public const int IscReqReplayDetect = 0x4;
public const int IscReqSequenceDetect = 0x8;
public const int IscReqConfidentiality = 0x10;
public const int IscReqConnection = 0x800;
public const int IscReqIdentify = 0x20000;
public const int IscReqAllocateMemory = 0x100;
public const int NativeFlagsRound0 = IscReqIdentify | IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
public const int NativeFlagsRoundSubsequent = IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
private readonly NegotiateAuthentication _auth;
private int _roundIndex;
private bool _disposed;
public HistorianSspiClient(string targetName, string package = "Negotiate")
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
{
Package = package,
TargetName = targetName,
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
RequireMutualAuthentication = false,
});
}
/// <remarks>
/// Acquires Negotiate credentials for an explicit user/domain/password instead
/// of the calling thread's identity. On Linux this routes through GSSAPI's
/// credential acquisition; the supplied credential is wrapped in a
/// <see cref="NetworkCredential"/>.
/// </remarks>
public HistorianSspiClient(string targetName, string? domain, string userName, string? password, string package = "Negotiate")
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
{
Package = package,
TargetName = targetName,
Credential = new NetworkCredential(userName, password ?? string.Empty, domain ?? string.Empty),
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
RequireMutualAuthentication = false,
});
}
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call corresponds to.</summary>
internal int NextRequestFlags => SelectRequestFlags(_roundIndex) | IscReqAllocateMemory;
public static int SelectRequestFlags(int roundIndex) => roundIndex == 0 ? NativeFlagsRound0 : NativeFlagsRoundSubsequent;
public HistorianSspiStepResult Next(byte[] incoming)
{
ArgumentNullException.ThrowIfNull(incoming);
ObjectDisposedException.ThrowIf(_disposed, this);
byte[]? outgoing = _auth.GetOutgoingBlob(incoming.Length == 0 ? null : incoming, out NegotiateAuthenticationStatusCode status);
_roundIndex++;
bool completed = status switch
{
NegotiateAuthenticationStatusCode.Completed => true,
NegotiateAuthenticationStatusCode.ContinueNeeded => false,
_ => throw new InvalidOperationException($"Negotiate handshake failed: {status}"),
};
return new HistorianSspiStepResult(outgoing ?? [], completed);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_auth.Dispose();
}
}
internal readonly record struct HistorianSspiStepResult(byte[] Token, bool IsCompleted);
@@ -0,0 +1,33 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianStatusProtocol
{
public const int SystemTimeByteCount = 16;
public static DateTime? TryReadSystemTime(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < SystemTimeByteCount)
{
return null;
}
ushort year = BinaryPrimitives.ReadUInt16LittleEndian(buffer[0..2]);
ushort month = BinaryPrimitives.ReadUInt16LittleEndian(buffer[2..4]);
ushort day = BinaryPrimitives.ReadUInt16LittleEndian(buffer[6..8]);
ushort hour = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]);
ushort minute = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]);
ushort second = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]);
ushort millisecond = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]);
try
{
return new DateTime(year, month, day, hour, minute, second, millisecond, DateTimeKind.Unspecified);
}
catch (ArgumentOutOfRangeException)
{
return null;
}
}
}
@@ -0,0 +1,297 @@
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianTagQueryProtocol
{
public const ushort NativeStartTagQueryMarker = 26_449;
public const ushort NativeStartTagQueryVersion = 1;
public static HistorianTagQueryAttempt CreateStartTagQueryAttempt(string tagFilter)
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write(NativeStartTagQueryMarker);
writer.Write(NativeStartTagQueryVersion);
WriteHistorianString(writer, tagFilter);
byte[] request = stream.ToArray();
return new HistorianTagQueryAttempt(
"native-start-tag-query-version1",
request,
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
}
public static HistorianTagQueryAttempt CreateStartTagQueryHeaderOnlyAttempt()
{
using MemoryStream stream = new();
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
writer.Write(NativeStartTagQueryMarker);
writer.Write(NativeStartTagQueryVersion);
byte[] request = stream.ToArray();
return new HistorianTagQueryAttempt(
"native-start-tag-query-header-only",
request,
Convert.ToHexString(SHA256.HashData(request)).ToLowerInvariant());
}
public static HistorianTagQueryStartResponse ParseStartTagQueryResponse(ReadOnlySpan<byte> response)
{
if (response.Length != 8)
{
throw new InvalidDataException("StartTagQuery response must be exactly 8 bytes.");
}
return new HistorianTagQueryStartResponse(
BitConverter.ToUInt32(response[..4]),
BitConverter.ToUInt32(response[4..8]));
}
public static IReadOnlyList<HistorianTagInfoResponse> ParseGetTagInfoResponse(ReadOnlySpan<byte> response)
{
if (response.Length < 4)
{
throw new InvalidDataException("GetTagInfo response is missing the tag count.");
}
int cursor = 0;
uint count = ReadUInt32(response, ref cursor);
List<HistorianTagInfoResponse> tags = new(checked((int)count));
for (uint index = 0; index < count; index++)
{
tags.Add(ParseTagInfoRecord(response, ref cursor));
}
return tags;
}
public static HistorianTagInfoResponse ParseGetTagInfoFromNameResponse(ReadOnlySpan<byte> response)
{
int cursor = 0;
return ParseTagInfoRecord(response, ref cursor);
}
public static IReadOnlyList<string> ParseGetLikeTagNamesResponse(ReadOnlySpan<byte> response)
{
if (response.Length < 4)
{
throw new InvalidDataException("GetLikeTagnames response is missing the tag count.");
}
int cursor = 0;
uint count = ReadUInt32(response, ref cursor);
List<string> tagNames = new(checked((int)count));
for (uint index = 0; index < count; index++)
{
uint charLength = ReadUInt32(response, ref cursor);
int byteLength = checked((int)charLength * 2);
EnsureAvailable(response, cursor, byteLength);
tagNames.Add(Encoding.Unicode.GetString(response.Slice(cursor, byteLength)));
cursor += byteLength;
}
if (cursor != response.Length)
{
throw new InvalidDataException("GetLikeTagnames response has trailing bytes.");
}
return tagNames;
}
private static void WriteHistorianString(BinaryWriter writer, string value)
{
writer.Write((uint)value.Length);
if (value.Length > 0)
{
writer.Write(Encoding.Unicode.GetBytes(value));
}
}
private static string ReadCompactAsciiString(ReadOnlySpan<byte> response, ref int cursor)
{
EnsureAvailable(response, cursor, 3);
byte marker = response[cursor++];
if (marker != 0x09)
{
throw new InvalidDataException($"Expected compact string marker 0x09, found 0x{marker:X2}.");
}
ushort byteLength = ReadUInt16(response, ref cursor);
EnsureAvailable(response, cursor, byteLength);
string value = Encoding.UTF8.GetString(response.Slice(cursor, byteLength));
cursor += byteLength;
return value;
}
private static HistorianTagInfoResponse ParseTagInfoRecord(ReadOnlySpan<byte> response, ref int cursor)
{
EnsureAvailable(response, cursor, 24);
byte[] nativeDataTypeDescriptor = response.Slice(cursor, 4).ToArray();
cursor += 4;
Guid typeId = new(response.Slice(cursor, 16));
cursor += 16;
uint tagKey = ReadUInt32(response, ref cursor);
// The compact-ASCII string slot count varies by tag origin (decoded from
// GetTagInfoFromName captures across multiple tag types):
// 1 string : TagName only (degenerate / unknown shape)
// 2 strings : TagName + MetadataProvider (e.g., MDAS-routed external tags)
// 4 strings : TagName + Description + ItemName + CreatedBy (local Sys tags)
// Walk strings dynamically until the next byte isn't the 0x09 marker.
List<string> strings = new(4);
while (cursor < response.Length && response[cursor] == 0x09)
{
strings.Add(ReadCompactAsciiString(response, ref cursor));
}
string tagName = strings.Count > 0 ? strings[0] : string.Empty;
// String at position 1 is Description for full-shape tags or MetadataProvider
// for MDAS-routed tags. Both are useful; expose under MetadataProvider for back-compat
// and Description for new semantics.
string metadataProvider = strings.Count > 1 ? strings[1] : string.Empty;
string? description = strings.Count >= 4 ? strings[1] : null;
EnsureAvailable(response, cursor, 4);
byte nativeTagClass = response[cursor++];
byte storageType = response[cursor++];
byte deadbandType = response[cursor++];
byte interpolationType = response[cursor++];
// Trailing region after the fixed 4-byte block holds:
// - some alignment / int32 fields (StorageRate, AcquisitionRate, TimeDeadband)
// - Int64 FILETIME (DateCreated)
// - For analog tags: pair of doubles (MinEU/MaxEU and/or MinRaw/MaxRaw)
// - Optional compact-ASCII EngineeringUnit string
// - Optional double RolloverValue
// - Trailer marker (often FE 00 or 00)
// The exact layout varies by tag type and storage mode; rather than commit fragile
// positional parsing, scan the trailing region for the first two consecutive
// 8-byte-aligned doubles and treat them as a (MinEU, MaxEU) pair. Both must be
// finite and the EU range must be sane (Min ≤ Max).
ReadOnlySpan<byte> trailing = response[cursor..];
(double? min, double? max, string? engineeringUnit) = TryReadAnalogTrailing(trailing);
cursor = response.Length;
return new HistorianTagInfoResponse(
tagName,
tagKey,
typeId,
nativeDataTypeDescriptor,
metadataProvider,
nativeTagClass,
storageType,
deadbandType,
interpolationType,
description,
min,
max,
engineeringUnit);
}
private static (double? min, double? max, string? engineeringUnit) TryReadAnalogTrailing(ReadOnlySpan<byte> trailing)
{
double? foundMin = null;
double? foundMax = null;
string? foundEu = null;
// Look for an EngineeringUnit compact-ASCII string anywhere in the trailing region.
for (int i = 0; i < trailing.Length - 3; i++)
{
if (trailing[i] != 0x09) continue;
ushort len = BitConverter.ToUInt16(trailing.Slice(i + 1, 2));
// Accept 1-32 byte ASCII strings as plausible EUs. Range chosen to filter false
// positives (most engineering units are short — "kPa", "Seconds", "RPM", etc.).
if (len < 1 || len > 32) continue;
int payloadStart = i + 3;
if (payloadStart + len > trailing.Length) continue;
// All bytes must be printable ASCII.
ReadOnlySpan<byte> payload = trailing.Slice(payloadStart, len);
bool allAscii = true;
foreach (byte b in payload)
{
if (b < 0x20 || b > 0x7E) { allAscii = false; break; }
}
if (!allAscii) continue;
string candidate = Encoding.ASCII.GetString(payload);
// Skip implausible values (numerics, mostly-special-chars).
if (double.TryParse(candidate, out _)) continue;
foundEu = candidate;
break;
}
// Look for two consecutive 8-byte-aligned doubles forming a sane EU range.
// Try each plausible alignment relative to the trailing-region start.
for (int alignOffset = 0; alignOffset < 8; alignOffset++)
{
for (int i = alignOffset; i + 16 <= trailing.Length; i += 8)
{
if (!TryReadDouble(trailing, i, out double a)) continue;
if (!TryReadDouble(trailing, i + 8, out double b)) continue;
// Both finite, both within sane EU range, a ≤ b.
if (!double.IsFinite(a) || !double.IsFinite(b)) continue;
if (Math.Abs(a) > 1e15 || Math.Abs(b) > 1e15) continue;
if (a > b) continue;
// Reject the all-zeros pair (uninformative).
if (a == 0 && b == 0) continue;
foundMin = a;
foundMax = b;
return (foundMin, foundMax, foundEu);
}
}
return (foundMin, foundMax, foundEu);
}
private static bool TryReadDouble(ReadOnlySpan<byte> buffer, int offset, out double value)
{
if (offset + 8 > buffer.Length) { value = 0; return false; }
value = BitConverter.ToDouble(buffer.Slice(offset, 8));
return true;
}
private static ushort ReadUInt16(ReadOnlySpan<byte> response, ref int cursor)
{
EnsureAvailable(response, cursor, 2);
ushort value = BitConverter.ToUInt16(response.Slice(cursor, 2));
cursor += 2;
return value;
}
private static uint ReadUInt32(ReadOnlySpan<byte> response, ref int cursor)
{
EnsureAvailable(response, cursor, 4);
uint value = BitConverter.ToUInt32(response.Slice(cursor, 4));
cursor += 4;
return value;
}
private static void EnsureAvailable(ReadOnlySpan<byte> response, int cursor, int byteCount)
{
if (cursor < 0 || byteCount < 0 || cursor > response.Length - byteCount)
{
throw new InvalidDataException("GetTagInfo response ended unexpectedly.");
}
}
}
internal sealed record HistorianTagQueryAttempt(string Name, byte[] RequestBuffer, string RequestSha256);
internal sealed record HistorianTagQueryStartResponse(uint QueryHandle, uint TagCount);
internal sealed record HistorianTagInfoResponse(
string TagName,
uint TagKey,
Guid TypeId,
byte[] NativeDataTypeDescriptor,
string MetadataProvider,
byte NativeTagClass,
byte StorageType,
byte DeadbandType,
byte InterpolationType,
string? Description = null,
double? MinEU = null,
double? MaxEU = null,
string? EngineeringUnit = null);
@@ -0,0 +1,242 @@
using System.Text;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// Serializers for the EnsT2 (CTagMetadata) and DelT (tag-name list) write paths.
/// Decoded from native captures landed in
/// <c>artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/bothmessage-write-with-delt-latest.ndjson</c>
/// — see <c>docs/plans/write-commands-reverse-engineering.md</c> Phase 2 findings.
///
/// Per the captured analog CTagMetadata, the layout is:
/// <code>
/// 1-byte leading marker = 4E (purpose unclear; observed constant — possibly "CTagMetadata" type tag)
/// 10-byte fixed header = 67 03 00 01 00 00 00 04 C6 02
/// 1-byte data-type code = 0x01 Float, 0x21 Double, 0x29 Int2, 0x31 Int4, 0x11 UInt4
/// 16 zero bytes (placeholder GUID + 2 bytes; future server-assigned tag id)
/// compact ASCII tag name
/// 16 bytes of 0xFF (sentinel — likely common-event-type GUID equivalent unused for analog)
/// compact ASCII description
/// compact ASCII metadata provider ("MDAS")
/// 7-byte flag block = 02 01 01 00 00 00 01
/// uint32 storage rate (ms)
/// int64 date-created FILETIME UTC
/// scaling block either compact `1A 03` (default 0/100/0/100) OR
/// `1F 00` + 4 doubles (MinEU, MaxEU, MinRaw, MaxRaw)
/// compact ASCII engineering unit
/// uint32 = 0x2710 (10000 — purpose unclear; observed constant)
/// 8-byte double = 1.0 (likely IntegralDivisor)
/// 2-byte trailer = `FE 00` for ApplyScaling=false; `FE 01` for ApplyScaling=true
/// </code>
/// The trailer's second byte is the ApplyScaling flag — verified 2026-05-04 by
/// capturing native CTagMetadata bytes for both values with identical
/// MinEU/MaxEU/MinRaw/MaxRaw inputs and observing that the server persists distinct
/// MinRaw/MaxRaw (and sets AnalogTag.Scaling=1) only when this byte is 0x01.
/// </remarks>
internal static class HistorianTagWriteProtocol
{
private const byte CompactAsciiMarker = 0x09;
/// <summary>
/// 11 bytes preceding the data-type discriminator. Byte 0 is the leading 0x4E
/// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is the
/// storage-type sub-marker (`0x02` for Cyclic, `0x06` for Delta — captured
/// 2026-05-04 by toggling --write-storage-type on the harness).
/// </summary>
private static readonly byte[] AnalogHeaderUpToTypeCodeCyclic =
[
0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
0x02,
];
private static readonly byte[] AnalogHeaderUpToTypeCodeDelta =
[
0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
0x06,
];
/// <summary>
/// Native CDataType wire codes per data type — captured 2026-05-04 by probing
/// every type via instrument-wcf-writemessage. Matches the codes already documented
/// in <see cref="HistorianWcfTagClient"/> MapDataType for the read path.
/// </summary>
public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch
{
Models.HistorianDataType.Float => 0x01,
Models.HistorianDataType.Double => 0x21,
Models.HistorianDataType.UInt2 => 0x09,
Models.HistorianDataType.UInt4 => 0x11,
Models.HistorianDataType.Int2 => 0x29,
Models.HistorianDataType.Int4 => 0x31,
_ => throw new ProtocolEvidenceMissingException(
$"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4."),
};
private static readonly byte[] AnalogPadding16 = new byte[16];
private static readonly byte[] AnalogPostNamePadding = new byte[16];
static HistorianTagWriteProtocol()
{
// 16 bytes of 0xFF observed between tag name and description.
for (int i = 0; i < AnalogPostNamePadding.Length; i++)
{
AnalogPostNamePadding[i] = 0xFF;
}
}
// After MDAS, the captured layout is a 7-byte flag block followed by uint32
// storage rate. The flag block's second byte is the StorageType (1 = Cyclic,
// 2 = Delta — captured 2026-05-04). When StorageType=Delta, an additional
// 4 zero bytes are inserted between the storage rate and the FILETIME (likely
// a placeholder for Delta-specific deadband / threshold config).
private static readonly byte[] AnalogFlagBlockCyclic = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
private static readonly byte[] AnalogFlagBlockDelta = [0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01];
private static readonly byte[] AnalogDeltaPostStorageRatePadding = new byte[4];
/// <summary>Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.</summary>
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary>
private static readonly byte[] AnalogScalingExplicitMarker = [0x1F, 0x00];
// 2-byte trailer: `FE` marker + ApplyScaling byte (0x00 = false, 0x01 = true). Verified
// against native captures by toggling ApplyScaling on the harness and confirming that
// the server persists distinct MinRaw/MaxRaw + sets AnalogTag.Scaling=1 only when the
// second byte is 0x01. The WCF binary encoder may split InBuff across two
// Bytes8Text chunks (e.g., `9E B7 ... 9F 01 00`) which can make the trailer look
// 1-byte from the wire, but the semantic CTagMetadata content is always 2 bytes.
private static readonly byte[] AnalogTrailerScalingDisabled = [0xFE, 0x00];
private static readonly byte[] AnalogTrailerScalingEnabled = [0xFE, 0x01];
private const double DefaultMinEU = 0.0;
private const double DefaultMaxEU = 100.0;
private const double DefaultMinRaw = 0.0;
private const double DefaultMaxRaw = 100.0;
private const string MetadataProvider = "MDAS";
private const uint IntegralDivisorMagic = 0x2710u;
private const uint DefaultStorageRateMs = 1000u;
/// <summary>
/// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
/// Double, Int2, Int4, UInt4 — see <see cref="GetAnalogDataTypeCode"/> for the
/// type-code mapping. Output matches the byte-for-byte capture for the same inputs.
/// When MinEU/MaxEU/MinRaw/MaxRaw are all defaults (0/100/0/100) emits the compact
/// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order.
/// </summary>
/// <param name="tagName">Tag name (ASCII).</param>
/// <param name="description">Tag description (ASCII; null/empty allowed).</param>
/// <param name="engineeringUnit">EU label (ASCII; null/empty allowed).</param>
/// <param name="dataType">Native data type — Float by default for backward compat.</param>
/// <param name="dateCreatedUtc">DateCreated FILETIME (caller passes <see cref="DateTime.UtcNow"/>).</param>
/// <param name="minEU">Engineering-units lower bound.</param>
/// <param name="maxEU">Engineering-units upper bound.</param>
/// <param name="minRaw">Raw lower bound.</param>
/// <param name="maxRaw">Raw upper bound.</param>
/// <param name="storageRateMs">StorageRate in milliseconds.</param>
public static byte[] SerializeAnalogCTagMetadata(
string tagName,
string? description,
string? engineeringUnit,
DateTime dateCreatedUtc,
Models.HistorianDataType dataType = Models.HistorianDataType.Float,
double minEU = DefaultMinEU,
double maxEU = DefaultMaxEU,
double minRaw = DefaultMinRaw,
double maxRaw = DefaultMaxRaw,
uint storageRateMs = DefaultStorageRateMs,
bool applyScaling = false,
Models.HistorianStorageType storageType = Models.HistorianStorageType.Cyclic,
double integralDivisor = 1.0)
{
if (storageRateMs == 0)
{
throw new ArgumentOutOfRangeException(nameof(storageRateMs), "Storage rate must be > 0 ms.");
}
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
byte typeCode = GetAnalogDataTypeCode(dataType);
bool isDelta = storageType == Models.HistorianStorageType.Delta;
using MemoryStream ms = new();
using BinaryWriter w = new(ms);
w.Write(isDelta ? AnalogHeaderUpToTypeCodeDelta : AnalogHeaderUpToTypeCodeCyclic); // 11 bytes
w.Write(typeCode); // 1 byte data-type discriminator
w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2)
WriteCompactAscii(w, tagName); // var
w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF
WriteCompactAscii(w, description ?? string.Empty); // var
WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS")
w.Write(isDelta ? AnalogFlagBlockDelta : AnalogFlagBlockCyclic); // 7 bytes
w.Write(storageRateMs); // uint32
if (isDelta)
{
w.Write(AnalogDeltaPostStorageRatePadding); // 4 bytes (Delta-only)
}
w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
if (minEU == DefaultMinEU && maxEU == DefaultMaxEU && minRaw == DefaultMinRaw && maxRaw == DefaultMaxRaw)
{
w.Write(AnalogScalingDefaultsMarker); // 2 bytes (1A 03)
}
else
{
w.Write(AnalogScalingExplicitMarker); // 2 bytes (1F 00)
w.Write(minEU);
w.Write(maxEU);
w.Write(minRaw);
w.Write(maxRaw); // 32 bytes total for the 4 doubles
}
WriteCompactAscii(w, engineeringUnit ?? string.Empty); // var
w.Write(IntegralDivisorMagic); // uint32 (purpose unclear — captured constant)
w.Write(integralDivisor); // double IntegralDivisor (default 1.0)
w.Write(applyScaling ? AnalogTrailerScalingEnabled : AnalogTrailerScalingDisabled);
return ms.ToArray();
}
/// <summary>
/// Serializes the tagNames byte buffer for the DelT (DeleteTags) WCF op.
/// Decoded layout from a captured DelT request:
/// <code>
/// ushort header1 = 0x6751
/// ushort header2 = 1
/// uint32 tagCount
/// for each tag: uint32 charCount + charCount × UTF-16 LE chars
/// </code>
/// </summary>
public static byte[] SerializeDeleteTagNames(IReadOnlyList<string> tagNames)
{
ArgumentNullException.ThrowIfNull(tagNames);
if (tagNames.Count == 0)
{
throw new ArgumentException("DeleteTags requires at least one tag name.", nameof(tagNames));
}
using MemoryStream ms = new();
using BinaryWriter w = new(ms);
w.Write((ushort)0x6751);
w.Write((ushort)1);
w.Write(checked((uint)tagNames.Count));
foreach (string name in tagNames)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(tagNames));
w.Write(checked((uint)name.Length));
w.Write(Encoding.Unicode.GetBytes(name));
}
return ms.ToArray();
}
/// <summary>Compact ASCII string: <c>0x09 + UInt16 byteLen + LEN ASCII bytes</c>.</summary>
private static void WriteCompactAscii(BinaryWriter writer, string value)
{
byte[] ascii = Encoding.ASCII.GetBytes(value);
if (ascii.Length > ushort.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(value), "Compact ASCII strings cannot exceed UInt16 length.");
}
writer.Write(CompactAsciiMarker);
writer.Write((ushort)ascii.Length);
writer.Write(ascii);
}
}
@@ -0,0 +1,115 @@
using System.ServiceModel;
using System.ServiceModel.Channels;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianWcfAuthChainHelper
{
private const int OpenConnection3MinResponseLength = 5;
public const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
public const uint NativeIntegratedEventConnectionMode = 0x501;
/// <summary>
/// Process + write-enabled + integrated security. Per native ilspy
/// (HistorianAccessUtil.SetConnectionMode): Process=1, OR 0x400 for integratedSecurity.
/// EnsT2 and DelT silently return false with err code 132 (OperationNotEnabled) when
/// Open2 is opened with 0x402 (read-only); 0x401 unlocks write capability.
/// </summary>
public const uint NativeIntegratedWriteEnabledConnectionMode = 0x401;
/// <summary>
/// Runs Hist.GetV → Hist.ValCl × N → Hist.Open2 against the configured /Hist endpoint and
/// returns the transient /Retr client handle decoded from the OpenConnection3 response.
/// Caller is responsible for opening the matching /Retr channel.
/// </summary>
public static uint OpenAuthenticatedConnection(
HistorianClientOptions options,
Binding historyBinding,
EndpointAddress historyEndpoint,
Guid contextKey,
CancellationToken cancellationToken,
uint connectionMode = NativeIntegratedReadOnlyConnectionMode,
Action<IHistoryServiceContract2, OpenConnectionContext>? additionalSetup = null)
{
ChannelFactory<IHistoryServiceContract2> historyFactory = new(historyBinding, historyEndpoint);
HistorianWcfClientCredentialsHelper.Configure(historyFactory, options);
historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfHistAddressingBehavior());
if (HistorianWcfMessageCaptureBehavior.IsEnabled)
{
historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfMessageCaptureBehavior());
}
try
{
IHistoryServiceContract2 historyChannel = historyFactory.CreateChannel();
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
try
{
historyChannel.GetInterfaceVersion(out _);
RunValClRounds(historyChannel, contextKey, options, cancellationToken);
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
bool open2Success = historyChannel.OpenConnection2(ref open2Request, out byte[] open2Response, out byte[] open2Error);
open2Response ??= [];
open2Error ??= [];
if (!open2Success || open2Response.Length < OpenConnection3MinResponseLength)
{
throw new InvalidOperationException(
$"Open2 failed (Success={open2Success}, ResponseLen={open2Response.Length}, ErrorLen={open2Error.Length}).");
}
(uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
if (additionalSetup is not null)
{
additionalSetup(historyChannel, new OpenConnectionContext(contextKey, clientHandle, storageSessionId));
}
return clientHandle;
}
finally
{
CloseChannelSafely(historyChannelCo);
}
}
finally
{
CloseFactorySafely(historyFactory);
}
}
public readonly record struct OpenConnectionContext(Guid ContextKey, uint ClientHandle, Guid StorageSessionId);
private static void RunValClRounds(IHistoryServiceContract2 channel, Guid contextKey, HistorianClientOptions options, CancellationToken cancellationToken)
{
HistorianNativeHandshake.RunTokenRounds(
(handle, wrapped, _) =>
{
bool serverSuccess = channel.ValidateClientCredential(handle, wrapped, out byte[] serverOutput, out byte[] errorBuffer);
return new HistorianNativeHandshake.TokenExchangeResult(serverSuccess, serverOutput ?? [], errorBuffer ?? []);
},
contextKey,
options,
cancellationToken);
}
private static void CloseChannelSafely(ICommunicationObject channel)
{
try
{
if (channel.State == CommunicationState.Faulted) channel.Abort();
else channel.Close();
}
catch { try { channel.Abort(); } catch { } }
}
private static void CloseFactorySafely<TChannel>(ChannelFactory<TChannel> factory)
{
try
{
if (factory.State == CommunicationState.Faulted) factory.Abort();
else factory.Close();
}
catch { try { factory.Abort(); } catch { } }
}
}
@@ -0,0 +1,63 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianWcfAuthenticationProtocol
{
private const uint NativeNtlmNegotiateVersionFlag = 0x0010_0000;
public static byte[] WrapValidateClientCredentialToken(bool isFirstRound, ReadOnlySpan<byte> token)
{
byte[] buffer = new byte[checked(1 + sizeof(uint) + token.Length)];
buffer[0] = isFirstRound ? (byte)1 : (byte)0;
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(1, sizeof(uint)), checked((uint)token.Length));
token.CopyTo(buffer.AsSpan(1 + sizeof(uint)));
return buffer;
}
public static bool TryApplyNativeNtlmNegotiateVersionFlag(Span<byte> token)
{
ReadOnlySpan<byte> ntlmSignature = "NTLMSSP\0"u8;
if (token.Length < 16
|| !token[..ntlmSignature.Length].SequenceEqual(ntlmSignature)
|| BinaryPrimitives.ReadUInt32LittleEndian(token.Slice(8, sizeof(uint))) != 1)
{
return false;
}
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(token.Slice(12, sizeof(uint)));
BinaryPrimitives.WriteUInt32LittleEndian(
token.Slice(12, sizeof(uint)),
flags | NativeNtlmNegotiateVersionFlag);
return true;
}
public static ValidateClientCredentialToken? TryReadWrappedValidateClientCredentialToken(ReadOnlySpan<byte> buffer)
{
if (buffer.Length < 1 + sizeof(uint))
{
return null;
}
uint tokenLength = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(1, sizeof(uint)));
if (tokenLength > int.MaxValue || buffer.Length != 1 + sizeof(uint) + (int)tokenLength)
{
return null;
}
return new ValidateClientCredentialToken(buffer[0] != 0, buffer[(1 + sizeof(uint))..].ToArray());
}
public static ValidateClientCredentialResponse? TryReadValidateClientCredentialResponse(ReadOnlySpan<byte> buffer)
{
if (buffer.Length == 0)
{
return null;
}
return new ValidateClientCredentialResponse(buffer[0] != 0, buffer[1..].ToArray());
}
}
internal sealed record ValidateClientCredentialToken(bool IsFirstRound, byte[] Token);
internal sealed record ValidateClientCredentialResponse(bool Continue, byte[] Token);
@@ -0,0 +1,212 @@
using System.Net.Security;
using System.ServiceModel;
using System.ServiceModel.Channels;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianWcfBindingFactory
{
public const string Scheme = "net.tcp";
public const int DefaultPort = 32568;
public static Binding CreateMdasNetTcpBinding(TimeSpan timeout, long maxReceivedMessageSize = 64 * 1024 * 1024)
{
var encoding = new MdasMessageEncodingBindingElement(
new BinaryMessageEncodingBindingElement
{
MessageVersion = MessageVersion.Soap12WSAddressing10
});
var transport = new TcpTransportBindingElement
{
MaxReceivedMessageSize = maxReceivedMessageSize,
TransferMode = TransferMode.Buffered
};
return new CustomBinding(encoding, transport)
{
CloseTimeout = timeout,
OpenTimeout = timeout,
ReceiveTimeout = timeout,
SendTimeout = timeout
};
}
public static Binding CreateMdasNetTcpWindowsBinding(TimeSpan timeout, long maxReceivedMessageSize = 64 * 1024 * 1024)
{
NetTcpBinding nativeShape = new(SecurityMode.Transport)
{
MaxReceivedMessageSize = maxReceivedMessageSize,
MaxBufferSize = checked((int)Math.Min(maxReceivedMessageSize, int.MaxValue))
};
nativeShape.ReaderQuotas.MaxArrayLength = nativeShape.MaxBufferSize;
nativeShape.Security.Transport.ClientCredentialType = TcpClientCredentialType.Windows;
nativeShape.Security.Transport.ProtectionLevel = ProtectionLevel.None;
BindingElementCollection elements = nativeShape.CreateBindingElements();
for (int i = 0; i < elements.Count; i++)
{
if (elements[i] is MessageEncodingBindingElement encoding)
{
elements[i] = new MdasMessageEncodingBindingElement(encoding);
break;
}
}
return new CustomBinding(elements)
{
CloseTimeout = timeout,
OpenTimeout = timeout,
ReceiveTimeout = timeout,
SendTimeout = timeout
};
}
public static Binding CreateMdasNetTcpCertificateBinding(TimeSpan timeout, long maxReceivedMessageSize = 64 * 1024 * 1024)
{
NetTcpBinding nativeShape = new(SecurityMode.Transport)
{
MaxReceivedMessageSize = maxReceivedMessageSize,
MaxBufferSize = checked((int)Math.Min(maxReceivedMessageSize, int.MaxValue))
};
nativeShape.ReaderQuotas.MaxArrayLength = nativeShape.MaxBufferSize;
nativeShape.Security.Transport.ClientCredentialType = TcpClientCredentialType.None;
BindingElementCollection elements = nativeShape.CreateBindingElements();
for (int i = 0; i < elements.Count; i++)
{
if (elements[i] is MessageEncodingBindingElement encoding)
{
elements[i] = new MdasMessageEncodingBindingElement(encoding);
break;
}
}
return new CustomBinding(elements)
{
CloseTimeout = timeout,
OpenTimeout = timeout,
ReceiveTimeout = timeout,
SendTimeout = timeout
};
}
// NetNamedPipeBinding is Windows-only at the BCL level; calling this on Linux
// throws PlatformNotSupportedException at runtime. Cross-platform callers should
// choose Transport = RemoteTcpCertificate (or RemoteTcpIntegrated on Windows).
#pragma warning disable CA1416 // Documented Windows-only entry point
public static Binding CreateMdasNetNamedPipeBinding(TimeSpan timeout, int maxBufferSize = 64 * 1024 * 1024)
{
NetNamedPipeBinding nativeShape = new()
{
MaxBufferSize = maxBufferSize,
MaxReceivedMessageSize = maxBufferSize
};
nativeShape.Security.Mode = NetNamedPipeSecurityMode.None;
nativeShape.ReaderQuotas.MaxArrayLength = maxBufferSize;
BindingElementCollection elements = nativeShape.CreateBindingElements();
for (int i = 0; i < elements.Count; i++)
{
if (elements[i] is MessageEncodingBindingElement encoding)
{
elements[i] = new MdasMessageEncodingBindingElement(encoding);
break;
}
}
return new CustomBinding(elements)
{
CloseTimeout = timeout,
OpenTimeout = timeout,
ReceiveTimeout = timeout,
SendTimeout = timeout
};
}
#pragma warning restore CA1416
public static (Binding HistoryBinding, EndpointAddress HistoryEndpoint, Binding RetrievalBinding, EndpointAddress RetrievalEndpoint) CreateBindingPair(
HistorianClientOptions options)
{
TimeSpan timeout = options.RequestTimeout;
return options.Transport switch
{
HistorianTransport.LocalPipe => (
CreateMdasNetNamedPipeBinding(timeout),
CreatePipeEndpointAddress(options.Host, HistorianWcfServiceNames.History),
CreateMdasNetNamedPipeBinding(timeout),
CreatePipeEndpointAddress(options.Host, HistorianWcfServiceNames.Retrieval)),
HistorianTransport.RemoteTcpIntegrated => (
CreateMdasNetTcpWindowsBinding(timeout),
CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.HistoryIntegrated),
CreateMdasNetTcpBinding(timeout),
CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.Retrieval)),
HistorianTransport.RemoteTcpCertificate => (
CreateMdasNetTcpCertificateBinding(timeout),
CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.HistoryCertificate, options.ServerDnsIdentity),
CreateMdasNetTcpBinding(timeout),
CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.Retrieval)),
_ => throw new NotSupportedException($"Transport {options.Transport} is not supported.")
};
}
public static EndpointAddress CreateEndpointAddress(string host, int port, string serviceName, string? dnsIdentity = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
Uri uri = new($"{Scheme}://{host}:{port}/{serviceName}");
return string.IsNullOrWhiteSpace(dnsIdentity)
? new EndpointAddress(uri)
: new EndpointAddress(uri, new DnsEndpointIdentity(dnsIdentity));
}
public static EndpointAddress CreatePipeEndpointAddress(string host, string serviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
return new EndpointAddress($"net.pipe://{host}/{serviceName}");
}
/// <summary>
/// Returns the appropriate endpoint address for an auxiliary service (Stat, Trx, etc.)
/// based on the transport — net.pipe for LocalPipe, net.tcp for the remote variants.
/// Use this rather than <see cref="CreatePipeEndpointAddress"/> directly when the calling
/// code may run under any transport.
/// </summary>
public static EndpointAddress CreateAuxiliaryEndpointAddress(HistorianClientOptions options, string serviceName)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
return options.Transport == HistorianTransport.LocalPipe
? CreatePipeEndpointAddress(options.Host, serviceName)
: CreateEndpointAddress(options.Host, options.Port, serviceName);
}
/// <summary>
/// Returns the appropriate binding for an auxiliary service (Stat, Trx, etc.) given the
/// transport. For LocalPipe, same NamedPipe binding as History. For remote TCP variants,
/// plain <see cref="CreateMdasNetTcpBinding"/> — auxiliaries don't repeat the Windows-
/// transport-security upgrade that the History service negotiates; the established session
/// authenticates the client already.
/// </summary>
// NetNamedPipeBinding / WindowsStreamSecurityBindingElement are Windows-only at the
// BCL level; calling this on Linux throws PlatformNotSupportedException at runtime.
// Cross-platform callers should choose Transport = RemoteTcpCertificate.
public static Binding CreateAuxiliaryBinding(HistorianClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
TimeSpan timeout = options.RequestTimeout;
return options.Transport switch
{
HistorianTransport.LocalPipe => CreateMdasNetNamedPipeBinding(timeout),
HistorianTransport.RemoteTcpIntegrated => CreateMdasNetTcpBinding(timeout),
HistorianTransport.RemoteTcpCertificate => CreateMdasNetTcpBinding(timeout),
_ => throw new NotSupportedException($"Transport {options.Transport} is not supported.")
};
}
}
@@ -0,0 +1,38 @@
using System.IdentityModel.Selectors;
using System.IdentityModel.Tokens;
using System.Security.Cryptography.X509Certificates;
using System.ServiceModel;
using System.ServiceModel.Security;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// Centralizes per-channel-factory credentials configuration that's not bound to a
/// single binding type. Today this covers <c>ServerCertificateValidation</c> for the
/// cert-transport binding when callers opt into <see cref="HistorianClientOptions.AllowUntrustedServerCertificate"/>.
/// Apply at every ChannelFactory&lt;T&gt; instantiation point in the WCF layer.
/// </remarks>
internal static class HistorianWcfClientCredentialsHelper
{
public static void Configure<TChannel>(ChannelFactory<TChannel> factory, HistorianClientOptions options)
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(options);
if (options.AllowUntrustedServerCertificate)
{
factory.Credentials.ServiceCertificate.SslCertificateAuthentication = new X509ServiceCertificateAuthentication
{
CertificateValidationMode = X509CertificateValidationMode.Custom,
CustomCertificateValidator = AcceptAnyCertificateValidator.Instance,
RevocationMode = X509RevocationMode.NoCheck,
};
}
}
private sealed class AcceptAnyCertificateValidator : X509CertificateValidator
{
public static readonly AcceptAnyCertificateValidator Instance = new();
public override void Validate(X509Certificate2 certificate) { }
}
}
@@ -0,0 +1,449 @@
using System.Buffers.Binary;
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// Mirrors HistorianWcfReadOrchestrator but targets IRetrievalServiceContract4 for the event flow.
/// Event row buffer layout is undecoded as of this pass — when StartEventQuery succeeds, this
/// orchestrator returns an empty enumeration but logs the row-buffer length via the
/// <see cref="LastResultBufferLength"/> diagnostic so a follow-up capture can decode the wire shape.
/// </remarks>
internal sealed class HistorianWcfEventOrchestrator
{
private const int OpenConnection3MinResponseLength = 5;
private const int CredentialBlockSizeBytes = 1026;
private const int MaxValClRounds = 8;
private const string ClientNodeNameFallback = "ZB.MOM.WW.SPHistorianClient";
private const string ClientDataSourceId = "2020.406.2652.2";
private const string ClientDllVersionString = "2020.406.2652.2";
private const byte NativeClientType = 4;
private const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
private const byte NativeClientCommonInfoFormatVersion = 4;
private const ushort NativeHcalVersion = 17;
private const uint NativeClientVersionInt = 999_999;
private const ushort NativeOpen2ClientVersion = 9;
/// <summary>
/// Documented native CM_EVENT default tag id used by aahClientManaged.dll
/// CreateDefaultEventTag → ConvertEventTagToTagMetadata. Registering this tag via
/// IHistoryServiceContract2.RegisterTags2 before StartEventQuery causes the server
/// to subscribe the session to CM_EVENT events; without it,
/// GetNextEventQueryResultBuffer returns native error type=4 code=85 (0x55).
/// </summary>
private static readonly Guid CmEventTagId = new("353b8145-5df0-4d46-a253-871aef49b321");
private readonly HistorianClientOptions _options;
public HistorianWcfEventOrchestrator(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>Diagnostic: length of the most recent event-row result buffer the server sent.</summary>
public int LastResultBufferLength { get; private set; }
/// <summary>Diagnostic: type+code description of the most recent error/terminal buffer.</summary>
public string LastErrorBufferDescription { get; private set; } = string.Empty;
/// <summary>Diagnostic: handle string passed to EnsT2.</summary>
public static string LastEnsT2Handle { get; private set; } = string.Empty;
/// <summary>Diagnostic: SHA256 of the CTagMetadata payload sent to EnsT2.</summary>
public static string LastEnsT2PayloadSha256 { get; private set; } = string.Empty;
/// <summary>Diagnostic: native return code from the prerequisite UpdC3 call.</summary>
public static uint LastUpdC3ReturnCode { get; private set; }
/// <summary>Diagnostic: native return code from the prerequisite RTag2 call.</summary>
public static uint LastRTag2ReturnCode { get; private set; }
public async IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
DateTime startUtc,
DateTime endUtc,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
{
throw new ProtocolEvidenceMissingException(
"Managed event flow currently requires IntegratedSecurity or an explicit UserName + Password.");
}
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<HistorianEvent> events = await Task.Run(
() => RunEventChain(startUtc, endUtc, cancellationToken),
cancellationToken).ConfigureAwait(false);
foreach (HistorianEvent evt in events)
{
cancellationToken.ThrowIfCancellationRequested();
yield return evt;
}
}
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, cancellationToken,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
additionalSetup: (historyChannel, context) =>
AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, cancellationToken);
}
private List<HistorianEvent> RunEventQuery(
Binding binding,
EndpointAddress retrievalEndpoint,
uint clientHandle,
DateTime startUtc,
DateTime endUtc,
CancellationToken cancellationToken)
{
ChannelFactory<IRetrievalServiceContract4> factory = new(binding, retrievalEndpoint);
HistorianWcfClientCredentialsHelper.Configure(factory, _options);
try
{
IRetrievalServiceContract4 channel = factory.CreateChannel();
ICommunicationObject channelCo = (ICommunicationObject)channel;
try
{
channel.GetInterfaceVersion(out _);
uint isAllowedReturn = channel.IsOriginalAllowed(clientHandle, out bool isAllowed);
if (isAllowedReturn != 0 || !isAllowed)
{
throw new InvalidOperationException(
$"Retr.IsOriginalAllowed denied the connection (return={isAllowedReturn}, isAllowed={isAllowed}).");
}
IReadOnlyList<HistorianEventQueryAttempt> attempts = HistorianEventQueryProtocol.CreateStartEventQueryAttempts(
startUtc.ToUniversalTime(),
endUtc.ToUniversalTime(),
eventCount: 5);
byte[] requestBuffer = attempts[0].RequestBuffer;
uint queryHandle = 0;
bool startSuccess = channel.StartEventQuery(
clientHandle,
HistorianEventQueryProtocol.QueryRequestTypeEvent,
checked((uint)requestBuffer.Length),
requestBuffer,
out _,
out _,
ref queryHandle,
out _,
out byte[] startError);
startError ??= [];
if (!startSuccess)
{
throw new InvalidOperationException(
$"Retr.StartEventQuery failed (errorLen={startError.Length}, error5={DescribeNativeError(startError)}).");
}
List<HistorianEvent> events = [];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
bool nextSuccess = channel.GetNextEventQueryResultBuffer(
clientHandle,
queryHandle,
out _,
out byte[] resultBuffer,
out _,
out byte[] errorBuffer);
resultBuffer ??= [];
errorBuffer ??= [];
LastResultBufferLength = resultBuffer.Length;
LastErrorBufferDescription = DescribeNativeError(errorBuffer);
// Any 5-byte type=4 error is treated as a soft terminal so the chain can
// surface evidence even when an unfamiliar code (e.g. 85 / 0x55 observed
// on first end-to-end runs without an event-tag registration step) blocks
// row enumeration. Code 30 (NoMoreData) is the canonical terminal; other
// codes mean "stop reading and let the caller see the diagnostic". When
// nextSuccess is false the server signaled hard failure; if there is also
// a 5-byte type=4 error buffer we still return the buffer length as
// evidence and surface via LastErrorBufferDescription rather than throw.
if (errorBuffer.Length == 5 && errorBuffer[0] == 4)
{
return events;
}
if (!nextSuccess)
{
throw new InvalidOperationException(
$"Retr.GetNextEventQueryResultBuffer failed (errorLen={errorBuffer.Length}, error5={DescribeNativeError(errorBuffer)}).");
}
if (resultBuffer.Length > 0)
{
events.AddRange(HistorianEventRowProtocol.Parse(resultBuffer));
}
if (resultBuffer.Length == 0 && errorBuffer.Length == 0)
{
return events;
}
}
}
finally
{
CloseChannelSafely(channelCo);
}
}
finally
{
CloseFactorySafely(factory);
}
}
/// <summary>Diagnostic: native return code from the last AddT(CM_EVENT) call.</summary>
public static uint LastAddReturnCode { get; private set; }
/// <summary>Diagnostic: byte length of the AddT response output buffer.</summary>
public static int LastAddOutputLength { get; private set; }
/// <remarks>
/// Calls <c>IHistoryServiceContract.AddTags</c> with the documented CM_EVENT CTagMetadata
/// payload. The chain now reaches the server's AddT handler (a real WCF response is
/// returned rather than the previous parameter-binding failure) but currently receives
/// native return code 76 against this Historian. Combined with code 85 from
/// <c>GetNextEventQueryResultBuffer</c>, two specific server rejections remain to decode
/// before live event reads return rows. The orchestrator continues regardless so the
/// caller can see the chain outcome via <see cref="LastAddReturnCode"/>,
/// <see cref="LastResultBufferLength"/>, and <see cref="LastErrorBufferDescription"/>.
/// Next concrete step: instrument <c>Wcf.AddT.Request</c> on a successful native event
/// run and compare byte-for-byte against this serialiser's output.
/// </remarks>
/// <remarks>
/// Replays the native event-tag registration sequence captured via the
/// instrument-wcf-writemessage IL-rewrite tool: UpdC3 (UpdateClientStatus3) → RTag2
/// (RegisterTags2 with the CM_EVENT tag id) → EnsT2 (EnsureTags2 with the full
/// CTagMetadata blob). The 81-byte UpdC3 status blob and 24-byte RTag2 buffer are
/// captured byte-for-byte from a successful native event read; the EnsT2 payload is
/// regenerated by <see cref="HistorianAddTagsProtocol.SerializeCmEventCTagMetadata"/>.
/// The Stat-service queries the native client also issues (Stat/GetV, Stat/GETHI,
/// Stat/GetSystemParameter for AllowOriginals/HistorianPartner/HistorianVersion/
/// MaxCyclicStorageTimeout/RealTimeWindow/FutureTimeThreshold/AllowRenameTags) appear
/// informational and are skipped here.
/// </remarks>
private static void AddCmEventTagViaAddT(
IHistoryServiceContract2 historyChannel,
HistorianWcfAuthChainHelper.OpenConnectionContext context,
Binding statusBinding,
EndpointAddress statusEndpoint,
EndpointAddress transactionEndpoint,
Binding retrievalBinding,
EndpointAddress retrievalEndpoint)
{
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
LastEnsT2Handle = handle;
ChannelFactory<IStatusServiceContract2> statusFactory = new(statusBinding, statusEndpoint);
IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
ICommunicationObject statusCo = (ICommunicationObject)statusChannel;
ChannelFactory<ITransactionServiceContract> transactionFactory = new(statusBinding, transactionEndpoint);
ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
ICommunicationObject transactionCo = (ICommunicationObject)transactionChannel;
ChannelFactory<IRetrievalServiceContract4> retrievalFactory = new(retrievalBinding, retrievalEndpoint);
IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel();
ICommunicationObject retrievalCo = (ICommunicationObject)retrievalChannel;
try
{
// Replays the discovery dance the native event flow runs between Open2 and EnsT2,
// captured byte-for-byte via instrument-wcf-{write,read}message. Best-effort —
// individual calls may fail on this server; the chain continues regardless because
// the goal is to put the server-side session into the state EnsT2 expects.
TryRun(() => statusChannel.GetInterfaceVersion(out _));
TryRun(() => statusChannel.GetInterfaceVersion(out _));
byte[] historianVersionRequest = BuildGetHistorianInfoRequest("HistorianVersion");
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
byte[] clientStatus = BuildUpdC3ClientStatusBlob();
bool updSuccess = historyChannel.UpdateClientStatus3(
handle: handle,
clientStatusSize: (uint)clientStatus.Length,
clientStatus: ref clientStatus,
serverStatusSize: out _,
serverStatus: out _,
errorSize: out _,
errorBuffer: out _);
LastUpdC3ReturnCode = updSuccess ? 0u : 1u;
// Records 11-16: 6 system-parameter queries before RTag2.
foreach (string parameterName in NativeStatusParametersBeforeRTag2)
{
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _));
}
byte[] registerBuffer = BuildRTag2CmEventInputBuffer();
bool registerSuccess = historyChannel.RegisterTags2(
handle: handle,
elementCount: 1,
inputBuffer: registerBuffer,
outputBuffer: out _,
errorBuffer: out _);
LastRTag2ReturnCode = registerSuccess ? 0u : 1u;
// Record 18: one more system-parameter query after RTag2 before EnsT2.
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, "AllowRenameTags", out _, out _, out _));
// Records 19-21: cross-service version probes the native client makes between
// RTag2 and EnsT2. They likely register the client with each service's session
// table; without them EnsT2 may reject the session.
TryRun(() => transactionChannel.GetInterfaceVersion(out _));
TryRun(() => statusChannel.GetInterfaceVersion(out _));
TryRun(() => retrievalChannel.GetInterfaceVersion(out _));
byte[] payload = HistorianAddTagsProtocol.SerializeCmEventCTagMetadata(DateTime.UtcNow);
using (var sha = System.Security.Cryptography.SHA256.Create())
{
byte[] hash = sha.ComputeHash(payload);
LastEnsT2PayloadSha256 = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
}
bool ensureSuccess = historyChannel.EnsureTags2(
handle: handle,
elementCount: 1,
inputBuffer: payload,
outputBuffer: out byte[] addOutput,
errorBuffer: out _);
LastAddReturnCode = ensureSuccess ? 0u : 1u;
LastAddOutputLength = addOutput?.Length ?? 0;
}
catch (Exception ex)
{
LastAddReturnCode = 0xFFFFFFFFu;
LastAddOutputLength = 0;
_ = ex;
}
finally
{
CloseChannelSafely(retrievalCo);
CloseFactorySafely(retrievalFactory);
CloseChannelSafely(transactionCo);
CloseFactorySafely(transactionFactory);
CloseChannelSafely(statusCo);
CloseFactorySafely(statusFactory);
}
}
private static readonly string[] NativeStatusParametersBeforeRTag2 =
[
"AllowOriginals",
"HistorianPartner",
"HistorianVersion",
"MaxCyclicStorageTimeout",
"RealTimeWindow",
"FutureTimeThreshold",
];
private static void TryRun(Action action)
{
try { action(); }
catch { }
}
/// <summary>
/// Native GETHI pRequestBuff layout for a parameter-name query: 8-byte header
/// (UInt16 0x6753 + UInt16 0x0002 + UInt32 nameLength) + UTF-16 LE chars (no
/// trailing null byte — observed truncated by 1 byte vs full UTF-16 in the
/// captured native bytes). Layout taken from
/// writemessage-capture-event-latest.ndjson record 8.
/// </summary>
private static byte[] BuildGetHistorianInfoRequest(string parameterName)
{
byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName);
// Native truncates the trailing high byte of the last UTF-16 char.
int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0;
byte[] buffer = new byte[8 + payloadLength];
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753);
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002);
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length);
Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength);
return buffer;
}
/// <summary>
/// 81-byte UpdC3 clientStatus blob captured from a native event read (record 10 of
/// writemessage-capture-event-latest.ndjson). Layout: 0x02 0x01 + 76 zero bytes +
/// uint32(0x0000001E). The trailing 30 is likely an interval / timeout in seconds; all
/// other observed fields are zero for a fresh session.
/// </summary>
private static byte[] BuildUpdC3ClientStatusBlob()
{
byte[] blob = new byte[81];
blob[0] = 0x02;
blob[1] = 0x01;
blob[77] = 0x1E;
return blob;
}
/// <summary>
/// 24-byte RTag2 pInBuff captured from a native event read (record 17). Layout:
/// 8-byte header (0x50 0x67 0x02 0x00 + uint32 element count = 1) + 16-byte tag id GUID.
/// </summary>
private static byte[] BuildRTag2CmEventInputBuffer()
{
byte[] buffer = new byte[24];
buffer[0] = 0x50;
buffer[1] = 0x67;
buffer[2] = 0x02;
buffer[3] = 0x00;
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), 1u);
CmEventTagId.ToByteArray().CopyTo(buffer.AsSpan(8, 16));
return buffer;
}
private static string DescribeNativeError(byte[] errorBuffer)
{
if (errorBuffer.Length < 5)
{
return "<short>";
}
byte type = errorBuffer[0];
uint code = BinaryPrimitives.ReadUInt32LittleEndian(errorBuffer.AsSpan(1, 4));
return $"type={type} code={code} (0x{code:X})";
}
private static void CloseChannelSafely(ICommunicationObject channel)
{
try
{
if (channel.State == CommunicationState.Faulted) channel.Abort();
else channel.Close();
}
catch { try { channel.Abort(); } catch { } }
}
private static void CloseFactorySafely<TChannel>(ChannelFactory<TChannel> factory)
{
try
{
if (factory.State == CommunicationState.Faulted) factory.Abort();
else factory.Close();
}
catch { try { factory.Abort(); } catch { } }
}
}
@@ -0,0 +1,38 @@
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using System.ServiceModel.Description;
using System.ServiceModel.Dispatcher;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// Forces an explicit <c>wsa:To</c> URI on every outgoing message. Native captures
/// of EnsT2 / DelT include <c>net.pipe://localhost/Hist</c> in the addressing header
/// block; without it the server appears to accept the body but not act on it
/// (silent fail observed for both write ops). WCF normally derives To from the
/// endpoint address, but the captured SDK bytes show it absent — re-asserting it
/// here closes the gap.
/// </remarks>
internal sealed class HistorianWcfHistAddressingBehavior : IEndpointBehavior
{
public void Validate(ServiceEndpoint endpoint) { }
public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) { }
public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
{
clientRuntime.ClientMessageInspectors.Add(new ToHeaderInspector(endpoint.Address.Uri));
}
private sealed class ToHeaderInspector(Uri toUri) : IClientMessageInspector
{
public object? BeforeSendRequest(ref Message request, IClientChannel channel)
{
request.Headers.To = toUri;
return null;
}
public void AfterReceiveReply(ref Message reply, object? correlationState) { }
}
}
@@ -0,0 +1,95 @@
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using System.ServiceModel.Description;
using System.ServiceModel.Dispatcher;
using System.Text.Json;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// Reverse-engineering aid: when the env var <c>AVEVA_HISTORIAN_SDK_WIRE_CAPTURE</c> is set,
/// every outgoing WCF message body and every incoming response body on this endpoint is
/// captured to that file as one ndjson record per call. Pair with the
/// <c>instrument-wcf-{write,read}message</c> native captures and diff offset-by-offset to
/// isolate SDK-vs-native differences. NEVER enable in production.
/// </remarks>
internal sealed class HistorianWcfMessageCaptureBehavior : IEndpointBehavior
{
public const string CapturePathEnvVar = "AVEVA_HISTORIAN_SDK_WIRE_CAPTURE";
public static bool IsEnabled => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(CapturePathEnvVar));
public void Validate(ServiceEndpoint endpoint) { }
public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) { }
public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
{
clientRuntime.ClientMessageInspectors.Add(new MessageCaptureInspector());
}
private sealed class MessageCaptureInspector : IClientMessageInspector
{
private static readonly object Lock = new();
public object? BeforeSendRequest(ref Message request, IClientChannel channel)
{
CaptureMessage("SDK.WriteMessage.Body", ref request);
return null;
}
public void AfterReceiveReply(ref Message reply, object? correlationState)
{
CaptureMessage("SDK.ReadMessage.Body", ref reply);
}
private static void CaptureMessage(string phase, ref Message message)
{
string? path = Environment.GetEnvironmentVariable(CapturePathEnvVar);
if (string.IsNullOrWhiteSpace(path) || message.IsEmpty)
{
return;
}
try
{
// Buffer the message so we can both inspect and forward the bytes.
MessageBuffer buffer = message.CreateBufferedCopy(int.MaxValue);
Message copy = buffer.CreateMessage();
using MemoryStream ms = new();
BinaryMessageEncodingBindingElement binaryEncoder = new();
MessageEncoderFactory factory = binaryEncoder.CreateMessageEncoderFactory();
factory.Encoder.WriteMessage(copy, ms);
byte[] bytes = ms.ToArray();
message = buffer.CreateMessage();
string action = message.Headers.Action ?? "<no-action>";
var record = new
{
TimestampUtc = DateTimeOffset.UtcNow.ToString("O"),
Phase = phase,
Action = action,
Length = bytes.Length,
Base64 = Convert.ToBase64String(bytes),
};
string? dir = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
lock (Lock)
{
File.AppendAllText(path, JsonSerializer.Serialize(record) + Environment.NewLine);
}
}
catch
{
// Capture is reverse-engineering aid — never let it break the live call.
}
}
}
}
@@ -0,0 +1,115 @@
using System.ServiceModel;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianWcfProbe
{
public static async Task<bool> ProbeAsync(HistorianClientOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
TimeSpan timeout = options.ConnectTimeout > TimeSpan.Zero
? options.ConnectTimeout
: TimeSpan.FromSeconds(5);
return await Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
WcfServiceVersion history = ProbeService<IHistoryServiceContract>(
options,
HistorianWcfServiceNames.History,
static channel =>
{
uint returnCode = channel.GetInterfaceVersion(out uint version);
return new WcfServiceVersion(returnCode, version);
},
timeout);
WcfServiceVersion retrieval = ProbeService<IRetrievalServiceContract>(
options,
HistorianWcfServiceNames.Retrieval,
static channel =>
{
uint returnCode = channel.GetInterfaceVersion(out uint version);
return new WcfServiceVersion(returnCode, version);
},
timeout);
WcfServiceVersion status = ProbeService<IStatusServiceContract>(
options,
HistorianWcfServiceNames.Status,
static channel =>
{
uint returnCode = channel.GetInterfaceVersion(out uint version);
return new WcfServiceVersion(returnCode, version);
},
timeout);
return history.ReturnCode == 0
&& history.InterfaceVersion > 0
&& retrieval.ReturnCode == 0
&& retrieval.InterfaceVersion > 0
&& status.ReturnCode == 0;
}, cancellationToken).ConfigureAwait(false);
}
private static WcfServiceVersion ProbeService<TContract>(
HistorianClientOptions options,
string serviceName,
Func<TContract, WcfServiceVersion> call,
TimeSpan timeout)
where TContract : class
{
ChannelFactory<TContract>? factory = null;
TContract? channel = null;
try
{
factory = new ChannelFactory<TContract>(
HistorianWcfBindingFactory.CreateMdasNetTcpBinding(timeout),
HistorianWcfBindingFactory.CreateEndpointAddress(options.Host, options.Port, serviceName));
factory.Open();
channel = factory.CreateChannel();
if (channel is IClientChannel clientChannel)
{
clientChannel.Open();
}
return call(channel);
}
finally
{
AbortOrClose(channel);
AbortOrClose(factory);
}
}
private static void AbortOrClose(object? communicationObject)
{
if (communicationObject is not ICommunicationObject clientChannel)
{
return;
}
try
{
if (clientChannel.State == CommunicationState.Faulted)
{
clientChannel.Abort();
}
else
{
clientChannel.Close();
}
}
catch
{
clientChannel.Abort();
}
}
private readonly record struct WcfServiceVersion(uint ReturnCode, uint InterfaceVersion);
}
@@ -0,0 +1,474 @@
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal sealed class HistorianWcfReadOrchestrator
{
private const ushort StartQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData;
private const int CredentialBlockSizeBytes = 1026;
private const int OpenConnection3MinResponseLength = 5;
private const string ClientNodeNameFallback = "ZB.MOM.WW.SPHistorianClient";
private const string ClientDataSourceId = "2020.406.2652.2";
private const string ClientDllVersionString = "2020.406.2652.2";
private const byte NativeClientType = 4;
private const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
private const byte NativeClientCommonInfoFormatVersion = 4;
private const ushort NativeHcalVersion = 17;
private const uint NativeClientVersionInt = 999_999;
private const ushort NativeOpen2ClientVersion = 9;
private const int MaxValClRounds = 8;
private readonly HistorianClientOptions _options;
public HistorianWcfReadOrchestrator(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public async IAsyncEnumerable<HistorianSample> ReadRawAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
int maxValues,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ValidateTransportAndAuth();
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<HistorianSample> rows = await Task.Run(() => RunRawChain(tag, startUtc, endUtc, maxValues, cancellationToken), cancellationToken).ConfigureAwait(false);
foreach (HistorianSample sample in rows)
{
cancellationToken.ThrowIfCancellationRequested();
yield return sample;
}
}
public async IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
string tag,
DateTime startUtc,
DateTime endUtc,
Models.RetrievalMode mode,
TimeSpan interval,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
ValidateTransportAndAuth();
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<HistorianAggregateSample> rows = await Task.Run(
() => RunAggregateChain(tag, startUtc, endUtc, mode, interval, cancellationToken),
cancellationToken).ConfigureAwait(false);
foreach (HistorianAggregateSample sample in rows)
{
cancellationToken.ThrowIfCancellationRequested();
yield return sample;
}
}
public async Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
string tag,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken)
{
ValidateTransportAndAuth();
cancellationToken.ThrowIfCancellationRequested();
return await Task.Run(() => RunAtTimeChain(tag, timestampsUtc, cancellationToken), cancellationToken).ConfigureAwait(false);
}
private void ValidateTransportAndAuth()
{
if (!_options.IntegratedSecurity && string.IsNullOrEmpty(_options.UserName))
{
throw new ProtocolEvidenceMissingException(
"Managed read flow currently requires IntegratedSecurity or an explicit UserName + Password.");
}
}
private List<HistorianSample> RunRawChain(
string tag,
DateTime startUtc,
DateTime endUtc,
int maxValues,
CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(_options, histBinding, histEndpoint, contextKey, cancellationToken);
return RunQuery(retrBinding, retrEndpoint, clientHandle, tag, startUtc, endUtc, maxValues, cancellationToken);
}
private List<HistorianAggregateSample> RunAggregateChain(
string tag,
DateTime startUtc,
DateTime endUtc,
Models.RetrievalMode mode,
TimeSpan interval,
CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(_options, histBinding, histEndpoint, contextKey, cancellationToken);
return RunAggregateQuery(retrBinding, retrEndpoint, clientHandle, tag, startUtc, endUtc, mode, interval, cancellationToken);
}
private List<HistorianSample> RunAtTimeChain(
string tag,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken)
{
if (timestampsUtc.Count == 0)
{
return [];
}
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(_options, histBinding, histEndpoint, contextKey, cancellationToken);
List<HistorianSample> results = new(timestampsUtc.Count);
foreach (DateTime ts in timestampsUtc)
{
cancellationToken.ThrowIfCancellationRequested();
DateTime tsUtc = ts.ToUniversalTime();
DateTime windowStart = tsUtc - TimeSpan.FromTicks(1);
DateTime windowEnd = tsUtc + TimeSpan.FromTicks(1);
List<HistorianAggregateSample> aggregates = RunAggregateQuery(
retrBinding,
retrEndpoint,
clientHandle,
tag,
windowStart,
windowEnd,
Models.RetrievalMode.Interpolated,
TimeSpan.FromTicks(2),
cancellationToken);
if (aggregates.Count == 0)
{
continue;
}
HistorianAggregateSample chosen = aggregates[0];
results.Add(new HistorianSample(
TagName: chosen.TagName,
TimestampUtc: tsUtc,
NumericValue: chosen.Value,
StringValue: null,
Quality: chosen.Quality,
QualityDetail: chosen.QualityDetail,
OpcQuality: chosen.OpcQuality,
PercentGood: 100));
}
return results;
}
private List<HistorianSample> RunQuery(
Binding binding,
EndpointAddress retrievalEndpoint,
uint clientHandle,
string tag,
DateTime startUtc,
DateTime endUtc,
int maxValues,
CancellationToken cancellationToken)
{
ChannelFactory<IRetrievalServiceContract2> retrievalFactory = new(binding, retrievalEndpoint);
HistorianWcfClientCredentialsHelper.Configure(retrievalFactory, _options);
try
{
IRetrievalServiceContract2 retrievalChannel = retrievalFactory.CreateChannel();
ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel;
try
{
retrievalChannel.GetInterfaceVersion(out _);
uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed);
if (isAllowedReturn != 0 || !isAllowed)
{
throw new InvalidOperationException(
$"Retr.IsOriginalAllowed denied the connection (return={isAllowedReturn}, isAllowed={isAllowed}).");
}
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(BuildDataQueryRequest(tag, startUtc, endUtc, maxValues));
uint queryHandle = 0;
bool startSuccess = retrievalChannel.StartQuery2(
clientHandle,
StartQueryRequestType,
checked((uint)requestBuffer.Length),
requestBuffer,
out _,
out _,
ref queryHandle,
out _,
out byte[] startError);
startError ??= [];
if (!startSuccess)
{
throw new InvalidOperationException(
$"Retr.StartQuery2 failed (errorLen={startError.Length}).");
}
List<HistorianSample> samples = [];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
bool nextSuccess = retrievalChannel.GetNextQueryResultBuffer2(
clientHandle,
queryHandle,
out _,
out byte[] resultBuffer,
out _,
out byte[] errorBuffer);
resultBuffer ??= [];
errorBuffer ??= [];
if (!nextSuccess)
{
throw new InvalidOperationException(
$"Retr.GetNextQueryResultBuffer2 failed (errorLen={errorBuffer.Length}).");
}
if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferRows(resultBuffer, errorBuffer, out IReadOnlyList<HistorianSample> rows, out bool hasMoreData))
{
throw new InvalidOperationException(
$"Retr.GetNextQueryResultBuffer2 returned an unparsable result buffer (length={resultBuffer.Length}).");
}
foreach (HistorianSample sample in rows)
{
samples.Add(sample);
if (samples.Count >= maxValues)
{
return samples;
}
}
if (!hasMoreData)
{
return samples;
}
}
}
finally
{
CloseChannelSafely(retrievalChannelCo);
}
}
finally
{
CloseFactorySafely(retrievalFactory);
}
}
private List<HistorianAggregateSample> RunAggregateQuery(
Binding binding,
EndpointAddress retrievalEndpoint,
uint clientHandle,
string tag,
DateTime startUtc,
DateTime endUtc,
Models.RetrievalMode mode,
TimeSpan interval,
CancellationToken cancellationToken)
{
ChannelFactory<IRetrievalServiceContract2> retrievalFactory = new(binding, retrievalEndpoint);
HistorianWcfClientCredentialsHelper.Configure(retrievalFactory, _options);
try
{
IRetrievalServiceContract2 retrievalChannel = retrievalFactory.CreateChannel();
ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel;
try
{
retrievalChannel.GetInterfaceVersion(out _);
uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed);
if (isAllowedReturn != 0 || !isAllowed)
{
throw new InvalidOperationException(
$"Retr.IsOriginalAllowed denied the connection (return={isAllowedReturn}, isAllowed={isAllowed}).");
}
HistorianDataQueryRequest request = BuildAggregateQueryRequest(tag, startUtc, endUtc, mode, interval);
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
uint queryHandle = 0;
bool startSuccess = retrievalChannel.StartQuery2(
clientHandle,
StartQueryRequestType,
checked((uint)requestBuffer.Length),
requestBuffer,
out _,
out _,
ref queryHandle,
out _,
out byte[] startError);
startError ??= [];
if (!startSuccess)
{
throw new InvalidOperationException(
$"Retr.StartQuery2 (aggregate {mode}) failed (errorLen={startError.Length}).");
}
List<HistorianAggregateSample> samples = [];
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
bool nextSuccess = retrievalChannel.GetNextQueryResultBuffer2(
clientHandle,
queryHandle,
out _,
out byte[] resultBuffer,
out _,
out byte[] errorBuffer);
resultBuffer ??= [];
errorBuffer ??= [];
if (!nextSuccess)
{
throw new InvalidOperationException(
$"Retr.GetNextQueryResultBuffer2 (aggregate {mode}) failed (errorLen={errorBuffer.Length}).");
}
if (!HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferAggregateRows(
resultBuffer,
errorBuffer,
mode,
interval,
out IReadOnlyList<HistorianAggregateSample> rows,
out bool hasMoreData))
{
throw new InvalidOperationException(
$"Retr.GetNextQueryResultBuffer2 (aggregate {mode}) returned an unparsable buffer (length={resultBuffer.Length}).");
}
samples.AddRange(rows);
if (!hasMoreData)
{
return samples;
}
}
}
finally
{
CloseChannelSafely(retrievalChannelCo);
}
}
finally
{
CloseFactorySafely(retrievalFactory);
}
}
internal static HistorianDataQueryRequest BuildDataQueryRequest(string tag, DateTime startUtc, DateTime endUtc, int maxValues)
{
return new HistorianDataQueryRequest(
TagNames: [tag],
StartUtc: startUtc.ToUniversalTime(),
EndUtc: endUtc.ToUniversalTime(),
MaxStates: checked((ushort)Math.Min(maxValues, ushort.MaxValue)),
BatchSize: 1,
Option: string.Empty);
}
internal static HistorianDataQueryRequest BuildAggregateQueryRequest(
string tag,
DateTime startUtc,
DateTime endUtc,
Models.RetrievalMode mode,
TimeSpan interval)
{
uint queryType = MapRetrievalModeToQueryType(mode);
return new HistorianDataQueryRequest(
TagNames: [tag],
StartUtc: startUtc.ToUniversalTime(),
EndUtc: endUtc.ToUniversalTime(),
MaxStates: 0,
BatchSize: 1,
Option: string.Empty)
{
QueryType = queryType,
Resolution = interval,
AggregationType = MapRetrievalModeToAggregationType(mode)
};
}
/// <summary>
/// QueryType wire value matches the native <c>ArchestrA.HistorianRetrievalMode</c> enum
/// ordinal exactly — verified 2026-05-04 by probing every mode through the
/// <c>instrument-wcf-writemessage</c> capture pipeline and reading the QueryType uint32
/// at offset 2 of <c>pRequestBuff</c>:
/// <code>
/// Cyclic=0 Delta=1 Full=2 Interpolated=3 BestFit=4 TimeWeightedAverage=5
/// MinimumWithTime=6 MaximumWithTime=7 Integral=8 Slope=9 Counter=10
/// ValueState=11 RoundTrip=12 StartBound=13 EndBound=14
/// </code>
/// The public <see cref="Models.RetrievalMode"/> enum mirrors the native order, so the
/// mapping reduces to <c>(uint)mode</c>. Prior version mapped <c>Cyclic</c> to 4
/// (BestFit's value) and threw for everything outside the four common modes.
/// </summary>
internal static uint MapRetrievalModeToQueryType(Models.RetrievalMode mode)
{
if (!Enum.IsDefined(mode))
{
throw new ProtocolEvidenceMissingException($"Retrieval mode {mode} is not a defined RetrievalMode value.");
}
return (uint)mode;
}
internal static uint MapRetrievalModeToAggregationType(Models.RetrievalMode mode) => mode switch
{
Models.RetrievalMode.TimeWeightedAverage => 0,
Models.RetrievalMode.Interpolated => 3,
_ => 3
};
private static void CloseChannelSafely(ICommunicationObject channel)
{
try
{
if (channel.State == CommunicationState.Faulted)
{
channel.Abort();
}
else
{
channel.Close();
}
}
catch
{
try { channel.Abort(); } catch { /* swallow */ }
}
}
private static void CloseFactorySafely<TChannel>(ChannelFactory<TChannel> factory)
{
try
{
if (factory.State == CommunicationState.Faulted)
{
factory.Abort();
}
else
{
factory.Close();
}
}
catch
{
try { factory.Abort(); } catch { /* swallow */ }
}
}
}
@@ -0,0 +1,275 @@
using System.Buffers.Binary;
using System.ServiceModel;
using System.ServiceModel.Channels;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// Drives the AddNonStreamValuesBegin / AddNonStreamValues / AddNonStreamValuesEnd
/// WCF op group on the <c>/Trx</c> service end-to-end. The native AVEVA wrapper's
/// equivalent surface (<c>HistorianAccess.AddRevisionValues*</c>) is gated by the
/// C++ <c>HistorianClient</c>'s per-connection cache and rejects all writes from a
/// managed client with err 129 <c>TagNotFoundInCache</c>. This SDK orchestrator
/// bypasses the wrapper entirely — talks WCF directly — to test whether the SERVER
/// gates on the same condition.
///
/// Live behavior is unverified. The first iteration is probe-only: open the auth
/// chain, drive the standard write priming, call AddNonStreamValuesBegin and
/// surface whatever the server returns.
/// </remarks>
internal sealed class HistorianWcfRevisionOrchestrator
{
private readonly HistorianClientOptions _options;
public HistorianWcfRevisionOrchestrator(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public Task<HistorianRevisionProbeResult> ProbeBeginAsync(CancellationToken cancellationToken)
=> Task.Run(() => ProbeBegin(cancellationToken), cancellationToken);
private HistorianRevisionProbeResult ProbeBegin(CancellationToken cancellationToken)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
HistorianRevisionProbeResult result = new();
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, cancellationToken,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
additionalSetup: (historyChannel, context) =>
{
result.OpenSucceeded = true;
result.ClientHandle = context.ClientHandle;
result.StorageSessionId = context.StorageSessionId;
// Run the same priming chain that EnsT2/DelT use — without it, the Trx
// service rejects calls with err 51 UnknownClient because the client
// hasn't registered itself across the auxiliary services.
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
EndpointAddress retrievalEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Retrieval);
RunPrimingChain(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
// Hypothesis: calling RTag2 (RegisterTags2) cascades client identity into
// the Trx service's session table. The event flow uses RTag2 with the
// CM_EVENT tag id and subsequent ops succeed. Try RTag2 with that same
// tag id here as a registration probe.
try
{
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
byte[] rtag2Buffer = BuildRTag2CmEventInputBuffer();
bool rtag2Ok = historyChannel.RegisterTags2(
handle: handle,
elementCount: 1,
inputBuffer: rtag2Buffer,
outputBuffer: out byte[] rtag2Out,
errorBuffer: out byte[] rtag2Err);
result.RTag2Succeeded = rtag2Ok;
result.RTag2OutHex = rtag2Out is null || rtag2Out.Length == 0 ? null : Convert.ToHexString(rtag2Out);
result.RTag2ErrorHex = rtag2Err is null || rtag2Err.Length == 0 ? null : Convert.ToHexString(rtag2Err);
}
catch (Exception ex)
{
result.RTag2Exception = $"{ex.GetType().Name}: {ex.Message}";
}
ChannelFactory<ITransactionServiceContract2> trxFactory = new(auxBinding, transactionEndpoint);
HistorianWcfClientCredentialsHelper.Configure(trxFactory, _options);
ITransactionServiceContract2 trxChannel = trxFactory.CreateChannel();
ICommunicationObject trxCo = (ICommunicationObject)trxChannel;
try
{
// Get interface version first to register the client in the Trx service's
// session table (matches the cross-service GetV priming pattern used by
// RunWritePriming for EnsT2/DelT).
try
{
uint trxRc = trxChannel.GetInterfaceVersion(out uint trxVersion);
result.TrxInterfaceVersionReturnCode = trxRc;
result.TrxInterfaceVersion = trxVersion;
}
catch (Exception ex)
{
result.TrxInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}";
}
// Probe V2 AddNonStreamValuesBegin2. Try BOTH possible handle formats —
// the server returns 0433000000 (UnknownClient = 51) when the wrong one
// is sent. Capture which one (if any) is recognized.
foreach ((string label, string handle) in new[]
{
("contextKey", contextKey.ToString("D").ToUpperInvariant()),
("storageSessionId", context.StorageSessionId.ToString("D").ToUpperInvariant()),
("contextKey-lower", contextKey.ToString("D")),
("clientHandle-as-string", context.ClientHandle.ToString()),
})
{
try
{
string? transactionId = null;
byte[]? errorBuffer = null;
bool ok = trxChannel.AddNonStreamValuesBegin2(handle, out transactionId, out errorBuffer);
result.BeginAttempts.Add(new HistorianRevisionBeginAttempt
{
HandleLabel = label,
HandleSent = handle,
Succeeded = ok,
TransactionId = transactionId,
ErrorHex = errorBuffer is null || errorBuffer.Length == 0 ? null : Convert.ToHexString(errorBuffer),
});
if (ok && !string.IsNullOrEmpty(transactionId))
{
result.BeginSucceeded = true;
result.BeginTransactionId = transactionId;
break;
}
}
catch (Exception ex)
{
result.BeginAttempts.Add(new HistorianRevisionBeginAttempt
{
HandleLabel = label,
HandleSent = handle,
Exception = $"{ex.GetType().Name}: {ex.Message}",
});
}
}
}
finally
{
try { if (trxCo.State == CommunicationState.Faulted) trxCo.Abort(); else trxCo.Close(); } catch { try { trxCo.Abort(); } catch { } }
try { if (trxFactory.State == CommunicationState.Faulted) trxFactory.Abort(); else trxFactory.Close(); } catch { try { trxFactory.Abort(); } catch { } }
}
});
return result;
}
/// <summary>
/// Mirrors HistorianWcfTagWriteOrchestrator.RunWritePriming. The cross-service GetV
/// calls + UpdC3 register the client in each aux service's session table so that
/// subsequent ops (like AddNonStreamValuesBegin2 on /Trx) recognize the handle.
/// </summary>
private static void RunPrimingChain(
IHistoryServiceContract2 historyChannel,
HistorianWcfAuthChainHelper.OpenConnectionContext context,
Binding auxBinding,
EndpointAddress statusEndpoint,
EndpointAddress transactionEndpoint,
EndpointAddress retrievalEndpoint)
{
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
ChannelFactory<IStatusServiceContract2> statusFactory = new(auxBinding, statusEndpoint);
IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
ChannelFactory<ITransactionServiceContract> transactionFactory = new(auxBinding, transactionEndpoint);
ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
ChannelFactory<IRetrievalServiceContract4> retrievalFactory = new(auxBinding, retrievalEndpoint);
IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel();
try
{
TryRun(() => statusChannel.GetInterfaceVersion(out _));
TryRun(() => statusChannel.GetInterfaceVersion(out _));
byte[] historianVersionRequest = BuildGetHistorianInfoRequest("HistorianVersion");
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
byte[] clientStatus = BuildUpdC3ClientStatusBlob();
TryRun(() => historyChannel.UpdateClientStatus3(handle, (uint)clientStatus.Length, ref clientStatus, out _, out _, out _, out _));
foreach (string parameterName in new[] { "AllowOriginals", "HistorianPartner", "HistorianVersion", "MaxCyclicStorageTimeout", "RealTimeWindow", "FutureTimeThreshold", "AllowRenameTags" })
{
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _));
}
TryRun(() => transactionChannel.GetInterfaceVersion(out _));
TryRun(() => statusChannel.GetInterfaceVersion(out _));
TryRun(() => retrievalChannel.GetInterfaceVersion(out _));
}
finally
{
CloseSafely(retrievalChannel, retrievalFactory);
CloseSafely(transactionChannel, transactionFactory);
CloseSafely(statusChannel, statusFactory);
}
}
/// <summary>Same 24-byte RTag2 buffer the event flow uses (CM_EVENT tag id).</summary>
private static byte[] BuildRTag2CmEventInputBuffer()
{
byte[] buffer = new byte[24];
buffer[0] = 0x50;
buffer[1] = 0x67;
buffer[2] = 0x02;
buffer[3] = 0x00;
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), 1u);
// CM_EVENT tag id — duplicated here to avoid a cross-class dependency on the
// event orchestrator. Verify against HistorianWcfEventOrchestrator.CmEventTagId
// if the value ever needs updating.
new Guid("353b8145-5df0-4d46-a253-871aef49b321").ToByteArray().CopyTo(buffer.AsSpan(8, 16));
return buffer;
}
private static byte[] BuildUpdC3ClientStatusBlob()
{
byte[] blob = new byte[81];
blob[0] = 0x02;
blob[1] = 0x01;
blob[77] = 0x1E;
return blob;
}
private static byte[] BuildGetHistorianInfoRequest(string parameterName)
{
byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName);
int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0;
byte[] buffer = new byte[8 + payloadLength];
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753);
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002);
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length);
Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength);
return buffer;
}
private static void TryRun(Action a) { try { a(); } catch { } }
private static void CloseSafely(object channel, ICommunicationObject factory)
{
try { if (channel is ICommunicationObject co) { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } } catch { }
try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { }
}
}
internal sealed class HistorianRevisionProbeResult
{
public bool OpenSucceeded { get; set; }
public uint ClientHandle { get; set; }
public Guid StorageSessionId { get; set; }
public uint? TrxInterfaceVersionReturnCode { get; set; }
public uint? TrxInterfaceVersion { get; set; }
public string? TrxInterfaceVersionException { get; set; }
public string? BeginTransactionId { get; set; }
public bool BeginSucceeded { get; set; }
public string? BeginErrorHex { get; set; }
public string? BeginException { get; set; }
public List<HistorianRevisionBeginAttempt> BeginAttempts { get; } = new();
public bool RTag2Succeeded { get; set; }
public string? RTag2OutHex { get; set; }
public string? RTag2ErrorHex { get; set; }
public string? RTag2Exception { get; set; }
}
internal sealed class HistorianRevisionBeginAttempt
{
public string HandleLabel { get; set; } = "";
public string HandleSent { get; set; } = "";
public bool Succeeded { get; set; }
public string? TransactionId { get; set; }
public string? ErrorHex { get; set; }
public string? Exception { get; set; }
}
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianWcfServiceNames
{
public const string Namespace = "aa";
public const string History = "Hist";
public const string HistoryCertificate = "HistCert";
public const string HistoryIntegrated = "Hist-Integrated";
public const string Retrieval = "Retr";
public const string Storage = "Storage";
public const string Status = "Stat";
public const string Transaction = "Trx";
}
@@ -0,0 +1,118 @@
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianWcfStatusClient
{
public static Task<string?> GetSystemParameterAsync(
HistorianClientOptions options,
string parameterName,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
return Task.Run(() => GetSystemParameter(options, parameterName), cancellationToken);
}
public static Task<HistorianConnectionStatus> GetConnectionStatusAsync(
HistorianClientOptions options,
CancellationToken cancellationToken)
{
return Task.Run(() => SynthesizeConnectionStatus(options), cancellationToken);
}
public static Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(
HistorianClientOptions options,
CancellationToken cancellationToken)
{
return Task.Run(() => SynthesizeStoreForwardStatus(options), cancellationToken);
}
private static string? GetSystemParameter(HistorianClientOptions options, string parameterName)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(options);
Binding statusBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(options, HistorianWcfServiceNames.Status);
string? value = null;
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
options, histBinding, histEndpoint, contextKey, CancellationToken.None,
additionalSetup: (_, context) => value = QuerySystemParameter(statusBinding, statusEndpoint, context.ClientHandle, parameterName));
return value;
}
private static string? QuerySystemParameter(Binding statusBinding, EndpointAddress statusEndpoint, uint clientHandle, string parameterName)
{
ChannelFactory<IStatusServiceContract2> factory = new(statusBinding, statusEndpoint);
IStatusServiceContract2 channel = factory.CreateChannel();
ICommunicationObject co = (ICommunicationObject)channel;
try
{
bool ok = channel.GetSystemParameter(clientHandle, parameterName, out string parameterValue, out _, out _);
return ok ? parameterValue : null;
}
finally
{
try { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } catch { try { co.Abort(); } catch { } }
try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { try { factory.Abort(); } catch { } }
}
}
/// <remarks>
/// AVEVA's native <c>HistorianAccess.GetConnectionStatus</c> reads local C++
/// <c>HistorianClient</c> state (no WCF op exists for it). We synthesize an equivalent
/// by attempting an authenticated session open: a successful auth+open implies
/// <c>ConnectedToServer = true</c>. Store-forward and partner-connection state are not
/// observable from a single client probe and remain false.
/// </remarks>
private static HistorianConnectionStatus SynthesizeConnectionStatus(HistorianClientOptions options)
{
bool connected;
string? error = null;
try
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(options);
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
options, histBinding, histEndpoint, contextKey, CancellationToken.None);
connected = true;
}
catch (Exception ex)
{
connected = false;
error = $"{ex.GetType().Name}: {ex.Message}";
}
return new HistorianConnectionStatus(
ServerName: options.Host,
Pending: false,
ErrorOccurred: !connected,
Error: error,
ConnectedToServer: connected,
ConnectedToServerStorage: connected,
ConnectedToStoreForward: false,
ConnectionKind: HistorianConnectionKind.Process);
}
/// <remarks>
/// Native <c>HistorianAccess.GetStoreForwardStatus</c> is also client-side state.
/// Without a local store-forward sidecar to probe, we report defaults: not pending,
/// no error, no data stored, not actively storing. Connection kind is Process by
/// convention (event-only sessions are uncommon for this status helper).
/// </remarks>
private static HistorianStoreForwardStatus SynthesizeStoreForwardStatus(HistorianClientOptions options)
{
return new HistorianStoreForwardStatus(
ServerName: options.Host,
Pending: false,
ErrorOccurred: false,
Error: null,
DataStored: false,
Storing: false,
ConnectionKind: HistorianConnectionKind.Process);
}
}
@@ -0,0 +1,456 @@
using System.Net;
using System.Runtime.CompilerServices;
using System.ServiceModel;
using System.ServiceModel.Channels;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
internal static class HistorianWcfTagClient
{
public static async IAsyncEnumerable<string> BrowseTagNamesAsync(
HistorianClientOptions options,
string filter,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
IReadOnlyList<string> tagNames = await Task.Run(
() => BrowseTagNames(options, filter),
cancellationToken).ConfigureAwait(false);
foreach (string tagName in tagNames)
{
cancellationToken.ThrowIfCancellationRequested();
yield return tagName;
}
}
public static Task<HistorianTagMetadata?> GetTagMetadataAsync(
HistorianClientOptions options,
string tag,
CancellationToken cancellationToken)
{
return Task.Run(() => GetTagMetadata(options, tag), cancellationToken);
}
private static IReadOnlyList<string> BrowseTagNames(HistorianClientOptions options, string filter)
{
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
uint startReturnCode = session.RetrievalChannel.StartLikeTagNameSearch(
session.Handle,
NormalizeLikeFilter(filter),
(uint)InsqlTagType.All,
isNotLike: false);
if (startReturnCode != 0)
{
throw new InvalidOperationException($"StartLikeTagNameSearch failed with return code {startReturnCode}.");
}
List<string> tagNames = [];
bool isMore;
do
{
uint getReturnCode = session.RetrievalChannel.GetLikeTagnames(
session.Handle,
out byte[] tagNameBuffer,
out uint tagNameBufferSize,
out isMore);
if (getReturnCode != 0)
{
throw new InvalidOperationException($"GetLikeTagnames failed with return code {getReturnCode}.");
}
if (tagNameBuffer.Length != tagNameBufferSize)
{
throw new InvalidDataException("GetLikeTagnames returned a buffer size that does not match the byte array length.");
}
tagNames.AddRange(HistorianTagQueryProtocol.ParseGetLikeTagNamesResponse(tagNameBuffer));
}
while (isMore);
return tagNames;
}
private static HistorianTagMetadata? GetTagMetadata(HistorianClientOptions options, string tag)
{
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
uint returnCode = session.RetrievalChannel.GetTagInfoFromName(
session.Handle,
tag,
out _,
out byte[] tagMetadata);
if (returnCode != 0)
{
return null;
}
if (tagMetadata.Length == 0)
{
return null;
}
HistorianTagInfoResponse parsed = HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(tagMetadata);
return new HistorianTagMetadata(
Name: parsed.TagName,
Key: parsed.TagKey,
DataType: MapDataType(parsed.NativeDataTypeDescriptor),
Description: parsed.Description,
EngineeringUnit: parsed.EngineeringUnit,
MinRaw: parsed.MinEU,
MaxRaw: parsed.MaxEU);
}
/// <summary>
/// Reverse-engineering helper: returns the parsed tag-info response (including the raw
/// 4-byte native data-type descriptor) without dispatching through <see cref="MapDataType"/>.
/// Used by <c>TagMetadataDescriptorProbeTests</c> to discover descriptors for new tag
/// types so they can be added to the dispatch table.
/// </summary>
internal static HistorianTagInfoResponse GetTagInfoForDescriptorProbe(HistorianClientOptions options, string tag)
{
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
return GetTagInfoForDescriptorProbe(session, tag);
}
/// <summary>Bulk variant: probes many tags and returns the raw response bytes alongside the parsed record (for byte-layout reverse engineering).</summary>
internal static IReadOnlyDictionary<string, byte[]?> GetTagInfoRawBytesForProbe(
HistorianClientOptions options,
IEnumerable<string> tags)
{
Dictionary<string, byte[]?> results = new(StringComparer.Ordinal);
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
foreach (string tag in tags)
{
try
{
uint rc = session.RetrievalChannel.GetTagInfoFromName(session.Handle, tag, out _, out byte[] bytes);
results[tag] = (rc == 0 && bytes.Length > 0) ? bytes : null;
}
catch { results[tag] = null; }
}
return results;
}
/// <summary>Bulk variant: probes many tags through a single session.</summary>
internal static IReadOnlyDictionary<string, HistorianTagInfoResponse?> GetTagInfosForDescriptorProbe(
HistorianClientOptions options,
IEnumerable<string> tags)
{
Dictionary<string, HistorianTagInfoResponse?> results = new(StringComparer.Ordinal);
using WcfRetrievalSession session = WcfRetrievalSession.Open(options);
foreach (string tag in tags)
{
try { results[tag] = GetTagInfoForDescriptorProbe(session, tag); }
catch { results[tag] = null; }
}
return results;
}
private static HistorianTagInfoResponse GetTagInfoForDescriptorProbe(WcfRetrievalSession session, string tag)
{
uint returnCode = session.RetrievalChannel.GetTagInfoFromName(
session.Handle,
tag,
out _,
out byte[] tagMetadata);
if (returnCode != 0 || tagMetadata.Length == 0)
{
throw new InvalidOperationException($"GetTagInfoFromName({tag}) returned code {returnCode}, {tagMetadata.Length} bytes.");
}
return HistorianTagQueryProtocol.ParseGetTagInfoFromNameResponse(tagMetadata);
}
internal static string NormalizeLikeFilter(string filter)
{
return filter == "*" ? "%" : filter.Replace('*', '%');
}
/// <summary>
/// Decodes the 4-byte native data-type descriptor returned by <c>GetTagInfoFromName</c>.
/// Layout determined by probing live tags + reading the <c>CDataType</c> predicate IL
/// (<c>IsAnalog</c>, <c>IsDiscrete</c>, <c>IsString</c>, <c>IsWideString</c>,
/// <c>IsEvent</c>, <c>IsStruct</c>, <c>IsBoolean</c>, <c>IsConvertableToInt64</c>,
/// <c>IsConvertableToUInt64</c>, <c>IsConvertableToDouble</c>) in
/// <c>current/aahClientManaged.dll</c>:
/// <list type="bullet">
/// <item>byte 0 = 0x03 (descriptor format version)</item>
/// <item>byte 1 = tag-origin marker — observed 0xCF (system / built-in) and 0xC3 (user-created).</item>
/// <item>byte 2 = storage attribute byte — varies per tag (0x00 vs 0x04 observed for the same data type).</item>
/// <item><b>byte 3 = data-type code</b> (the load-bearing field; matches the native <c>CDataType</c> byte 0).</item>
/// </list>
/// Bit pattern of byte 3 (deduced from the predicate IL):
/// <list type="bullet">
/// <item>bit 0x80: extended/reserved marker — when set the type is treated specially (e.g., 0x81 = Boolean).</item>
/// <item>bit 0x40: wide-string variant (set for <see cref="HistorianDataType.DoubleByteString"/>, clear for <see cref="HistorianDataType.SingleByteString"/>).</item>
/// <item>bit 0x20: integer signed flag (UInt16=0x09 → Int16=0x29; UInt32=0x11 → Int32=0x31).</item>
/// <item>low 3 bits: type class — 1=numeric, 2=discrete/bool, 3=string, 4=event, 5=structure, 7=fixed-string.</item>
/// </list>
/// Type-code dispatch:
/// <list type="table">
/// <item><term>0x01</term><description><see cref="HistorianDataType.Float"/> — probed: SysDataAcqOverallItemsPerSec → 03 CF 00 01</description></item>
/// <item><term>0x02</term><description><see cref="HistorianDataType.Int1"/> (Discrete/Bool) — probed: SysClassicDataRedirector → 03 CF 00 02</description></item>
/// <item><term>0x03</term><description><see cref="HistorianDataType.SingleByteString"/> — IL inference (string class without bit 0x40)</description></item>
/// <item><term>0x04</term><description><see cref="HistorianDataType.Event"/> — IL inference (IsEvent low 3 bits == 4)</description></item>
/// <item><term>0x05</term><description><see cref="HistorianDataType.Structure"/> — IL inference (IsStruct low 3 bits == 5)</description></item>
/// <item><term>0x09</term><description><see cref="HistorianDataType.UInt2"/> — probed: SysCritErrCnt → 03 CF 00 09, SysTimeSec → 03 CF 04 09</description></item>
/// <item><term>0x11</term><description><see cref="HistorianDataType.UInt4"/> — probed: SysConfigStatus → 03 CF 04 11</description></item>
/// <item><term>0x21</term><description><see cref="HistorianDataType.Double"/> — IL inference (IsConvertableToDouble matches 33)</description></item>
/// <item><term>0x29</term><description><see cref="HistorianDataType.Int2"/> — IL inference (IsConvertableToInt64 matches 41 = signed UInt16 bit pattern)</description></item>
/// <item><term>0x31</term><description><see cref="HistorianDataType.Int4"/> — probed: OtOpcUaParityTest_001.Counter → 03 C3 00 31</description></item>
/// <item><term>0x43</term><description><see cref="HistorianDataType.DoubleByteString"/> — probed: SysString → 03 CF 00 43</description></item>
/// </list>
/// Extended dispatch (recovered from the same IL):
/// <list type="table">
/// <item><term>0x08</term><description><see cref="HistorianDataType.UInt1"/> — 1-byte unsigned (in IsConvertableToUInt64 list)</description></item>
/// <item><term>0x10</term><description><see cref="HistorianDataType.Guid"/> — 16-byte GUID (matches IsGuid)</description></item>
/// <item><term>0x18</term><description><see cref="HistorianDataType.FileTime"/> — Windows FILETIME (matches IsFileTime)</description></item>
/// <item><term>0x19</term><description><see cref="HistorianDataType.Int8"/> — 8-byte signed (in IsConvertableToInt64 list, follows Int16=0x29 / Int32=0x31)</description></item>
/// <item><term>0x39</term><description><see cref="HistorianDataType.UInt8"/> — 8-byte unsigned (in IsConvertableToUInt64 list, follows UInt16=0x09 / UInt32=0x11 with signed-bit set)</description></item>
/// <item><term>0x81</term><description><see cref="HistorianDataType.Int1"/> — Boolean extended form (matches IsBoolean's literal byte=129 check; same semantic as 0x02 Discrete)</description></item>
/// </list>
/// Code 0x38 also appears in <c>CDataType.IsConvertableToUInt64</c>'s allow-list but is
/// NEVER produced by any tag-creation path (verified by reading the IL of
/// <c>CDataType.InitializeAnalog</c>/<c>InitializeDiscrete</c>/<c>InitializeStruct</c>/
/// <c>InitializeString</c>, and by probing all 198 tags in a sample Runtime DB via the
/// <c>EnumerateAllTagDescriptorsAcrossOneSession</c> probe — 0x38 does not appear).
/// It is a value-side type used during data conversion / query result decoding, never a
/// tag descriptor; intentionally left unmapped so an unexpected 0x38 in a tag descriptor
/// throws <see cref="ProtocolEvidenceMissingException"/> rather than being silently
/// treated as <see cref="HistorianDataType.UInt8"/>.
/// </summary>
internal static HistorianDataType MapDataType(byte[] nativeDataTypeDescriptor)
{
// byte 1 origin marker: 0xCF = system / built-in tag, 0xC3 = MDAS-routed
// (e.g. OPC UA imported), 0xC7 = SDK-created via EnsT2 (live-verified by the
// EnsureTagAsync round-trip test).
if (nativeDataTypeDescriptor is not [0x03, 0xCF or 0xC3 or 0xC7, _, _])
{
throw new ProtocolEvidenceMissingException(
$"GetTagInfoFromName data type descriptor {Convert.ToHexString(nativeDataTypeDescriptor)}");
}
return nativeDataTypeDescriptor[3] switch
{
0x01 => HistorianDataType.Float,
0x02 => HistorianDataType.Int1,
0x03 => HistorianDataType.SingleByteString,
0x04 => HistorianDataType.Event,
0x05 => HistorianDataType.Structure,
0x08 => HistorianDataType.UInt1,
0x09 => HistorianDataType.UInt2,
0x10 => HistorianDataType.Guid,
0x11 => HistorianDataType.UInt4,
0x18 => HistorianDataType.FileTime,
0x19 => HistorianDataType.Int8,
0x21 => HistorianDataType.Double,
0x29 => HistorianDataType.Int2,
0x31 => HistorianDataType.Int4,
0x39 => HistorianDataType.UInt8,
0x43 => HistorianDataType.DoubleByteString,
0x81 => HistorianDataType.Int1,
_ => throw new ProtocolEvidenceMissingException(
$"GetTagInfoFromName data type descriptor {Convert.ToHexString(nativeDataTypeDescriptor)}")
};
}
private sealed class WcfRetrievalSession : IDisposable
{
private readonly ChannelFactory<IHistoryServiceContract2> _historyFactory;
private readonly IHistoryServiceContract2 _historyChannel;
private readonly ChannelFactory<IRetrievalServiceContract2> _retrievalFactory;
private WcfRetrievalSession(
ChannelFactory<IHistoryServiceContract2> historyFactory,
IHistoryServiceContract2 historyChannel,
ChannelFactory<IRetrievalServiceContract2> retrievalFactory,
IRetrievalServiceContract2 retrievalChannel,
uint handle)
{
_historyFactory = historyFactory;
_historyChannel = historyChannel;
_retrievalFactory = retrievalFactory;
RetrievalChannel = retrievalChannel;
Handle = handle;
}
public IRetrievalServiceContract2 RetrievalChannel { get; }
public uint Handle { get; }
public static WcfRetrievalSession Open(HistorianClientOptions options)
{
ValidateSupportedAuth(options);
// The browse/metadata code uses the legacy Open2-V1 buffer, which carries
// its own auth blob. That buffer is only valid against the WCF transport that
// negotiates Windows security at the channel level (`/Hist-Integrated`) or
// against the cert binding (which trusts the channel-level cert identity).
// For LocalPipe and RemoteTcpIntegrated the original behaviour stays —
// hit the Integrated endpoint with the Windows transport binding. Only
// RemoteTcpCertificate gets the cert binding here, so browse/metadata
// works from a Linux client over the cert transport.
(Binding historyBinding, EndpointAddress historyEndpoint) = options.Transport switch
{
HistorianTransport.RemoteTcpCertificate => (
HistorianWcfBindingFactory.CreateMdasNetTcpCertificateBinding(options.RequestTimeout),
HistorianWcfBindingFactory.CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.HistoryCertificate, options.ServerDnsIdentity)),
_ => (
HistorianWcfBindingFactory.CreateMdasNetTcpWindowsBinding(options.RequestTimeout),
HistorianWcfBindingFactory.CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.HistoryIntegrated)),
};
ChannelFactory<IHistoryServiceContract2>? historyFactory = null;
IHistoryServiceContract2? historyChannel = null;
ChannelFactory<IRetrievalServiceContract2>? retrievalFactory = null;
IRetrievalServiceContract2? retrievalChannel = null;
try
{
historyFactory = new ChannelFactory<IHistoryServiceContract2>(historyBinding, historyEndpoint);
HistorianWcfClientCredentialsHelper.Configure(historyFactory, options);
if (options.Transport != HistorianTransport.RemoteTcpCertificate)
{
// Windows transport-security only applies to the integrated-auth binding.
historyFactory.Credentials.Windows.AllowedImpersonationLevel = System.Security.Principal.TokenImpersonationLevel.Impersonation;
ApplyWindowsCredential(historyFactory, options);
}
historyFactory.Open();
historyChannel = historyFactory.CreateChannel();
((IClientChannel)historyChannel).Open();
byte[] openBuffer = BuildOpen2Buffer(options);
bool openSuccess = historyChannel.OpenConnection2(ref openBuffer, out byte[] openOut, out byte[] openError);
HistorianLegacyOpen2Output? openOutput = HistorianOpen2Protocol.TryReadLegacyOpen2Output(openOut);
if (!openSuccess || openOutput is null)
{
HistorianNativeError? nativeError = HistorianOpen2Protocol.TryReadNativeError(openError);
string code = nativeError is null ? "unknown" : nativeError.Code.ToString(System.Globalization.CultureInfo.InvariantCulture);
throw new InvalidOperationException($"OpenConnection2 failed for tag browse; native error code {code}.");
}
retrievalFactory = new ChannelFactory<IRetrievalServiceContract2>(
HistorianWcfBindingFactory.CreateMdasNetTcpBinding(options.RequestTimeout),
HistorianWcfBindingFactory.CreateEndpointAddress(options.Host, options.Port, HistorianWcfServiceNames.Retrieval));
HistorianWcfClientCredentialsHelper.Configure(retrievalFactory, options);
retrievalFactory.Open();
retrievalChannel = retrievalFactory.CreateChannel();
((IClientChannel)retrievalChannel).Open();
return new WcfRetrievalSession(
historyFactory,
historyChannel,
retrievalFactory,
retrievalChannel,
openOutput.Handle);
}
catch
{
AbortOrClose(retrievalChannel);
AbortOrClose(retrievalFactory);
AbortOrClose(historyChannel);
AbortOrClose(historyFactory);
throw;
}
}
public void Dispose()
{
try
{
_historyChannel.CloseConnection(Handle);
}
catch
{
// Close best-effort; channel cleanup below still runs.
}
AbortOrClose(RetrievalChannel);
AbortOrClose(_retrievalFactory);
AbortOrClose(_historyChannel);
AbortOrClose(_historyFactory);
}
private static void ValidateSupportedAuth(HistorianClientOptions options)
{
// Three valid auth shapes:
// 1. IntegratedSecurity=true (current Windows identity, no UserName/Password)
// 2. IntegratedSecurity=false + UserName + Password (NTLM/Kerberos with explicit creds)
// 3. IntegratedSecurity=true + UserName + Password (impersonation/explicit override)
// The fourth combination — IntegratedSecurity=false with no UserName/Password — has
// no way to authenticate against the /Hist-Integrated endpoint and is rejected.
if (!options.IntegratedSecurity
&& string.IsNullOrEmpty(options.UserName)
&& string.IsNullOrEmpty(options.Password))
{
throw new ProtocolEvidenceMissingException(
"Tag browse / metadata requires either IntegratedSecurity=true OR an explicit UserName + Password.");
}
}
private static void ApplyWindowsCredential(ChannelFactory<IHistoryServiceContract2> factory, HistorianClientOptions options)
{
if (string.IsNullOrWhiteSpace(options.UserName))
{
return;
}
NetworkCredential credential = new();
int slash = options.UserName.IndexOf('\\');
if (slash > 0 && slash < options.UserName.Length - 1)
{
credential.Domain = options.UserName[..slash];
credential.UserName = options.UserName[(slash + 1)..];
}
else
{
credential.UserName = options.UserName;
}
credential.Password = options.Password;
factory.Credentials.Windows.ClientCredential = credential;
}
private static byte[] BuildOpen2Buffer(HistorianClientOptions options)
{
string processName = Path.GetFileNameWithoutExtension(Environment.ProcessPath) ?? "ZB.MOM.WW.SPHistorianClient";
HistorianOpen2Request request = new(
options.Host,
processName,
(uint)Environment.ProcessId,
string.Empty,
[],
4,
11,
1026,
HistorianMetadataNamespace.Empty);
return HistorianOpen2Protocol.SerializeLegacyVersion1(request);
}
private static void AbortOrClose(object? communicationObject)
{
if (communicationObject is not ICommunicationObject channel)
{
return;
}
try
{
if (channel.State == CommunicationState.Faulted)
{
channel.Abort();
}
else
{
channel.Close();
}
}
catch
{
channel.Abort();
}
}
}
}
@@ -0,0 +1,255 @@
using System.Buffers.Binary;
using System.Runtime.Versioning;
using System.ServiceModel;
using System.ServiceModel.Channels;
using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf.Contracts;
namespace ZB.MOM.WW.SPHistorianClient.Wcf;
/// <remarks>
/// Drives the EnsT2 (EnsureTags2) and DelT (DeleteTags) WCF operations end-to-end.
/// Mirrors <see cref="HistorianWcfReadOrchestrator"/> for the reads flow — opens an
/// authenticated session, runs the documented priming chain (UpdC3 + 7×
/// Stat.GetSystemParameter + Trx/Stat/Retr GetV) and then issues the write op.
///
/// AddS2 is intentionally NOT here — it is blocked architecturally per
/// <c>docs/plans/write-commands-reverse-engineering.md</c> Phase 2 findings.
/// </remarks>
internal sealed class HistorianWcfTagWriteOrchestrator
{
private readonly HistorianClientOptions _options;
public HistorianWcfTagWriteOrchestrator(HistorianClientOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(definition);
ArgumentException.ThrowIfNullOrWhiteSpace(definition.TagName, nameof(definition));
// GetAnalogDataTypeCode throws ProtocolEvidenceMissingException for unsupported
// types (String, Int1/Int8/UInt8, Guid, Event, Structure) — surface that early.
_ = HistorianTagWriteProtocol.GetAnalogDataTypeCode(definition.DataType);
return Task.Run(() => EnsureTag(definition), cancellationToken);
}
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
return Task.Run(() => DeleteTag(tagName), cancellationToken);
}
private bool EnsureTag(HistorianTagDefinition definition)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
EndpointAddress retrievalEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval);
if (_options.Transport != HistorianTransport.LocalPipe)
{
retrievalEndpoint = HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
}
bool result = false;
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
additionalSetup: (historyChannel, context) => result = SendEnsureTags2(
historyChannel, context, definition, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint));
return result;
}
private bool DeleteTag(string tagName)
{
Guid contextKey = Guid.NewGuid();
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
EndpointAddress retrievalEndpoint = _options.Transport == HistorianTransport.LocalPipe
? HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval)
: HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
bool result = false;
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
additionalSetup: (historyChannel, context) =>
{
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
result = SendDeleteTags(historyChannel, context, tagName);
});
return result;
}
private static bool SendEnsureTags2(
IHistoryServiceContract2 historyChannel,
HistorianWcfAuthChainHelper.OpenConnectionContext context,
HistorianTagDefinition definition,
Binding auxBinding,
EndpointAddress statusEndpoint,
EndpointAddress transactionEndpoint,
EndpointAddress retrievalEndpoint)
{
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
byte[] payload = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: definition.TagName,
description: definition.Description,
engineeringUnit: definition.EngineeringUnit,
dateCreatedUtc: DateTime.UtcNow,
dataType: definition.DataType,
minEU: definition.MinEU,
maxEU: definition.MaxEU,
minRaw: definition.MinRaw,
maxRaw: definition.MaxRaw,
storageRateMs: definition.StorageRateMs,
applyScaling: definition.ApplyScaling,
storageType: definition.StorageType,
integralDivisor: definition.IntegralDivisor);
bool ok = historyChannel.EnsureTags2(
handle: handle,
elementCount: 1,
inputBuffer: payload,
outputBuffer: out byte[] outBuf,
errorBuffer: out byte[] errBuf);
WriteDiag("EnsT2", $"Returned={ok} OutLen={outBuf?.Length ?? -1} OutHex={(outBuf is null ? "<null>" : Convert.ToHexString(outBuf))} ErrLen={errBuf?.Length ?? -1} ErrHex={(errBuf is null ? "<null>" : Convert.ToHexString(errBuf))}");
return ok;
}
/// <summary>
/// Runs the priming chain captured between Open2 and the actual write op (EnsT2 / DelT).
/// Both paths share the same priming per the native flow capture:
/// Stat.GetV ×2 → Stat.GETHI(HistorianVersion) ×2 → UpdC3 → 6 GetSystemParameter →
/// GetSystemParameter("AllowRenameTags") → Trx.GetV → Stat.GetV → Retr.GetV.
/// </summary>
private static void RunWritePriming(
IHistoryServiceContract2 historyChannel,
HistorianWcfAuthChainHelper.OpenConnectionContext context,
Binding auxBinding,
EndpointAddress statusEndpoint,
EndpointAddress transactionEndpoint,
EndpointAddress retrievalEndpoint)
{
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
ChannelFactory<IStatusServiceContract2> statusFactory = new(auxBinding, statusEndpoint);
IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
ChannelFactory<ITransactionServiceContract> transactionFactory = new(auxBinding, transactionEndpoint);
ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
ChannelFactory<IRetrievalServiceContract4> retrievalFactory = new(auxBinding, retrievalEndpoint);
IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel();
try
{
TryRun(() => statusChannel.GetInterfaceVersion(out _));
TryRun(() => statusChannel.GetInterfaceVersion(out _));
byte[] historianVersionRequest = BuildGetHistorianInfoRequest("HistorianVersion");
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
byte[] clientStatus = BuildUpdC3ClientStatusBlob();
historyChannel.UpdateClientStatus3(handle, (uint)clientStatus.Length, ref clientStatus, out _, out _, out _, out _);
foreach (string parameterName in NativeStatusParametersBeforeAnalogEnsT2)
{
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _));
}
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, "AllowRenameTags", out _, out _, out _));
TryRun(() => transactionChannel.GetInterfaceVersion(out _));
TryRun(() => statusChannel.GetInterfaceVersion(out _));
TryRun(() => retrievalChannel.GetInterfaceVersion(out _));
}
finally
{
CloseSafely(retrievalChannel, retrievalFactory);
CloseSafely(transactionChannel, transactionFactory);
CloseSafely(statusChannel, statusFactory);
}
}
private static bool SendDeleteTags(
IHistoryServiceContract2 historyChannel,
HistorianWcfAuthChainHelper.OpenConnectionContext context,
string tagName)
{
// DelT uses the uint clientHandle, NOT the GUID handle (decoded from wire capture).
// Native DelT request encodes statusSize as MS-NBFS marker 0x81
// (ZeroTextWithEndElement = value 0) and status as xsi:nil. Earlier notes called
// 0x81 "OneText" — that was wrong; the WithEndElement-pair table is:
// 0x80/0x81 ZeroText, 0x82/0x83 OneText, 0x84/0x85 FalseText,
// 0x86/0x87 TrueText, 0x88/0x89 Int8Text.
// Sending statusSize=1 (which WCF encodes as 0x83 OneTextWithEndElement) made the
// server return DelTResult=false with err=04 84 00 00 00 (HistorianAccessError
// type 4 / code 132). statusSize=0 matches the native parity request.
byte[] tagNamesBytes = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]);
uint statusSize = 0;
byte[] status = null!;
bool ok = historyChannel.DeleteTags(
handle: context.ClientHandle,
tagNamesSize: checked((uint)tagNamesBytes.Length),
tagNames: tagNamesBytes,
statusSize: ref statusSize,
status: ref status,
errorSize: out uint errorSize,
errorBuffer: out byte[] errorBuffer);
WriteDiag("DelT", $"Returned={ok} ClientHandle={context.ClientHandle} StatusSize={statusSize} StatusLen={status?.Length ?? -1} StatusHex={(status is null ? "<null>" : Convert.ToHexString(status))} ErrorSize={errorSize} ErrorLen={errorBuffer?.Length ?? -1} ErrorHex={(errorBuffer is null ? "<null>" : Convert.ToHexString(errorBuffer))}");
return ok;
}
private static void WriteDiag(string op, string line)
{
string? diagPath = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_DELT_DIAG");
if (string.IsNullOrWhiteSpace(diagPath)) return;
try { File.AppendAllText(diagPath, $"{DateTimeOffset.UtcNow:O} {op} {line}{Environment.NewLine}"); } catch { }
}
private static readonly string[] NativeStatusParametersBeforeAnalogEnsT2 =
[
"AllowOriginals",
"HistorianPartner",
"HistorianVersion",
"MaxCyclicStorageTimeout",
"RealTimeWindow",
"FutureTimeThreshold",
];
private static void TryRun(Action a) { try { a(); } catch { } }
/// <summary>81-byte UpdC3 status blob captured from native (same as event flow).</summary>
private static byte[] BuildUpdC3ClientStatusBlob()
{
byte[] blob = new byte[81];
blob[0] = 0x02;
blob[1] = 0x01;
blob[77] = 0x1E;
return blob;
}
/// <summary>GETHI request bytes for a parameter-name query (decoded from native).</summary>
private static byte[] BuildGetHistorianInfoRequest(string parameterName)
{
byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName);
int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0;
byte[] buffer = new byte[8 + payloadLength];
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753);
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002);
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length);
Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength);
return buffer;
}
private static void CloseSafely(object channel, ICommunicationObject factory)
{
try { if (channel is ICommunicationObject co) { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } } catch { }
try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { }
}
}

Some files were not shown because too many files have changed in this diff Show More