Compare commits

..

224 Commits

Author SHA1 Message Date
Joseph Doherty 9bdb899774 fix(clients): inline Go gosec directive and strip IPv6 brackets in Python authority split 2026-06-01 07:57:22 -04:00
Joseph Doherty e5c704de69 feat(gateway): add machine FQDN to self-signed cert SANs
Best-effort resolve the host FQDN via Dns.GetHostEntry and add it as a
DNS SAN when it differs (OrdinalIgnoreCase) from the short machine name
and "localhost". SocketException / ArgumentException are caught and
silently skipped so cert generation remains robust when DNS is absent.
2026-06-01 07:52:48 -04:00
Joseph Doherty 4e520f9c0c fix(gateway): delete temp cert file on persist failure
Wrap the WriteAllBytes/Move/HardenPermissions sequence in a try/catch so
that any failure best-effort deletes the hardened .tmp file (which may
already hold PFX/private-key bytes) before rethrowing.  Add a test that
induces a persist failure by pointing SelfSignedCertPath inside a
regular file and asserts no .tmp is left on disk.
2026-06-01 07:45:15 -04:00
Joseph Doherty 2eb81379e4 docs: TLS auto-cert and lenient client trust 2026-06-01 07:43:13 -04:00
Joseph Doherty ddd5721082 fix(gateway): harden self-signed cert persistence and config validation 2026-06-01 07:37:27 -04:00
Joseph Doherty 3775f6bf3b feat(gateway): supply generated cert as Kestrel HTTPS default 2026-06-01 07:30:26 -04:00
Joseph Doherty cdfad420bb fix(client-rust): apply TLS guard to GalaxyClient and add CLI strict flag
Extract the TLS-without-CA guard into a shared `build_tls_config` helper
in options.rs so both GatewayClient and GalaxyClient use identical logic.
GalaxyClient previously had no guard, so TLS-without-CA produced a cryptic
tonic handshake failure; it now returns the same actionable InvalidEndpoint
error. The guard message notes that a server-name override affects SNI but
does not pin trust. Add --require-certificate-validation to ConnectionArgs
in the CLI binary. Add a mirror test for GalaxyClient in tests/tls.rs.
2026-06-01 07:28:16 -04:00
Joseph Doherty 330e665f6b fix(gateway): correct ECDSA key usage and dispose CertificateRequest
Drop KeyEncipherment from the self-signed cert's key-usage extension — it
is semantically wrong for ECDSA (RSA key-transport only); DigitalSignature
alone is correct for TLS 1.3 / ECDHE server certs.  CertificateRequest is
unchanged (not IDisposable in .NET 10).  Test now also asserts MachineName,
127.0.0.1 and IPv6 loopback are present in the SAN extension.
2026-06-01 07:27:15 -04:00
Joseph Doherty 5e01ad9c22 fix(client-dotnet): apply lenient TLS to GalaxyRepositoryClient and enforce hostname on CA-pin
Mirror MxGatewayClient's three-branch handler structure in GalaxyRepositoryClient
(CA-pin / lenient accept-all / OS trust) so the Galaxy endpoint works against the
gateway's self-signed cert under the default lenient posture. Expose an internal
CreateHttpHandlerForTests seam for unit testing. Add RemoteCertificateNameMismatch
rejection at the top of both CA-pinned callbacks so a pinned-CA connection truly
verifies the host. Strengthen existing lenient test to invoke the callback and assert
it returns true; add mirrored Galaxy-client handler tests.
2026-06-01 07:24:07 -04:00
Joseph Doherty 77a9108673 feat(gateway): persist/reuse self-signed cert with hardened permissions 2026-06-01 07:23:33 -04:00
Joseph Doherty 192607ab8c fix(gateway): detect Certificate:Thumbprint and cover more KestrelTlsInspector cases 2026-06-01 07:22:24 -04:00
Joseph Doherty ba82afe669 fix(client-java): keep Temurin 21 toolchain, auto-provision instead of bumping to 26 2026-06-01 07:20:04 -04:00
Joseph Doherty fe7d1ce1ec feat(gateway): validate MxGateway:Tls options 2026-06-01 07:19:22 -04:00
Joseph Doherty b8a6695612 feat(gateway): generate self-signed ECDSA cert with SANs 2026-06-01 07:18:39 -04:00
Joseph Doherty 6f9188bc8d test(client-python): update TLS default-channel test for TOFU behavior 2026-06-01 07:17:36 -04:00
Joseph Doherty a276f46f81 feat(client-java): accept gateway cert by default over TLS 2026-06-01 07:13:45 -04:00
Joseph Doherty 572b268d81 feat(client-rust): accept gateway cert by default over TLS (or documented pin-only fallback) 2026-06-01 07:11:09 -04:00
Joseph Doherty 4c093a64fa feat(client-python): accept gateway cert by default via TOFU pre-fetch 2026-06-01 07:10:55 -04:00
Joseph Doherty f47bbaea95 feat(client-dotnet): accept gateway cert by default over TLS 2026-06-01 07:08:55 -04:00
Joseph Doherty c463b49f46 feat(client-go): accept gateway cert by default over TLS 2026-06-01 07:08:47 -04:00
Joseph Doherty 87f86503ef feat(gateway): add MxGateway:Tls options block 2026-06-01 07:08:19 -04:00
Joseph Doherty e912ef960c feat(gateway): detect HTTPS endpoints missing a certificate 2026-06-01 07:08:12 -04:00
Joseph Doherty c4e7ddea70 docs: implementation plan for gateway TLS auto-cert and lenient client trust 2026-06-01 07:01:58 -04:00
Joseph Doherty 6bfa4fe884 docs: design for gateway TLS auto-cert and lenient client trust 2026-06-01 06:54:23 -04:00
Joseph Doherty b4a7bac4c0 scripts: add pack-clients.ps1 to pack/publish all 5 client packages 2026-05-28 17:12:08 -04:00
Joseph Doherty 6df373ae4c client/go: release docs and tag-go-module.ps1 helper 2026-05-28 17:07:25 -04:00
Joseph Doherty fe44e3c18a client/java: maven-publish wiring for Gitea Maven feed 2026-05-28 17:07:11 -04:00
Joseph Doherty 523f944f3e client/rust: Cargo metadata + Gitea alternative-registry config 2026-05-28 17:06:47 -04:00
Joseph Doherty c33f1e6047 client/python: PyPI metadata + Gitea feed install instructions 2026-05-28 17:06:01 -04:00
Joseph Doherty 92cc4688e6 client/go: avoid holding mutex across BrowseChildren RPC in Expand 2026-05-28 15:33:48 -04:00
Joseph Doherty a155554038 grpc: reuse GalaxyBrowseProjector.ResolveParentId from handler 2026-05-28 15:32:48 -04:00
Joseph Doherty 68f905a344 client/java: avoid holding monitor across BrowseChildren RPC in expand 2026-05-28 15:32:36 -04:00
Joseph Doherty 5abc222c72 galaxy: add by-name and by-path indexes to GalaxyHierarchyIndex 2026-05-28 15:31:56 -04:00
Joseph Doherty da3aa7b0b2 client/go: paginate DiscoverHierarchy across multi-page galaxies 2026-05-28 15:31:16 -04:00
Joseph Doherty f0ec068430 galaxy: add cycle guard to HasMatchingDescendant 2026-05-28 15:30:08 -04:00
Joseph Doherty 1a1d14a9fd client/python: add public browse_children_raw for API parity 2026-05-28 15:29:08 -04:00
Joseph Doherty b2448510ac client/java: add browseChildrenRejectsRepeatedPageToken test for parity 2026-05-28 15:17:52 -04:00
Joseph Doherty 75610e3f55 client/go: wrap browseChildren duplicate-page-token error in GatewayError 2026-05-28 15:17:10 -04:00
Joseph Doherty 5032166106 client/dotnet: assert failed expand leaves node unexpanded 2026-05-28 15:16:07 -04:00
Joseph Doherty 76a042d663 grpc: make page_token error strings RPC-name-agnostic 2026-05-28 15:15:40 -04:00
Joseph Doherty 4a19854eb9 docs: per-client High-level walker example using LazyBrowseNode
Add a "High-level walker" subsection under each client's "Browsing
lazily" section showing idiomatic use of LazyBrowseNode (browse +
expand, idempotency note, redeploy refresh pattern).
2026-05-28 14:34:19 -04:00
Joseph Doherty a4467e23ef client/python: make LazyBrowseNode.expand concurrency-safe 2026-05-28 14:32:35 -04:00
Joseph Doherty eacfeff9fb client/dotnet: make LazyBrowseNode.ExpandAsync thread-safe 2026-05-28 14:28:36 -04:00
Joseph Doherty b4bc2df015 client/java: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:29:15 -04:00
Joseph Doherty fd2a0ac4c7 client/go: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:26:41 -04:00
Joseph Doherty 555e4be51f client/rust: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:26:05 -04:00
Joseph Doherty 1d8c0d83c4 client/python: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:24:23 -04:00
Joseph Doherty 6600f2a7bd client/dotnet: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:24:17 -04:00
Joseph Doherty 803a207ad2 client/java: regenerate protos for BrowseChildren
Regen'd from galaxy_repository.proto after BrowseChildren RPC was added.
GalaxyRepositoryGrpc and GalaxyRepositoryOuterClass now include the
BrowseChildrenRequest/BrowseChildrenReply types and stub methods.
2026-05-28 14:21:56 -04:00
Joseph Doherty 97e583e96b docs: implementation plan for per-language LazyBrowseNode walker
9 tasks: Java toolchain install (Homebrew), 5 parallel per-language
walker implementations, README updates, final verification. Java
walker is gated on toolchain bootstrap success; other languages
proceed independently if Java fails.
2026-05-28 14:17:52 -04:00
Joseph Doherty eaf479349d docs: design for client-side LazyBrowseNode walker + per-language tests
Adds one high-level walker per client (.NET/Python/Rust/Go/Java) plus
six unit tests each against existing fake transports. One-shot idempotent
Expand semantics; pagination hidden inside the helper. Includes Java
toolchain bootstrap (Homebrew Temurin + Gradle) so the Java client can
build locally on the macOS dev host.
2026-05-28 14:12:03 -04:00
Joseph Doherty 83a4d41fce docs: align design doc test-plan with InvalidArgument error mapping 2026-05-28 13:30:19 -04:00
Joseph Doherty 0d6193cdc4 docs: note BrowseChildren in gateway overview and client READMEs 2026-05-28 13:25:46 -04:00
Joseph Doherty 8cd3e1c20e client/go: regenerate protos for BrowseChildren 2026-05-28 13:22:06 -04:00
Joseph Doherty 5c28458624 client/rust: regenerate protos for BrowseChildren 2026-05-28 13:19:54 -04:00
Joseph Doherty 0b389f5a97 docs: document BrowseChildren RPC and lazy browse architecture 2026-05-28 13:19:08 -04:00
Joseph Doherty 108c4bb118 client/python: regenerate protos for BrowseChildren 2026-05-28 13:18:25 -04:00
Joseph Doherty cf54a278e1 docs: record lazy-browse stays wire-only; align error mapping 2026-05-28 13:18:23 -04:00
Joseph Doherty 81b2aacfe2 client/dotnet: live smoke for BrowseChildren 2026-05-28 13:17:29 -04:00
Joseph Doherty 5932fe2fd3 dashboard: surface lazy-load errors via BrowseLoadState.Error 2026-05-28 13:15:26 -04:00
Joseph Doherty 310dfab8b4 dashboard: lazy-load BrowsePage via DashboardBrowseService 2026-05-28 13:10:10 -04:00
Joseph Doherty ba157b4b4f grpc: implement BrowseChildren handler + metadata:read scope 2026-05-28 13:08:45 -04:00
Joseph Doherty 87e22dd529 galaxy: add GalaxyBrowseProjector for direct-children projection 2026-05-28 12:58:07 -04:00
Joseph Doherty d9eaf4b056 galaxy: add ChildrenByParent index for level-at-a-time browse 2026-05-28 12:51:48 -04:00
Joseph Doherty 2c5c5e5c7e contracts: add BrowseChildren RPC for lazy hierarchy browse 2026-05-28 12:47:02 -04:00
Joseph Doherty b3ebf583ad docs: implementation plan for lazy-browse BrowseChildren RPC
12-task bite-sized plan executing the approved design.
Includes native task persistence file.
2026-05-28 12:41:11 -04:00
Joseph Doherty edb812d859 docs: design for lazy-browse BrowseChildren RPC
OPC UA-style level-at-a-time browse across gRPC, dashboard, and the
shared cache projector. Server still loads the full Galaxy hierarchy;
laziness is wire-side and UI-side only.
2026-05-28 12:34:37 -04:00
Joseph Doherty 795eee72e3 client/dotnet: backfill XML doc comments to satisfy analyzers
Adds missing <summary>/<param> docs across the .NET client library and its
test suite so CommentChecker reports zero issues. TreatWarningsAsErrors
requires the analyzer surface clean before publishing the NuGet package.
2026-05-27 14:30:53 -04:00
Joseph Doherty 615b487a77 docs+ui: backfill XML doc comments and finish dashboard layout pass
Adds missing <summary>/<param> XML docs across 99 server, worker, and test
files so CommentChecker reports zero issues (TreatWarningsAsErrors needs the
analyzer clean). Bundles in WIP dashboard work: NavSection extraction,
MainLayout/site.css/js styling alignment, and DashboardOptions/Auth tweaks.
2026-05-27 14:20:10 -04:00
Joseph Doherty 382861c602 build: add NonWindows.slnx for macOS/Linux dev hosts
The Worker + Worker.Tests projects pull in the Windows-only ArchestrA.MxAccess
COM stack and can't be built off Windows. Add a sibling .slnx that lists only
the cross-platform projects (Contracts, Server, IntegrationTests, Tests) so
non-Windows hosts can restore + build the rest of the solution with:

    dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx

The canonical solution on Windows remains ZB.MOM.WW.MxGateway.slnx.
2026-05-26 01:18:29 -04:00
Joseph Doherty ba2b936609 ui: align dashboard styling with ScadaLink master conventions
- Rename DashboardLayout.razor -> MainLayout.razor; dashboard.css -> site.css
- Sidebar 218 -> 220px; add hamburger + Bootstrap collapse for <lg viewports
- Rename .metric-* KPI classes to .agg-* (matches shared theme tokens)
- Rebuild ApiKeysPage create form as card + h6 subsections + bottom Save/Cancel
2026-05-26 01:12:54 -04:00
Joseph Doherty 7fc1955287 Dashboard: handle GET /logout (was 405) by signing out + redirecting to /login
Browsers that navigate directly to /logout via the address bar issued a GET
against a POST-only route and got 405 Method Not Allowed. Logout is
self-destructive, so the GET path can skip antiforgery; the existing POST
form (used by the layout's Sign out button) is unchanged and still
antiforgery-protected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:40:39 -04:00
Joseph Doherty 54480dde61 Add review-process + glauth design docs, bench scripts; ignore install/
Picks up the missing glauth.md referenced by CLAUDE.md, captures the
review workflow alongside the bench-read-bulk and review-readme helper
scripts, and excludes the local install/ deployment tree from source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:26:21 -04:00
Joseph Doherty 581b541801 code-reviews: regenerate after batch 2 resolutions
All 41 findings from the 42b0037 re-review are now Resolved across
8 modules. Open count = 0 for every module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:29:36 -04:00
Joseph Doherty d3cb311aae Resolve Client.Java-032..036: shared subscription base, batch tokenizer
Client.Java-032  README CLI examples for stream-alarms and
                 acknowledge-alarm now use the correct picocli flags
                 (--filter-prefix and --reference); two regression
                 tests parse each documented invocation.
Client.Java-033  StreamAlarmsCommand publishes an
                 AtomicReference<MxGatewayAlarmFeedSubscription> and
                 mirrors MxEventStream's overflow branch: a failed
                 queue.offer cancels the subscription, queues an
                 IllegalStateException, then queues the END sentinel
                 — preserving the fail-fast contract.
Client.Java-034  BatchCommand routes through a new
                 MxGatewayCli.tokenizeBatchLine POSIX-style shell
                 tokenizer that respects double-quoted, single-quoted,
                 and backslash-escaped arguments.
Client.Java-035  Added streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages
                 to MxGatewayClientSessionTests; asserts request shape,
                 message ordering, and cancellation propagation.
Client.Java-036  Extracted MxGatewayStreamSubscription<TRequest,TResponse>
                 abstract base; the four subscription classes
                 (MxGatewayEventSubscription, MxGatewayAlarmFeedSubscription,
                 MxGatewayActiveAlarmsSubscription, DeployEventSubscription)
                 collapse to ~10-line subclasses. A new contract test
                 runs identical lifecycle / cancellation assertions
                 across all four subclasses.

All resolved at 2026-05-24; gradle build + gradle test BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:29:27 -04:00
Joseph Doherty 186d03e5cc Resolve IntegrationTests-025: stopBoundary for repo-root walker
ResolveRepositoryRoot accepts an optional stopBoundary parameter that
caps the upward walk; production callers pass null and behavior is
unchanged. The two repository-marker tests now seal their walkers
inside their own temp directories, so a redirected TMP or a co-located
C:\src checkout no longer leaks ambient marker-bearing ancestors into
the assertion.

Regression test ResolveRepositoryRoot_StopBoundary_IsolatesWalkerFromAmbientAncestorMarkers
constructs an outer ancestor that carries src/ + .git, confirms the
walker leaks into it without the boundary, then asserts the same call
throws with the boundary supplied.

Resolved at 2026-05-24; IntegrationTestEnvironmentTests 5/5 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:29:09 -04:00
Joseph Doherty 6bae5ea3a3 Resolve Tests-027..031: flake root cause + coverage gaps
Tests-027  GatewayMetrics exposes its internal Meter; the
           StreamEvents_WhenEventIsWritten_RecordsSendDuration listener
           now filters by ReferenceEquals(instrument.Meter, metrics.Meter)
           instead of Meter.Name, so parallel tests with their own
           GatewayMetrics no longer cross-contaminate the families list.
Tests-028  FakeWorkerClient.Kill now captures LastKillReason;
           SessionManager.KillWorkerAsync tests pin the reason
           propagation end-to-end and cover the blank/null guard. The
           DashboardSessionAdminService kill test pins the literal
           dashboard-admin-kill reason.
Tests-029  Added CloseSessionAsync_BlankSessionId_ReturnsFailure to mirror
           the existing KillWorkerAsync blank-id coverage.
Tests-030  DeleteAsync_WhenStoreRefuses_ReportsFriendlyError renamed and
           extended to assert the dashboard-delete-key audit row with
           Details = not-found-or-active. Added
           DeleteAsync_BlankKeyId_ReturnsFailure.
Tests-031  DashboardSnapshotPublisher reconnect test now measures the
           gap from the first throw inside the fake (firstThrowAt) to
           secondSubscribeAt, isolating Task.Delay from StartAsync /
           scheduling overhead.

All resolved at 2026-05-24; 512/512 gateway tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:28:54 -04:00
Joseph Doherty 430187c28b code-reviews: regenerate after batch 1 resolutions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:39 -04:00
Joseph Doherty f5b50c4484 Resolve Client.Python-022..026: TLS-by-default, batch CLI, README
Client.Python-022  README CLI examples for stream-alarms and
                   acknowledge-alarm now use the correct flags;
                   regression test parses every documented line through
                   Click.
Client.Python-023  Re-applied Client.Python-013 — _use_plaintext drops
                   the silent localhost / 127.0.0.1 auto-downgrade
                   branch; --plaintext and --tls are mutually exclusive
                   and TLS is the default.
Client.Python-024  batch dispatch routes through main.main(...,
                   standalone_mode=False) under a redirected stdout
                   instead of click.testing.CliRunner; recursive batch
                   lines are rejected outright.
Client.Python-025  Added behavioural tests for the five bulk SDK methods,
                   stream_alarms, and the new CLI subcommands.
Client.Python-026  _bench_read_bulk hoists 'import time' to module scope
                   and logs cleanup failures instead of swallowing them.

All resolved at 2026-05-24; python -m pytest is 65/65 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:27 -04:00
Joseph Doherty 4a0f88b17d Resolve Client.Rust-022..029: MalformedReply, correlation ids, clippy
Client.Rust-022  Restored Error::MalformedReply for register / add_item /
                 add_item2 and the bulk-subscribe / read-bulk / write-bulk
                 dispatch arms so malformed-but-OK replies fail loudly
                 instead of returning Vec::new().
Client.Rust-023  Restored next_correlation_id and routed every CLI close /
                 stream-alarms / acknowledge-alarm / bench-read-bulk call
                 through it so each call carries a unique opaque token.
Client.Rust-024  Added round-trip tests for read_bulk / write_bulk /
                 write2_bulk / write_secured_bulk / write_secured2_bulk
                 plus stream_alarms and percentile_summary unit tests.
Client.Rust-025  RustClientDesign.md re-synced — new bulk SDK, alarms
                 surface, Error variants, CLI command list, and the
                 Windows stack workaround.
Client.Rust-026  Session::read_bulk now borrows a tag slice; bench-read-
                 bulk binds tags once outside the warm-up / steady-state
                 loops.
Client.Rust-027  .cargo/config.toml selector tightened to
                 cfg(all(windows, target_env = "msvc")) and comment
                 rewritten to match reality (release + debug ship the
                 8 MB reservation).
Client.Rust-028  run_batch removed the empty-line break; stdin EOF is
                 the only terminator.
Client.Rust-029  Re-applied Client.Rust-001 / 002 / 012 — added the
                 missing doc comments, renamed BulkReplyKind variants,
                 and replaced the clone-on-copy with a deref under lock
                 so cargo clippy -D warnings is clean.

All resolved at 2026-05-24; cargo fmt + check + clippy + test all green
(55 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:15 -04:00
Joseph Doherty 82996aa8e6 Resolve Client.Go-022..027: bulk flags, bench cancel, batch loop
Client.Go-022  Re-applied Client.Go-015 shape — runWriteBulkVariant drops
               the unused secured param and gates -current-user-id /
               -verifier-user-id / -user-id behind the secured-only
               variants.
Client.Go-023  Re-applied Client.Go-018 shape — bench warm-up and steady-
               state loops respect ctx.Err().
Client.Go-024  Added SDK-level tests for WriteBulk / Write2Bulk /
               WriteSecuredBulk / WriteSecured2Bulk / ReadBulk and
               StreamAlarms via the existing bufconn fake gateway pattern.
Client.Go-025  Five bulk SDK methods short-circuit on empty input without
               an RPC round-trip and document the behavior.
Client.Go-026  runBatch widens scanner.Buffer to 16 MiB and emits an
               error-with-sentinel if a longer line still arrives, rather
               than aborting the session silently.
Client.Go-027  runBatch treats blank lines as skip-and-continue; only EOF
               ends the session.

All resolved at 2026-05-24; gofmt + go vet + go build + go test ./... all
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:49:58 -04:00
Joseph Doherty 712cb06442 Resolve Client.Dotnet-018..021: README + bench-read-bulk hardening
Client.Dotnet-018  README CLI examples for stream-alarms / acknowledge-alarm
                   replaced with parser-correct flags; new theory test
                   parses each documented README example through the CLI.
Client.Dotnet-019  BenchReadBulkAsync routes through new
                   RequireRegisterServerHandle helper that fails loudly when
                   the OK register reply has no typed payload.
Client.Dotnet-020  Bench steady-state catch is now
                   catch (Exception ex) when (ex is not OperationCanceledException)
                   so user-driven cancellation exits promptly.
Client.Dotnet-021  --timeout-ms now flows through ParseTimeoutMs which
                   rejects negatives with a clear error in both read-bulk
                   and bench-read-bulk.

All resolved at 2026-05-24; 67/67 .NET client tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:49:45 -04:00
Joseph Doherty 4d77279e7e Resolve Server-044..050: KillWorker accounting + admin service hardening
Server-044  KillWorkerAsync catch path now calls _metrics.SessionRemoved
            so the open-session gauge does not leak when KillWorker throws.
Server-045  KillWorkerAsync routes through a new
            GatewaySession.KillWorkerWithCloseGateAsync that takes the
            per-session close lock, so concurrent kills count SessionsClosed
            exactly once.
Server-046  CloseSessionCoreAsync's SessionCloseStartedException branch and
            ShutdownAsync's kill fallback both increment SessionsClosed (not
            just the gauge), so the counter and gauge stay consistent.
Server-047  ApiKeysPage.ConfirmPendingAsync holds PendingAction across the
            awaited action and clears it in finally, matching the sessions
            pages.
Server-048  Closed: the 044/045 regression tests cover the previously-
            untested kill paths.
Server-049  IDashboardSessionAdminService + DashboardSessionAdminService
            now carry XML docs that pin the Admin gate, missing-session
            return-Fail semantics, and the dashboard-admin-kill reason.
Server-050  CloseSessionAsync and KillWorkerAsync catch unexpected
            exceptions after the SessionManagerException catches and return
            a friendly Fail; OperationCanceledException tied to the caller
            token still propagates.

All resolved at 2026-05-24; 503/503 gateway tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:49:34 -04:00
Joseph Doherty 6079c62709 code-reviews: regenerate index at 42b0037
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:56 -04:00
Joseph Doherty 37ef27e8ed code-reviews: bump Worker + Worker.Tests headers to 42b0037
No source changes since d692232 — header bumped to track the latest
reviewed commit, all prior findings remain closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:56 -04:00
Joseph Doherty db2218f395 code-reviews: re-review Client.Java at 42b0037
Append 5 new findings (Client.Java-032..036): README flags for new
alarm subcommands do not exist; StreamAlarmsCommand bounded queue
silently drops alarms instead of fail-fast; BatchCommand whitespace
tokenisation shreds quoted args; no library-side stream_alarms test;
MxGatewayAlarmFeedSubscription is the fourth duplicate subscription
class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:55 -04:00
Joseph Doherty bc28fee641 code-reviews: re-review Client.Python at 42b0037
Append 5 new findings (Client.Python-022..026): README flags for new
alarm subcommands do not exist; Client.Python-013 regression — the
silent localhost auto-plaintext branch is still present (the prior
Resolution did not survive the rename); production batch path uses
the click.testing.CliRunner helper; no behavioural tests for new SDK
+ CLI; bench cleanup swallows exceptions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:55 -04:00
Joseph Doherty 15fceed536 code-reviews: re-review Client.Rust at 42b0037
Append 8 new findings (Client.Rust-022..029): MalformedReply path
absent from the new bulk SDK methods, hard-coded client_correlation_id
in new CLI commands, no tests for stream_alarms / bulk SDK / bench,
RustClientDesign.md silent on new surface, run_bench_read_bulk clones
tags inside the loop, .cargo/config.toml comment is wrong, run_batch
exits on blank line, and cargo clippy fails at HEAD with three
warnings the prior reviewer punted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:41 -04:00
Joseph Doherty afa82e0989 code-reviews: re-review Client.Go at 42b0037
Append 6 new findings (Client.Go-022..027): regressions of Client.Go-015
(runWriteBulkVariant secured flag) and Client.Go-018 (bench loop
ignores ctx.Err()) reintroduced by the bulk port; no SDK tests for the
new WriteBulk/ReadBulk/StreamAlarms methods; bulk SDK accepts empty
slices; bufio.Scanner buffer + blank-line sentinel can abort the batch
session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:41 -04:00
Joseph Doherty b9ef09d26e code-reviews: re-review Client.Dotnet at 42b0037
Append 4 new findings (Client.Dotnet-018..021): README flags for the
new stream-alarms/acknowledge-alarm subcommands cite options that do
not exist on the CLI; BenchReadBulkAsync reinstates the silent
register-handle fallback and swallows OperationCanceledException;
both new --timeout-ms consumers cast int32 to uint without bounds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:40 -04:00
Joseph Doherty 7d66967122 code-reviews: re-review IntegrationTests at 42b0037
Append 1 new finding (IntegrationTests-025): the
ResolveRepositoryRoot_NoMarkers test walks up from Path.GetTempPath()
through unisolated ancestors; a redirected TMP or co-located checkout
at C:\src silently breaks the assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:26 -04:00
Joseph Doherty 2f8404d2ef code-reviews: re-review Contracts at 42b0037
No new findings — the only Contracts commit in window (bd1d1f1) is a
comment-only proto edit; field numbering remains additive with no
reuse, renumbering, or type narrowing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:25 -04:00
Joseph Doherty 2b92be02b9 code-reviews: re-review Tests at 42b0037
Append 5 new findings (Tests-027..031) covering the
StreamEvents_WhenEventIsWritten_RecordsSendDuration flake root cause
(shared MeterListener by meter name), missing kill-path coverage
(reason propagation + concurrent-kill double-count), asymmetric guard
coverage between Close and Kill, missing audit-failure-path coverage
for ApiKey Delete, and the DashboardSnapshotPublisher reconnect-window
timer sensitivity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:15 -04:00
Joseph Doherty 056f0d8808 code-reviews: re-review Server at 42b0037
Append 7 new findings (Server-044..050) covering the destructive-action
wave: KillWorkerAsync metric/state leaks, ShutdownAsync kill-fallback
gauge leak, inconsistent ConfirmDialog cleanup across pages, missing
XML docs on the new DashboardSessionAdmin surface, and unhandled
RemoveSessionAsync exception paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:28:15 -04:00
Joseph Doherty 42b0037376 Dashboard: replace inline fully-qualified type refs with @using
ApiKeysPage and GalaxyPage referenced ApiKeyConstraints and
GalaxyRepositoryOptions by full namespace inside @code. Add the
appropriate @using directive at the top of each file and let the
short type names resolve through that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:51:00 -04:00
Joseph Doherty de7639a3e9 Dashboard: fix 500 on / from duplicate endpoint mapping
GatewayApplication.MapGatewayEndpoints registered MapGet("/", ...)
that redirected to /health/live. After the dashboard added a Blazor
component at @page "/", both endpoints matched GET / and the matcher
threw AmbiguousMatchException, surfacing as a 500. The redirect
predated the dashboard home page — drop it so / lands on Overview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:48:43 -04:00
Joseph Doherty 8738735f0d clients: document StreamAlarms + AcknowledgeAlarm in each README
Each client's README now covers the alarms surface in both the SDK
section (StreamAlarms / AcknowledgeAlarm beside the existing
QueryActiveAlarms entry, with the streaming-cancellation note) and
the CLI examples (stream-alarms / acknowledge-alarm invocations
mirroring the in-tree implementations across .NET, Go, Rust, Python,
and Java).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:40:23 -04:00
Joseph Doherty e80f3c70b6 docs: cover admin dashboard actions + API key Delete
Update the design docs so they match the implemented Admin-only
dashboard surface. GatewayDashboardDesign now documents the Close
session / Kill worker controls and the new Delete action on revoked
API keys, plus the ConfirmDialog gate for every destructive action.
Sessions.md adds the SessionManager.KillWorkerAsync entry alongside
CloseSessionAsync and explains the immediate-kill semantics. Authentication.md adds the IApiKeyAdminStore.DeleteAsync write path
and the dashboard-delete-key audit event. DashboardInterfaceDesign
drops the "read-only until admin workflows have a separate design"
line in favor of the confirm-before-act invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:35:25 -04:00
Joseph Doherty 24cc5fd0f0 Dashboard: delete revoked API keys + confirm Rotate/Revoke/Delete
Add IApiKeyAdminStore.DeleteAsync that only deletes already-revoked
rows (active keys must be revoked first so the revoke event lands in
the audit log before the row disappears) and a matching admin-gated
DashboardApiKeyManagementService.DeleteAsync. ApiKeysPage now shows
Delete on revoked rows in place of the old "No actions" stub, and
Rotate/Revoke/Delete all route through ConfirmDialog so each
destructive action requires an explicit confirmation step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:30:30 -04:00
Joseph Doherty c5153d68bb Dashboard: fix API keys page stuck on "Loading"
ApiKeysPage.OnInitializedAsync overrode the base without chaining, so
DashboardPageBase's Snapshot seed and hub connect never ran and the
page rendered the null-snapshot empty state forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:22:38 -04:00
Joseph Doherty 0e56b5befb Dashboard: confirm before Close session / Kill worker
Add a shared ConfirmDialog component and route Sessions, Workers, and
SessionDetails Close/Kill buttons through it. The dialog shows the
target session id and a color-matched confirm button (yellow Close,
red Kill); Cancel dismisses without invoking the admin service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:17:32 -04:00
Joseph Doherty c5e7479ee4 Dashboard: admin-only Close session / Kill worker
Add IDashboardSessionAdminService (Admin-role gate, friendly errors,
audit logging) wrapping a new ISessionManager.KillWorkerAsync that
skips graceful shutdown and cleans up registry/metrics. Sessions,
Workers, and SessionDetails pages render Close / Kill buttons only
when CanManage; the service re-checks the role on every call so
forged clicks return Unauthenticated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:10:32 -04:00
Joseph Doherty 8a0c59d7e8 Java client: port stream-alarms and acknowledge-alarm
Adds the session-less alarm CLI subcommands to the Java CLI. stream-alarms
attaches to the gateway's central alarm feed (--filter-prefix, --limit, --json
— NDJSON, one AlarmFeedMessage per line); acknowledge-alarm is a unary ack
(--reference required, --comment, --operator). streamAlarms joins
queryActiveAlarms on MxGatewayClient and uses a new
MxGatewayAlarmFeedSubscription cancellable handle. Batch dispatch re-enters the
picocli command line per stdin line, so registering the two new subcommands
suffices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:46:03 -04:00
Joseph Doherty 828e3e6cf6 Python client: port stream-alarms and acknowledge-alarm
Adds the session-less alarm CLI subcommands to mxgw-py. stream-alarms reads a
bounded slice of the gateway's central alarm feed (--filter-prefix,
--max-messages, --timeout, --json; aggregate `{messages: [...]}`);
acknowledge-alarm is a unary ack (--reference required, --comment, --operator).
GatewayClient.stream_alarms joins query_active_alarms via a
_canceling_alarm_feed_iterator helper mirroring the existing
_canceling_active_alarms_iterator pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:54 -04:00
Joseph Doherty 7de4efeb02 Rust client: port stream-alarms and acknowledge-alarm + fix stream-events family + 8MB Windows stack
Adds the session-less alarm CLI subcommands to mxgw. stream-alarms attaches to
the gateway's central alarm feed (--filter-prefix, --max-events, --json/--jsonl;
aggregate shape `{messageCount, messages: [...]}`); acknowledge-alarm is a unary
ack (--reference required, --comment, --operator). stream_alarms joins
query_active_alarms on GatewayClient and re-exports AlarmFeedStream.

Also extends stream-events JSON to emit a full `events` array (itemHandle, value
projected to protojson-shaped `*Value` keys, etc.) instead of just `eventCount`,
matching the other four CLIs, and renders MxEvent.family as the protobuf enum
NAME (MX_EVENT_FAMILY_ON_WRITE_COMPLETE) rather than the raw i32 so the e2e
write round-trip can recognise the OnWriteComplete echo.

Adds clients/rust/.cargo/config.toml bumping the Windows main-thread stack to
8 MB via /STACK:8388608. clap-derive's Command enum (one variant per subcommand)
overflowed the default 1 MB stack in debug builds after the new variants
landed; release builds were unaffected but the e2e matrix runs Rust via
`cargo run` (debug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:46 -04:00
Joseph Doherty 6f0d142639 Go client: port stream-alarms and acknowledge-alarm
Adds the session-less alarm CLI subcommands to mxgw-go. stream-alarms attaches
to the gateway's central alarm feed (--filter-prefix, --limit, --json — NDJSON,
one AlarmFeedMessage per line); acknowledge-alarm is a unary ack (--reference
required, --comment, --operator). StreamAlarms joins QueryActiveAlarms on the
public Client and is wired through the existing batch dispatcher via runWithIO.
SDK type aliases for StreamAlarmsRequest / AlarmFeedMessage / StreamAlarmsClient
land alongside the existing alarm types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:32 -04:00
Joseph Doherty 11cc6715ed .NET client: port stream-alarms and acknowledge-alarm + fix stream-events OCE
Adds the session-less alarm CLI subcommands. stream-alarms attaches to the
gateway's central alarm feed (--filter-prefix, --max-events, --json/--jsonl);
acknowledge-alarm is a unary ack (--reference required, --comment, --operator).
StreamAlarmsAsync joins QueryActiveAlarmsAsync on MxGatewayClient and the
transport interface; the CLI client interface, adapter, and FakeGatewayTransport
follow.

Also fixes the OCE bug exposed by -VerifyWrite in the cross-language e2e:
StreamEventsAsync's await foreach now swallows OperationCanceledException when
the supplied cancellation token is the one that fired (graceful end-of-window),
and RunBatchAsync no longer excludes OCE from its outer catch — so a streaming
command that hits its --timeout reports a JSON error inside its EOR-delimited
record instead of killing the long-lived batch process.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:24 -04:00
Joseph Doherty f90bff01db Java client: port bulk read/write SDK methods + CLI subcommands
Final language in the bulk-CLI port wave. HEAD's MxGatewaySession had
only the subscribe-style bulks; this commit adds the value-bulks plus
matching picocli subcommands and a bench-read-bulk harness.

SDK (MxGatewaySession.java):
- List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries)
- List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries)
- List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries)
- List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries)
- List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, Duration timeout)

readBulk uses java.time.Duration for the timeout parameter (idiomatic
Java) and internally converts to the timeoutMs proto field;
Duration.ZERO / null both delegate to the worker default. Per-entry
secured user ids stay on each WriteSecured(2)BulkEntry to match the
proto's per-row shape.

CLI (MxGatewayCli.java):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk /
  write-secured2-bulk as picocli @Command subcommands. Write families
  share value-parsing logic; gating of --current-user-id /
  --verifier-user-id / --timestamp matches the cross-language flag
  contract.
- bench-read-bulk: --iterations / --warmup loop with avg/min/max ms
  reporting plus a --json mode that emits the cross-language bench
  JSON schema.

A small fixture in MxGatewayCliTests.FakeSession adds stub
implementations of the five new interface methods so the test module
compiles.

Verification: gradle build BUILD SUCCESSFUL (4 tasks executed, all
tests pass); gradle :zb-mom-ww-mxgateway-cli:installDist BUILD
SUCCESSFUL. Manual smoke against live gateway on localhost:5120:
open-session → register → read-bulk cold (wasCached=false both tags)
→ subscribe-bulk → read-bulk warm (wasCached=true both tags) →
write-bulk int32 111,222 (both wasSuccessful=true) → write2-bulk
timestamped (both wasSuccessful=true) → write-secured-bulk and
write-secured2-bulk return per-entry MXAccess "Value does not fall
within the expected range" failures with the configured user/verifier
ids (0,0) — confirming the SDK does NOT throw on per-entry MXAccess
failures and surfaces them through BulkWriteResult exactly as the
.NET and Go ports do → bench-read-bulk iterations=20 avg=9.5 ms
last_success=2/2 cached=2/2 → close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:50:34 -04:00
Joseph Doherty 6add4b4acc Python client: port bulk read/write SDK methods + CLI subcommands
Mirrors the .NET / Go ports of divergent branch commit f220908. HEAD's
Session class had only the subscribe-style bulks; this commit adds the
value-bulk SDK surface plus matching CLI subcommands and a
bench-read-bulk harness.

SDK (zb_mom_ww_mxgateway/session.py):
- async def write_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write2_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write_secured_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def write_secured2_bulk(server_handle, entries, *, correlation_id="")
  → list[pb.BulkWriteResult]
- async def read_bulk(server_handle, tag_addresses, *, timeout_ms=0,
  correlation_id="") → list[pb.BulkReadResult]

All five reuse the existing _ensure_bulk_size validator and route
through the existing invoke() pipeline. read_bulk additionally enforces
timeout_ms >= 0.

CLI (zb_mom_ww_mxgateway_cli/commands.py):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk /
  write-secured2-bulk registered as click @main.command(...). The
  write families share a _build_write_bulk_entries() helper that parses
  --item-handles and --values with a single --type, validates count
  match, converts via to_mx_value, and assembles the correct per-entry
  proto message.
- bench-read-bulk: opens its own session, subscribes to --bulk-size
  TestMachine_NNN.TestChangingInt tags, runs warmup then steady-state
  ReadBulk for --duration-seconds with time.perf_counter() latency
  capture, and emits the shared JSON schema (language, durationMs,
  totalCalls, successfulCalls, failedCalls, totalReadResults,
  cachedReadResults, callsPerSecond, latencyMs:{p50,p95,p99,max,mean})
  so scripts/bench-read-bulk.ps1 collates Python alongside the four
  other clients. _percentile_summary + linear-interpolation
  _percentile helper match the Go / .NET implementations.

to_mx_value is added to the existing values-module import line in
commands.py since the bulk-write commands need it.

Verification: python -m pip install -e . --quiet --no-deps; pytest
42/42 passing. Manual smoke against live gateway on localhost:5120:
open-session → register → subscribe-bulk on two
TestMachine_NNN.TestChangingInt tags (both wasSuccessful=true) →
read-bulk (both wasSuccessful=true / wasCached=true / int32 values
present) → close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:50:10 -04:00
Joseph Doherty 325106920f Rust client: port BenchReadBulk subcommand + session.rs tightening
The bulk-write/read SDK methods (read_bulk, write_bulk, write2_bulk,
write_secured_bulk, write_secured2_bulk) and the matching clap
subcommands (ReadBulk, WriteBulk, Write2Bulk, WriteSecuredBulk,
WriteSecured2Bulk) were already on HEAD from a prior session — they
were the only bulk family that HEAD shipped before the .NET / Go /
Python / Java parallel ports. The one missing piece from the divergent
branch (commit f220908) was the BenchReadBulk benchmark harness.

mxgw-cli/src/main.rs adds:
- BenchReadBulk clap variant with flags --client-name,
  --duration-seconds, --warmup-seconds, --bulk-size, --tag-start,
  --tag-prefix, --tag-attribute, --timeout-ms, --json — defaults match
  the .NET and Go benches.
- run_bench_read_bulk(): open-session → register → subscribe_bulk on
  the synthesized TestMachine_NNN.TestChangingInt tags to populate the
  worker value cache → warmup → steady-state loop with per-call
  std::time::Instant capture → unsubscribe → close-session.
- BenchStats + LatencySummary structs and a percentile()
  helper (nearest-rank with linear interpolation, matching the Go and
  .NET implementations) so the cross-language JSON output is byte-for-
  byte comparable. JSON schema: language / command / endpoint /
  clientName / bulkSize / durationSeconds / warmupSeconds / durationMs
  / tags / totalCalls / successfulCalls / failedCalls /
  totalReadResults / cachedReadResults / callsPerSecond /
  latencyMs:{p50,p95,p99,max,mean}. scripts/bench-read-bulk.ps1 will
  pick up the Rust line on its next run.

session.rs picks up minor tightening tied to the bulk SDK methods that
were already in the file (per-entry validation paths, BulkReplyKind
dispatch coverage) — no public-surface change.

Verification: cargo build --workspace clean (the 2 pre-existing
options.rs missing_docs warnings remain — out of scope); cargo test
--workspace 34/34 passing; cargo clippy --workspace --all-targets has
only the 3 pre-existing tolerated warnings (enum_variant_names on
BulkReplyKind, missing_docs on options.rs, clone_on_copy on
galaxy.rs:282). Manual smoke against live gateway on localhost:5120:
read-bulk on two TestMachine tags returned wasCached=true,
wasSuccessful=true; bench-read-bulk --duration-seconds 2
--warmup-seconds 1 --bulk-size 2 --json ran 363 calls / 181.35 calls
per second / p50=5.3 ms / p99=7.8 ms / 726 of 726 cached reads, all
emitting valid JSON in the shared bench schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:50:09 -04:00
Joseph Doherty 8aaab82287 Go client: port bulk read/write SDK methods + CLI subcommands
Mirrors the .NET addition: HEAD's session.go had only the subscribe-style
bulks (AddItemBulk / AdviseItemBulk / RemoveItemBulk / UnAdviseItemBulk /
SubscribeBulk / UnsubscribeBulk). This commit ports the value-bulk SDK
surface and CLI subcommands from divergent branch commit f220908.

SDK (clients/go/mxgateway/session.go):
- WriteBulk(ctx, serverHandle int32, entries []*WriteBulkEntry)
- Write2Bulk(ctx, ..., entries []*Write2BulkEntry)
- WriteSecuredBulk(ctx, ..., entries []*WriteSecuredBulkEntry)
- WriteSecured2Bulk(ctx, ..., entries []*WriteSecured2BulkEntry)
- ReadBulk(ctx, serverHandle int32, tagAddresses []string, timeout time.Duration)
  → []*BulkReadResult

types.go gains public re-exports of the generated proto types
(WriteBulkCommand, WriteBulkEntry, Write2BulkCommand, Write2BulkEntry,
WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecured2BulkCommand,
WriteSecured2BulkEntry, ReadBulkCommand, BulkWriteReply, BulkWriteResult,
BulkReadReply, BulkReadResult) so external callers can construct entries
through the public `mxgateway` package without dipping into the internal
generated path.

CLI (clients/go/cmd/mxgw-go/main.go):
- read-bulk, write-bulk, write2-bulk, write-secured-bulk,
  write-secured2-bulk routed through runWithIO. write families share a
  runWriteBulkVariant helper that gates per-variant flags
  (--current-user-id, --verifier-user-id, --timestamp) so the
  Client.Go-015 flag-gating contract is preserved.
- bench-read-bulk: percentile + timing helpers; JSON output schema
  identical to the .NET / Rust / Python / Java benches.

parseInt32List was changed from panic-on-error to ([]int32, error) so
the new write-bulk commands surface parse errors gracefully; the
existing runUnsubscribeBulk caller is updated accordingly.

Verification: go build ./... + go vet ./... + go test ./... all clean.
Manual smoke against live gateway on localhost:5120: open-session →
register → subscribe-bulk on 3 TestMachine_NNN.TestChangingInt tags
(all wasSuccessful=true) → read-bulk (all wasSuccessful=true /
wasCached=true) → write-bulk int32 100/200/300 (all wasSuccessful=true)
→ close-session SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:49:33 -04:00
Joseph Doherty b3ae200b11 .NET client: port bulk read/write SDK methods + CLI subcommands
Adds the value-bulk SDK surface and CLI subcommands that lived on the
divergent branch (commit f220908) but were never merged into main.
HEAD's MxGatewaySession only had the subscribe-style bulks (AddItem /
Advise / Remove / UnAdvise / Subscribe / Unsubscribe). The proto
contract already defined ReadBulkCommand / WriteBulkCommand /
Write2BulkCommand / WriteSecuredBulkCommand / WriteSecured2BulkCommand
/ BulkReadReply / BulkWriteReply, so this is purely a client-side
addition.

SDK (MxGatewaySession.cs):
- WriteBulkAsync(serverHandle, IReadOnlyList<WriteBulkEntry>, ct)
- Write2BulkAsync(serverHandle, IReadOnlyList<Write2BulkEntry>, ct)
- WriteSecuredBulkAsync(serverHandle, IReadOnlyList<WriteSecuredBulkEntry>, ct)
- WriteSecured2BulkAsync(serverHandle, IReadOnlyList<WriteSecured2BulkEntry>, ct)
- ReadBulkAsync(serverHandle, IReadOnlyList<string> tagAddresses, TimeSpan timeout, ct)

Per-entry secured user ids live on each WriteSecured(2)BulkEntry — they
are NOT lifted to ctor args because the proto field shape allows distinct
ids per row.

CLI (MxGatewayClientCli.cs):
- read-bulk / write-bulk / write2-bulk / write-secured-bulk / write-secured2-bulk
  routed through the existing dispatch table, with --type, --values,
  --item-handles, --timeout-ms, --current-user-id, --verifier-user-id,
  --timestamp flags matching the cross-language CLI surface.
- bench-read-bulk benchmark harness: warmup + steady-state ReadBulk loop
  with p50/p95/p99/max/mean latency, emitting the shared JSON schema so
  scripts/bench-read-bulk.ps1 collates the .NET line alongside the four
  other clients.

The new subcommands flow through the existing batch dispatcher without
further changes.

Verification: dotnet build clean (0 warnings / 0 errors);
dotnet test 59/59 passing. Manual smoke against the live gateway
on localhost:5120: read-bulk returned 2 BulkReadResult entries with
wasSuccessful=true, wasCached=true; write-bulk on int32 returned
wasSuccessful=true; close-session returned SESSION_STATE_CLOSED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:49:33 -04:00
Joseph Doherty 71d2c39f01 e2e: port batch subcommand to all five client CLIs
scripts/run-client-e2e-tests.ps1 expects each language CLI to expose a
`batch` subcommand that reads command lines from stdin, runs each
through the normal subcommand dispatch, writes the JSON result, then
a sentinel line `__MXGW_BATCH_EOR__`. The implementation lived on a
divergent branch (commit 6126099) that was never merged into main —
this commit ports the same protocol to HEAD's renamed CLIs so the
existing matrix script runs end-to-end.

The protocol:
  - one line of stdin = one full CLI invocation
  - successful output → stdout, then __MXGW_BATCH_EOR__
  - failure → {"error":"...","type":"error"} JSON on stdout, then
    __MXGW_BATCH_EOR__ (errors do NOT exit the loop)
  - empty line or EOF terminates the loop

Per-CLI additions:

  .NET: RunBatchAsync + per-line StringWriter capture, JSON error
    envelope when forceJsonErrors is true. Two new tests in
    MxGatewayClientCliTests covering the success and error paths.

  Go:   runBatch with bufio.Scanner, runs each line through the
    existing runWithIO switch with a buffered stdout writer. One new
    test pinning the EOR sentinel.

  Rust: new `Batch` variant on the clap Command enum, run_batch
    re-parses each line via Cli::try_parse_from. Two new tests in the
    inline mod tests block.

  Python: new `batch` click command in commands.py that uses
    CliRunner to dispatch each line; synthesises {"error",..."type"}
    JSON from click error messages when the captured output isn't
    already JSON-shaped. Three new tests in test_cli.py.

  Java: BatchCommand inner @Command with BufferedReader stdin loop,
    fresh commandLine() per dispatch with captured stdout/stderr
    PrintWriters; non-zero exit codes and uncaught exceptions both
    surface as JSON-error blocks. Two new tests.

Also fixes scripts/run-client-e2e-tests.ps1 line 705: the Python
invocation was still passing the old module name `mxgateway_cli` to
`python -m`; the client SDK rename in 397d3c5 moved it to
`zb_mom_ww_mxgateway_cli`. Without the fix the Python leg fails
with "No module named mxgateway_cli" before reaching open-session.

Verification: full matrix at the redeployed gateway (localhost:5120,
running ZB.MOM.WW.MxGateway.Server.exe / ZB.MOM.WW.MxGateway.Worker.exe)
with -SkipBulk -SkipReadWriteBulk -SkipParity -SkipAuth (those phases
exercise bulk read/write CLI subcommands that also live on the
divergent branch — porting those is a follow-up). All five clients
report `closed=true, addedItems=120, eventCount=5` and overall
`success=true`. Per-language unit tests pass:
  - dotnet: 59/59
  - go:     all packages clean
  - rust:   cargo test --workspace clean
  - python: 42/42
  - java:   gradle build SUCCESSFUL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:08:15 -04:00
Joseph Doherty a68f0cf222 code-review: regen README, all 21 open findings resolved
Closes the 2026-05-24 resolution sweep at HEAD `83eba4b`. All 21 open
findings from the d692232 re-review are now Resolved:

  Server          327e9c5  Server-031, -032, -038, -039, -040, -041, -042, -043
  Contracts       bd1d1f1  Contracts-016, -017
  Tests           d48099f  Tests-025, -026
  IntegrationTests 865c22a IntegrationTests-022, -023, -024
  Client.Java     10bd0c0  Client.Java-027, -028, -029, -030, -031
  Client.Rust     83eba4b  Client.Rust-021

Also fixes stale "Open findings" header counts in Client.Java and
IntegrationTests findings.md that survived the resolve passes
(the agents updated each finding's Status but missed the header
sum). `regen-readme.py --check` is now green.

Module status: 11 / 11 reviewed, 0 / 276 total open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:21:39 -04:00
Joseph Doherty 83eba4bec5 Resolve Client.Rust-021
Client.Rust-021 (Design adherence): RustClientDesign.md "Crate layout"
section now describes the actual flat workspace structure instead of
the aspirational nested form. The replacement text states that the
workspace root is clients/rust/, the top-level crate
zb-mom-ww-mxgateway-client is declared in clients/rust/Cargo.toml
directly, and crates/mxgw-cli/ is the sole [workspace.members] entry.
The accompanying tree lists the real files on disk (Cargo.toml,
Cargo.lock, build.rs, README.md, RustClientDesign.md,
src/{lib,client,session,galaxy,options,auth,error,value,version,generated}.rs
plus the src/generated/ tonic-build output dir, tests/, and
crates/mxgw-cli/).

Doc-only change. cargo build --workspace + cargo test --workspace clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:21:07 -04:00
Joseph Doherty 10bd0c0e4d Resolve Client.Java-027..031
Client.Java-027 (Documentation): Updated 17 Gradle task references in
clients/java/README.md (lines 37, 108-110, 160-161, 169-176, 186, 206,
221) and 3 in clients/java/JavaClientDesign.md from the retired short
subproject names to the canonical zb-mom-ww-mxgateway-client /
zb-mom-ww-mxgateway-cli names. Copy-pasting any documented command now
matches the subproject names declared in settings.gradle.

Client.Java-028 (Design adherence): Build-layout block in
JavaClientDesign.md lines 23-27 updated to show the actual package
paths com/zb/mom/ww/mxgateway/{client,cli}/ instead of the retired
com/dohertylan/mxgateway/{client,cli}/ paths.

Client.Java-029 (Documentation): README.md line 210 corrected from
"zb-mom-ww-mxgateway-cli/build/install/mxgateway-cli" to
"zb-mom-ww-mxgateway-cli/build/install/zb-mom-ww-mxgateway-cli" — Gradle
installDist produces a directory whose name matches the project name,
not the short suffix. The e2e script already used the correct path.

Client.Java-030 (Testing coverage): Added
queryActiveAlarmsForwardsRequestAndStreamsSnapshots to
MxGatewayClientSessionTests. The test pushes a QueryActiveAlarmsRequest
carrying session_id / client_correlation_id / alarm_filter_prefix
through an InProcessGateway + TestGatewayService and asserts the server
observed all three request fields, two ActiveAlarmSnapshots stream in
order, and onError is never called. TDD red→green confirmed via a
deliberately-wrong session_id assertion. The re-triage note in
Client.Java-030's resolution clarifies that the finding's reference to
"the existing acknowledgeAlarm test" was aspirational — the alarm RPC
surface had zero coverage before this commit.

Client.Java-031 (Conventions): README.md prose lines 17, 22, 26 updated
to use the canonical zb-mom-ww-mxgateway-client / zb-mom-ww-mxgateway-cli
names so the layout description matches Gradle / IDE project names.

Verification: gradle build BUILD SUCCESSFUL; all Java unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:21:06 -04:00
Joseph Doherty 865c22a884 Resolve IntegrationTests-022..024
IntegrationTests-022 (Conventions): ResolveRepositoryRoot now throws
InvalidOperationException when the walk exhausts without finding a root
marker, with a message naming the start directory, the expected markers
(src/, .git, *.sln, *.slnx), and the MXGATEWAY_LIVE_MXACCESS_WORKER_EXE
escape hatch. Replaces the silent fallback to
Directory.GetCurrentDirectory() that previously masked misconfiguration.
New regression test
ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers
in IntegrationTestEnvironmentTests asserts the throw and the message
contents. TDD red→green confirmed.

IntegrationTests-023 (Testing coverage): DashboardLdapLiveTests's
AuthenticateAsync_AdminInGwAdminGroup_Succeeds now asserts that the
authenticated principal carries a ClaimTypes.Role claim with value
DashboardRoles.Admin in addition to the existing LdapGroupClaimType
assertion. A regression in MapGroupsToRoles (returning an empty list or
missing the RDN fallback) would now surface here. Gated by
MXGATEWAY_RUN_LIVE_LDAP_TESTS.

IntegrationTests-024 (Conventions): Option (b) — extracted within
IntegrationTests. New file TestSupport/NullDashboardEventBroadcaster.cs
(public type, private ctor, singleton Instance). The inline class at
the bottom of WorkerLiveMxAccessSmokeTests is gone; the file now imports
the shared type. Matches the unit-test project's Tests-007 / Tests-021 /
Tests-025 pattern while keeping the two test projects independently
buildable (no shared test-helpers project crossing module boundaries).

Verification: dotnet build src/ZB.MOM.WW.MxGateway.IntegrationTests
clean; 19/19 integration tests passing (live MxAccess + LDAP + Galaxy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:40 -04:00
Joseph Doherty d48099f0d0 Resolve Tests-025..026
Tests-025 (Conventions): Extracted the previously-duplicated
NullDashboardEventBroadcaster into TestSupport/NullDashboardEventBroadcaster.cs
(singleton Instance, private ctor). The two nested copies in
EventStreamServiceTests and GatewayEndToEndFakeWorkerSmokeTests were
removed; both files now use the shared type via
'using ZB.MOM.WW.MxGateway.Tests.TestSupport;'. The Server-041 regression
test's ThrowingDashboardEventBroadcaster is intentionally left nested —
single-file usage doesn't warrant promotion to TestSupport. The third
copy in IntegrationTests/WorkerLiveMxAccessSmokeTests was handled by
IntegrationTests-024 in its own commit.

Tests-026 (Testing coverage): Added a new RecordingDashboardEventBroadcaster
test double in TestSupport — a thread-safe (ConcurrentQueue<DashboardEventCapture>)
recorder. New fixture
StreamEventsAsync_PublishesEachEventToDashboardBroadcaster in
EventStreamServiceTests pushes two events through the fake session and
asserts the broadcaster received both with the correct sessionId and
WorkerSequence. TDD red→green confirmed: the deliberately-wrong
"Expected 3, Actual 2" red phase proved the recording fake was actually
invoked by the production code path.

Verification: 486/486 server tests passing (485 previous + 1 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:40 -04:00
Joseph Doherty bd1d1f1c0e Resolve Contracts-016..017
Contracts-016 (Conventions): QueryActiveAlarmsRequest.session_id header
replaced with the unambiguous "Clients may leave session_id empty; the
gateway currently ignores it and serves the session-less central-monitor
cache. A future version may use it to scope the snapshot to one
session." Removes the ambiguity that the prior "reserved for future
use" wording introduced.

Contracts-017 (Documentation): The rpc QueryActiveAlarms comment now
includes the alarm_filter_prefix description: "QueryActiveAlarmsRequest.alarm_filter_prefix
optionally narrows the snapshot to alarms whose alarm_full_reference
starts with the given prefix; an empty prefix returns the full set."

Both are proto-comment-only changes — no wire-format impact, no field
renumbering, and the regenerated MxaccessGateway.cs / MxaccessGatewayGrpc.cs
carry only the doc-comment delta. Added the additive-only regression
guard QueryActiveAlarmsRequest_PinsFieldNumbersAndRoundTripsPrefixFilter
to ProtobufContractRoundTripTests — pins
session_id=1 / client_correlation_id=2 / alarm_filter_prefix=3 by
descriptor lookup and round-trips the message with and without the
filter populated.

Verification: dotnet build src/ZB.MOM.WW.MxGateway.slnx clean;
ProtobufContractRoundTripTests 40/40 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:20:13 -04:00
Joseph Doherty 327e9c5f94 Resolve Server-031..032 (re-triaged) + Server-038..043
Server-031: re-triaged. The recommended gateway-side
"skip-while-command-in-flight" guard is already in place at
WorkerClient.HeartbeatLoopAsync via WorkerClientOptions.HeartbeatStuckCeiling
(default 75s = 5× HeartbeatGrace). Two regression tests pin the
behaviour. Recommendation #1 (decouple worker-side _writeLock) is a
Worker-module concern (Worker-017 / Worker-023) and out of scope here.

Server-032: re-triaged. Recommendation #2 (rich diagnostic) is already
in EnqueueWorkerEventAsync, with #3 (overflow grace) absorbed by the
TryWrite → WriteAsync-with-timeout fall-through. Test
EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic
pins the diagnostic string. Recommendation #1 (prose contract in
gateway.md / docs) is deferred — outside this pass's edit scope.

Server-038 (Security): EventsHub.SubscribeSession's missing per-session
ACL is documented with a TODO(per-session-acl) and a <remarks> block
explaining the v1 acceptance (any dashboard role can subscribe to any
session — non-secret metadata, redacted value logging). The per-session
ACL design lands in a follow-up once a session-scoped role exists.

Server-039 (Error handling): HubTokenService.Validate now rejects a
deserialized payload where both Name and NameIdentifier are null/empty.
New test file HubTokenServiceTests.cs covers the regression and five
sanity cases. TDD confirmed.

Server-040 (Conventions): MapGroupsToRoles gains a precedence comment
explaining "full literal match first, leading-RDN fallback;
OrdinalIgnoreCase via DashboardOptions.GroupToRole". Documentation-only.

Server-041 (Design adherence): EventStreamService.ProduceEventsAsync
wraps the broadcaster.Publish call in try/catch (Exception). The
producer loop and gRPC stream are no longer at the mercy of the
broadcaster's never-throw discipline. New regression test
StreamEventsAsync_WhenDashboardBroadcasterThrows_StillYieldsEventsAndDoesNotFaultSession.

Server-042 (Performance): DashboardSnapshotPublisher.ExecuteAsync now
mirrors AlarmsHubPublisher's reconnect loop — wraps the await foreach
in a while-not-cancelled, catches general exceptions, and Task.Delays
5s before retrying. An internal ctor accepts a shorter delay for the
test. New test file DashboardSnapshotPublisherTests.cs covers the
throw-then-yield reconnect path and the normal-completion case.

Server-043 (Documentation): HubTokenService class XML doc gains a
<remarks> describing the singleton lifetime, the two consumer scopes
(DashboardHubConnectionFactory scoped, HubTokenAuthenticationHandler
transient), and the thread-safety contract.

Verification: dotnet build src/ZB.MOM.WW.MxGateway.slnx clean
(0 warnings / 0 errors); src/ZB.MOM.WW.MxGateway.Tests 486/486 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 03:18:52 -04:00
Joseph Doherty d2d2e5f68f code-review 2026-05-24: re-review at d692232 across all 11 modules
Restores the `code-reviews/` tree (was unwritten on this working copy)
and re-reviews every module per `REVIEW-PROCESS.md` against HEAD
`d692232`. The diff in scope is the five commits since the last sweep:
`dc9c0c9` (ZB.MOM.WW gateway-side rename + slnx migrate),
`397d3c5` (client SDK rename + the missing alarm-RPC proto types and
the .NET DiscoverHierarchyOptions POCO), `27ed651` (role-based LDAP
auth + HubToken bearer, drop PathBase), `6594359` (sidebar layout +
three SignalR push hubs), and `d692232` (EventsHub publisher + doc
refresh).

Module status

| Module | Open | Total | Delta this pass |
|---|---|---|---|
| Server           | 8 | 43 | +6 |
| Contracts        | 2 | 17 | +2 |
| Tests            | 2 | 26 | +2 |
| IntegrationTests | 3 | 24 | +3 |
| Client.Java      | 5 | 31 | +5 |
| Client.Rust      | 1 | 21 | +1 |
| Worker           | 0 | 25 |  0 (rename-only diff, clean) |
| Worker.Tests     | 0 | 30 |  0 (rename-only diff, clean) |
| Client.Dotnet    | 0 | 17 |  0 (rename + alarm-fix diff, clean) |
| Client.Python    | 0 | 21 |  0 (rename + alarm-fix diff, clean) |
| Client.Go        | 0 | 21 |  0 (rename + alarm-fix diff, clean) |

Total new findings: 19. Severity breakdown: 1 Medium-security
(Server-038), 4 Medium-documentation/coverage, 14 Low.

New findings

  * Server-038 (Medium / Security) — EventsHub.SubscribeSession accepts
    any session id from any Viewer; no per-session ACL guards the
    EventsHub group fan-out.
  * Server-039 (Low / Error handling) — HubTokenService.Validate
    accepts a payload with null Name/NameIdentifier.
  * Server-040 (Low / Conventions) — MapGroupsToRoles undocumented
    full-vs-RDN lookup precedence.
  * Server-041 (Low / Design adherence) — EventStreamService calls
    IDashboardEventBroadcaster.Publish without a try/catch — fragile
    seam relying on the never-throw contract.
  * Server-042 (Low / Performance) — DashboardSnapshotPublisher tight
    retry loop with no backoff (vs AlarmsHubPublisher 5s delay).
  * Server-043 (Low / Documentation) — HubTokenService singleton
    sharing across login + hub-token validation undocumented.

  * Contracts-016 (Low / Conventions) — QueryActiveAlarmsRequest.session_id
    reserved-for-future-use ambiguity.
  * Contracts-017 (Low / Documentation) — rpc QueryActiveAlarms doc
    omits the alarm_filter_prefix filter description.

  * Tests-025 (Low / Conventions) — duplicate NullDashboardEventBroadcaster
    fakes in EventStreamServiceTests and GatewayEndToEndFakeWorkerSmokeTests.
  * Tests-026 (Medium / Testing coverage) — no test proves
    EventStreamService actually calls IDashboardEventBroadcaster.Publish.

  * IntegrationTests-022 (Low / Conventions) — ResolveRepositoryRoot
    silent fallback to Directory.GetCurrentDirectory().
  * IntegrationTests-023 (Low / Testing coverage) — DashboardLdapLiveTests
    success-path asserts ldap_group but not the Role claim.
  * IntegrationTests-024 (Low / Conventions) — inline
    NullDashboardEventBroadcaster fake duplicates Tests-side copies.

  * Client.Java-027 (Medium / Documentation) — README + JavaClientDesign
    Gradle task names still use the old short project names.
  * Client.Java-028 (Medium / Design adherence) — JavaClientDesign
    build-layout shows the old `com/dohertylan/mxgateway/` package paths.
  * Client.Java-029 (Low / Documentation) — README installDist path
    cites the wrong directory.
  * Client.Java-030 (Low / Testing coverage) — no Java test exercises
    the regenerated QueryActiveAlarmsRequest RPC.
  * Client.Java-031 (Low / Conventions) — README prose uses old short
    project names instead of canonical prefixed ones.

  * Client.Rust-021 (Low / Design adherence) — RustClientDesign.md
    "Crate layout" shows an aspirational nested `crates/zb-mom-ww-mxgateway-client/`
    that does not exist; actual layout is the flat top-level crate.

Two pre-existing pending findings (Server-031 lock-contention,
Server-032 bounded event channel) remain unchanged — neither was
touched by this wave of commits.

Process notes

- The `code-reviews/` tree was not in this working copy's git
  history (the local extract pre-dates the divergent branch that
  carried the reviews). Restored from `dd7ca16` via
  `git checkout dd7ca16 -- code-reviews/` before the re-review.
- Some "Resolved" entries in the restored findings.md reference
  fixes that landed on the divergent branch (the same one that
  carried the reviews) and are not present on the current main
  lineage. The re-review treats those statuses as historical;
  the new pass only files findings against HEAD's actual state.
- `python code-reviews/regen-readme.py --check` is green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:34:30 -04:00
Joseph Doherty d692232191 dashboard: clear deferred items — EventsHub publisher + doc refresh
EventsHub publisher (closes the v2.1 follow-up flagged in the previous commit)

EventStreamService now mirrors every MxEvent it forwards to a gRPC client
into the `EventsHub` group for the session. The fan-out goes through a new
singleton `IDashboardEventBroadcaster`:

  * IDashboardEventBroadcaster — abstraction so EventStreamService doesn't
    take a direct dependency on SignalR.
  * DashboardEventBroadcaster — singleton implementation that hands the
    SendAsync to IHubContext<EventsHub> as fire-and-forget. Errors are
    logged at debug and dropped so the source gRPC stream is never
    blocked.

EventStreamService now takes IDashboardEventBroadcaster as a ctor parameter
and calls Publish(sessionId, publicEvent) once per event after sequence
filtering, before the bounded queue write. Test fixtures and the live
integration harness pass NullDashboardEventBroadcaster.Instance so the
broadcaster is a no-op in unit tests.

SessionDetailsPage adds a "Recent events" panel:
  * implements IAsyncDisposable
  * opens a second HubConnection via DashboardHubConnectionFactory targeting
    /hubs/events
  * calls SubscribeSession(SessionId) on Start
  * renders the most recent 50 events in a small table (worker seq, family,
    server/item handle, alarm reference when the event is OnAlarmTransition)
  * shows a live/offline conn-pill driven by HubConnection.Closed /
    Reconnected events

The dashboard mirror is intentionally passive — events appear only while a
gRPC client is also consuming that session's events. Documented as such in
the empty-state copy and in GatewayDashboardDesign.md.

Documentation refresh

Every doc that referenced the retired options (PathBase, RequireAdminScope,
RequiredGroup) and the old API-key-cookie auth flow is updated to describe
the new model:

  * CLAUDE.md — Authentication section now explains LDAP bind +
    GroupToRole + HubToken bearer flow.
  * gateway.md — Dashboard section: root-mounted routes, snapshot/alarms/
    events SignalR hubs, LDAP cookie + bearer scheme.
  * docs/GatewayConfiguration.md — drop PathBase / RequireAdminScope rows,
    add GroupToRole row, append "Authorization policies" and "SignalR hubs"
    subsections describing the three policies and the /hubs/* endpoints.
  * docs/GatewayDashboardDesign.md — hosting model (root mount, new
    endpoint layout), Realtime Updates rewritten as a hub table
    (DashboardSnapshotHub / AlarmsHub / EventsHub with producers, payloads,
    and routing), Authentication And Authorization rewritten around LDAP +
    role mapping + the hub bearer flow, Configuration block updated.
  * docs/GatewayProcessDesign.md — security-section dashboard paragraph
    and the example config block both refreshed to LDAP/role auth.
  * docs/ImplementationPlanGateway.md — dashboard-auth deliverable list
    updated (LDAP bind + GroupToRole + /hubs/token bearer mint replace the
    API-key login flow).
  * docs/GatewayTesting.md — DashboardLdapLiveTests blurb describes the
    GroupToRole fixture (`{ GwAdmin: Admin }`) instead of the retired
    RequiredGroup default; success-path assertion explains the role-claim
    check.

Verification: 475 server tests, 275 worker tests (+ 9 dev-rig skips), 18
integration tests (live MxAccess + LDAP + Galaxy) all pass — including the
live worker smoke test fixture that now constructs EventStreamService with
the new broadcaster parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:07:30 -04:00
Joseph Doherty 65943597d4 dashboard: side-rail layout + SignalR push hubs (snapshot, alarms, events)
Layout
------
DashboardLayout.razor replaces the inline header nav with a left side rail
modelled on the OtOpcUa admin (Dashboard B). The top bar keeps only the
brand, breadcrumb, and signed-in status pill; navigation moves into a
fixed-width 218px rail with grouped section eyebrows (Overview,
Runtime, Galaxy, Admin) and a Session footer carrying the user name,
role claims, and a Sign-out button. dashboard.css gains the
`.app-shell` flex container, `.side-rail` column, `.rail-eyebrow`,
`.rail-link[.active]`, `.rail-foot`, `.rail-user`, `.rail-roles`, and
`.rail-btn` rules (all driven by the existing theme.css tokens, no new
hard-coded colours).

SignalR (push)
--------------
Adds three hubs under `Dashboard/Hubs/`, all gated by the
`HubClientsPolicy` registered in the previous commit:

  * DashboardSnapshotHub (/hubs/snapshot)
    Broadcasts the full DashboardSnapshot on every change. Sends the
    current snapshot to a new caller in OnConnectedAsync so the first
    paint is immediate.

  * AlarmsHub (/hubs/alarms)
    Connected clients auto-join the `__alarms__` group. Receives
    AlarmFeedMessage values (active_alarm / snapshot_complete /
    transition) re-broadcast from the gateway's central alarm monitor.

  * EventsHub (/hubs/events)
    Per-session push surface. Clients call SubscribeSession(sessionId)
    to join `session:{id}`. The publisher side is intentionally a
    follow-up — the snapshot hub already carries recent-events
    rollups; a dedicated MxEvent broadcaster on EventStreamService
    will plug into this hub's group convention.

Two BackgroundService publishers wire server-side data sources to the
hubs:

  * DashboardSnapshotPublisher subscribes to
    `IDashboardSnapshotService.WatchSnapshotsAsync` and forwards every
    snapshot to all connected hub clients.
  * AlarmsHubPublisher subscribes to `IGatewayAlarmService.StreamAsync`
    (no filter) and forwards every AlarmFeedMessage to the
    `__alarms__` group, reconnecting with a 5-second backoff if the
    stream faults.

Connection + auth plumbing
--------------------------
  * `GET /hubs/token` issues a fresh data-protected bearer token
    bound to the calling user's identity and roles. Gated by the
    cookie-only ViewerPolicy so a Blazor circuit (cookie-authenticated)
    can mint a token, but a hub bearer cannot self-bootstrap a new
    one.
  * DashboardHubConnectionFactory (scoped) is the client-side helper
    Razor pages inject. It builds a HubConnection with an
    AccessTokenProvider that calls HubTokenService.Issue on every
    (re)connect — keeps the connection alive across cookie refresh
    boundaries.

Pull → push refactor
--------------------
DashboardPageBase no longer drives its own `WatchSnapshotsAsync`
async-foreach loop. It now:
  1. seeds Snapshot synchronously from `IDashboardSnapshotService.GetSnapshot()`
     so the first render is non-empty;
  2. opens a `DashboardSnapshotHub` connection via the connection
     factory;
  3. updates Snapshot + triggers StateHasChanged on each
     `SnapshotUpdated` push.

The hub connection is best-effort: if SignalR can't start, the
synchronous snapshot seed keeps the UI populated. SignalR's
WithAutomaticReconnect handles the recovery path.

Package
-------
Adds `Microsoft.AspNetCore.SignalR.Client` 10.0.0 to the server csproj
so the in-process Blazor pages can open hub connections back to their
own hosting process.

Verification: 475 server tests (+ 2 new
`DashboardHubsRegistrationTests` that pin the hub negotiate endpoints
and the singleton/scoped DI shape), 275 worker tests (+ 9 dev-rig
skips), 18 integration tests (live MxAccess + LDAP + Galaxy) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:48:27 -04:00
Joseph Doherty 27ed65114e dashboard: role-based LDAP auth + hub bearer scheme, drop PathBase
Restructure dashboard auth around LDAP-driven Admin/Viewer roles, add a
bearer scheme so SignalR hubs (next commit) can authenticate without
forwarding the HttpOnly browser cookie, and mount the dashboard at the
host root instead of a configurable `/dashboard` prefix.

Configuration changes (breaking):
- `MxGateway:Dashboard:PathBase` removed — the dashboard now serves at `/`.
- `MxGateway:Dashboard:RequireAdminScope` removed — role checks replace
  the single admin-scope claim.
- `MxGateway:Ldap:RequiredGroup` removed — replaced by `MxGateway:Dashboard:GroupToRole`,
  a map from LDAP group name to dashboard role. Legal role values:
  `Admin` and `Viewer`. Users whose LDAP groups don't intersect this
  map are rejected at login (the existing fail-closed contract).
- appsettings.json ships a default mapping `{ GwAdmin: Admin, GwReader: Viewer }`.

Auth model:
- DashboardRoles: new static class with `Admin` and `Viewer` constants.
- DashboardAuthenticator.AuthenticateAsync: after LDAP bind, maps the
  user's groups through `DashboardOptions.GroupToRole` and emits one
  `ClaimTypes.Role` claim per resolved role. Empty result → login fails.
- DashboardAuthorizationRequirement now carries `RequiredRoles`; static
  presets `AnyDashboardRole` (Viewer ∨ Admin) and `AdminOnly`.
- DashboardAuthorizationHandler checks `IsInRole` against the
  requirement's role list instead of the old scope claim. The
  `AuthenticationMode.Disabled` and `AllowAnonymousLocalhost` bypasses
  are preserved.
- DashboardApiKeyAuthorization.CanManage now requires the `Admin` role
  (was: required LDAP group membership). The constructor's IOptions
  parameter is gone.

Policies / schemes:
- DashboardAuthenticationDefaults gains `ViewerPolicy`, `AdminPolicy`,
  `HubClientsPolicy`, and `HubAuthenticationScheme`. The legacy
  `AuthorizationPolicy` and `ScopeClaimType` constants are removed.
- DashboardServiceCollectionExtensions registers all three policies,
  adds the cookie scheme and the HubToken bearer scheme side by side,
  calls `AddSignalR()`, and hard-codes the cookie's login/logout/denied
  paths to root-relative `/login` etc.

Hub bearer infrastructure (no hubs wired yet — next commit):
- HubTokenService: mints time-limited data-protected JSON tokens
  carrying the user's name, NameIdentifier, and roles. 30-minute
  lifetime, purpose `ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`.
- HubTokenAuthenticationHandler: validates the token from
  `Authorization: Bearer …` or `?access_token=…` (WebSocket upgrade
  query string) and rebuilds the principal.

Endpoint mapping:
- DashboardEndpointRouteBuilderExtensions drops the `MapGroup(pathBase)`
  wrapper. Login/logout/denied and Razor component routes are now
  mounted at `/`. The login form posts to `/login`. Razor components
  require the new `ViewerPolicy`.
- All page `@page "/dashboard/X"` dual-route directives are removed —
  pages live at their canonical roots (`@page "/"`, `@page "/sessions"`, …).
- App.razor and DashboardLayout.razor drop their PathBase computations.

EffectiveLdapConfiguration drops `RequiredGroup`; EffectiveDashboardConfiguration
drops `PathBase`/`RequireAdminScope` and gains `GroupToRole`. SettingsPage
renders the role mapping in place of the retired fields.

Tests updated:
- DashboardAuthenticatorTests: covers the new GroupToRole mapping
  (short name + DN + multi-role).
- DashboardAuthorizationHandlerTests: split into Viewer-policy and
  Admin-policy cases.
- DashboardApiKeyAuthorizationTests, DashboardApiKeyManagementServiceTests:
  authorized principal now carries the `Admin` role claim.
- DashboardCookieOptionsTests: expects root-relative login/logout paths.
- GatewayApplicationTests: dashboard component routes registered at `/`,
  `/sessions`, … and gated by `ViewerPolicy`. Filter on
  `ComponentTypeMetadata` to ignore minimal-API endpoints sharing `/`.
- GatewayOptionsTests + Validator: drop PathBase / RequireAdminScope /
  RequiredGroup assertions; add a `GroupToRole` value-validation case.
- DashboardLdapLiveTests: provides the default `GwAdmin` → `Admin`
  mapping so the live LDAP bind resolves to a role.

Verification: 473 server tests, 275 worker tests (+9 dev-rig skips), 18
integration tests (live MxAccess + LDAP + Galaxy) all pass.

This commit is intentionally UI-neutral. The sidebar layout and the
SignalR hubs that consume the new HubToken scheme land in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:38:33 -04:00
Joseph Doherty 397d3c5c4f rename: apply ZB.MOM.WW prefix to all client SDKs + fix pre-existing alarm-RPC breaks
Rename across every client surface using each language's idiomatic convention:

  * .NET   clients/dotnet/MxGateway.Client[.Cli|.Tests]/
             -> clients/dotnet/ZB.MOM.WW.MxGateway.Client[.Cli|.Tests]/
             namespaces -> ZB.MOM.WW.MxGateway.Client[.Cli|.Tests]
             contracts ProjectReference repointed to ZB.MOM.WW.MxGateway.Contracts
             sln migrated to slnx (dotnet sln migrate)
  * Python src/mxgateway -> src/zb_mom_ww_mxgateway
             src/mxgateway_cli -> src/zb_mom_ww_mxgateway_cli
             distribution: mxaccess-gateway-client -> zb-mom-ww-mxaccess-gateway-client
  * Rust   crate: mxgateway-client -> zb-mom-ww-mxgateway-client
             build.rs proto path repointed
  * Java   subprojects: mxgateway-{client,cli} -> zb-mom-ww-mxgateway-{client,cli}
             packages com.dohertylan.mxgateway -> com.zb.mom.ww.mxgateway
             group   com.dohertylan.mxgateway -> com.zb.mom.ww.mxgateway
             rootProject mxaccessgw-java -> zb-mom-ww-mxaccessgw-java
  * Go     generate-proto.ps1 proto path repointed; module path and
             package mxgateway kept (Go convention).
  * proto-inputs.json: generatedOutputs.python updated to new package path.
  * scripts/run-client-e2e-tests.ps1: Java CLI install path + gradle task
             updated to zb-mom-ww-mxgateway-cli.

CLI binary names (mxgw, mxgw-py, mxgw-go, mxgateway-cli) and wire-level
identifiers (MXGATEWAY_* env vars, the mxgw_<id>_<secret> API key
prefix, protobuf package names like mxaccess_gateway.v1, all MXAccess
references) intentionally NOT renamed.

Fix pre-existing alarms-over-gateway breaks unblocked by the rename:

  * mxaccess_gateway.proto: add missing public message QueryActiveAlarmsRequest
    {session_id, client_correlation_id, alarm_filter_prefix} and missing
    rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns
    (stream ActiveAlarmSnapshot). All four typed clients referenced
    these but they were absent from the proto.
  * MxAccessGatewayService.QueryActiveAlarms: implement the new RPC on
    the server, streaming from IGatewayAlarmService.CurrentAlarms with
    optional alarm_filter_prefix filter.
  * clients/dotnet/.../DiscoverHierarchyOptions.cs: add the hand-written
    .NET POCO that wraps DiscoverHierarchyRequest (referenced by
    GalaxyRepositoryClient.DiscoverHierarchyAsync but never authored).
  * Drop retired session_id field references from
    AcknowledgeAlarmRequest/AcknowledgeAlarmReply test fixtures across
    .NET, Rust, Go, and Python clients.
  * Rust integration test: add the missing stream_alarms impl on the
    fake MxAccessGateway server (the trait gained the method, fake
    didn't).
  * Rust CLI test: bump expected gatewayProtocolVersion 2 -> 3.

Regenerated artifacts updated in this commit:
  * src/ZB.MOM.WW.MxGateway.Contracts/Generated/{MxaccessGateway,MxaccessGatewayGrpc}.cs
  * clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2{,_grpc}.py
  * clients/go/internal/generated/*.pb.go
(C# regenerated by Grpc.Tools on contracts build; Python and Go via
their generate-proto.ps1 scripts; Rust regenerates from .proto via
tonic-build at compile time so no checked-in artefact.)

Verification: 472 server tests, 275 worker tests (9 dev-rig skipped),
18 integration tests (live MxAccess + LDAP + Galaxy), 57 .NET client
tests, 32 Rust workspace tests, 39 Python tests, all Go packages, and
gradle build for Java all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:09:34 -04:00
Joseph Doherty dc9c0c950c rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.

External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
  MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths

Also fixes two tests that were not rename-related but became visible
while validating the rename:

- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
  gateway service correctly maps to RpcException(Cancelled) per gRPC
  convention was being misclassified as a stream fault. Added a sibling
  catch on RpcException with StatusCode.Cancelled.

- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
  and made it accept either a .git marker OR a .sln/.slnx next to src/
  so the worker-exe walker works in non-git working copies.

clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.

Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
  Tests: 472/472 pass
  Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
  IntegrationTests: 18/18 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:22:23 -04:00
dohertj2 867bf18116 alarms-over-gateway: full pipeline (#118)
Seven slices on this branch implement the full alarms-over-gateway path:

1. f711a55  A.2: WnWrapAlarmConsumer replaces aaAlarmManagedClient (wnwrapConsumer.dll, XML payload bypasses FILETIME crash)
2. 82eb0ad  A.3 in-process: AlarmDispatcher wires consumer events onto worker MxAccessEventQueue
3. 01f5e6a  A.3 worker IPC: SubscribeAlarms / UnsubscribeAlarms / AcknowledgeAlarm / QueryActiveAlarms commands + executor switch arms
4. 9b21ca3  A.3 gateway: WorkerAlarmRpcDispatcher routes RPCs through the IPC; replaces NotWiredAlarmRpcDispatcher in DI
5. 47b1fd4  A.3 auto-subscribe: SessionManager issues SubscribeAlarms on session open (gated by Alarms.Enabled config)
6. 4e02927  A.3 alarm-ack-by-name: public AcknowledgeAlarm now accepts Provider!Group.Tag references via AlarmAckByName
7. a4ed605  A.3 live smoke: end-to-end pipeline verified on dev rig; surfaced + fixed three production-relevant AVEVA quirks (SetXmlAlarmQuery required for reads, breaks acks; v2 8-arg AlarmAckByName is a stub; AlarmAckByGUID is a stub)

Known follow-ups not in scope:
 - WnWrapAlarmConsumer.PollOnce needs to be driven from the worker StaRuntime (production hosting); currently the timer-based path deadlocks on cross-apartment marshaling without an STA pump.
 - Pre-existing structure-test failure (test project ArchestrA.MxAccess ref) untouched.

Test counts at merge time:
  Worker: 195 pass / 4 skipped (live probes incl. AlarmsLiveSmokeTests) / 1 pre-existing fail
  Server: 308 pass / 0 fail
2026-05-01 12:31:27 -04:00
Joseph Doherty a4ed605f74 A.3 (live smoke): full alarms-over-gateway pipeline verified end-to-end
Skip-gated AlarmsLiveSmokeTests.Alarms_full_pipeline_round_trip ran
against the dev rig with the flip script firing
TestMachine_001.TestAlarm001 every 10s. Verified:
  - Subscribe + 1st PollOnce yield real transition events
  - Field-by-field decode correct (provider, group, tag, severity,
    UTC timestamp, comment, type)
  - SnapshotActiveAlarms reflects current state
  - AcknowledgeByName(real identity) -> rc=0
  - Pipeline keeps streaming transitions on the 10s cadence post-ack

Three production quirks surfaced and were fixed in
WnWrapAlarmConsumer:

1. SetXmlAlarmQuery is mandatory for reads. Skipping it (per the
   earlier discovery-doc recommendation) makes the first
   GetXmlCurrentAlarms2 fail with E_FAIL. The doc's claim that the
   call is unnecessary because the round-trip echo is mangled was
   wrong — mangled echo or not, the call is required.

2. SetXmlAlarmQuery breaks AlarmAckByName on the same consumer
   instance (returns -55). Workaround: provision a parallel
   "ack-only" wnwrap consumer that runs Initialize → Register →
   Subscribe via the v1-prefixed methods, no SetXmlAlarmQuery.
   Production WnWrapAlarmConsumer now holds two COM clients;
   AcknowledgeByName always dispatches through the ack-only one.

3. AlarmAckByName has v2 (8-arg) and v1 (6-arg) overloads. The v2
   8-arg overload returns -55 on this AVEVA build (apparently a
   stub); the v1 6-arg overload works. Production now calls the
   6-arg overload, discarding the proto's operator_domain and
   operator_full_name fields. The proto contract keeps both for
   forward-compat if AVEVA fixes the v2 method.

Bonus finding (not fixed here): AlarmAckByGUID throws
NotImplementedException on wnwrap. Reference→GUID lookup that we
initially planned to plumb is therefore not viable; all acks must
go through AlarmAckByName. WorkerAlarmRpcDispatcher.AcknowledgeAsync
already routes references through the by-name path, so this only
affects the GUID-input branch (which the worker tries first if the
input parses as a GUID — that branch will surface
NotImplementedException as MxaccessFailure if a client supplies one).

Threading caveat: wnwrap is ThreadingModel=Apartment, so the
consumer's internal Timer (firing on threadpool threads) blocks on
cross-apartment marshaling without an STA message pump. The smoke
test sidesteps this with pollIntervalMilliseconds=0 (Timer disabled)
+ manual PollOnce calls from the test STA. Production hosting will
route polls through the worker's StaRuntime in a follow-up; PollOnce
is now public so the wire-up is straightforward.

Test counts after this slice:
  Worker: 195 pass / 4 skipped (live probes incl. new live smoke) /
          1 pre-existing structure-fail (untouched)
  Server: 308 pass / 0 fail
Solution builds clean.

docs/AlarmClientDiscovery.md "Live smoke-test discoveries" section
records all five findings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:17:39 -04:00
Joseph Doherty 4e02927f01 A.3 (alarm-ack-by-name): public AcknowledgeAlarm now accepts Provider!Group.Tag references
Closes the gap where the public AcknowledgeAlarm RPC required canonical
GUIDs but OnAlarmTransitionEvent.AlarmFullReference is "Provider!Group.Tag".
Adds an AVEVA AlarmAckByName path that wraps wwAlarmConsumerClass.AlarmAckByName
so callers can ack with the natural reference.

Proto:
- New MxCommandKind.AcknowledgeAlarmByName (=29).
- New AcknowledgeAlarmByNameCommand(alarm_name, provider_name, group_name,
  comment, operator_user/node/domain/full_name) on MxCommand oneof.
- AcknowledgeAlarmReplyPayload (existing) carries the AVEVA native
  status; reused for the by-name path.

Worker:
- IMxAccessAlarmConsumer + WnWrapAlarmConsumer + AlarmDispatcher +
  AlarmCommandHandler all gain an AcknowledgeByName(name, provider,
  group, comment, operator-identity) overload that maps to
  wwAlarmConsumerClass.AlarmAckByName.
- MxAccessCommandExecutor: new switch arm routes
  MxCommandKind.AcknowledgeAlarmByName to the handler. Empty alarm_name
  yields InvalidRequest; handler exceptions surface as MxaccessFailure.

Gateway:
- WorkerAlarmRpcDispatcher.TryParseAlarmReference: parses
  "Provider!Group.Tag" with the convention that the FIRST '!' separates
  provider, the FIRST '.' after '!' separates group; tag may contain
  more dots.
- AcknowledgeAsync now branches: GUID input → AcknowledgeAlarm command
  (existing path); reference input → AcknowledgeAlarmByName command
  (new path); neither parses → InvalidRequest with a clear diagnostic.

Tests: 13 new unit tests cover each layer end-to-end:
- WorkerAlarmRpcDispatcher.TryParseAlarmReference (3 valid + 8 invalid
  forms) including the realistic 4-component "Galaxy!TestArea.
  TestMachine_001.TestAlarm001" reference.
- WorkerAlarmRpcDispatcher.AcknowledgeAsync routes references through
  AcknowledgeAlarmByName + propagates the full operator tuple.
- Executor switch arm carries the by-name tuple and rejects empty
  alarm_name.
- AlarmDispatcher.AcknowledgeByName forwards to consumer.
- Existing fakes extended for the new overload.

Counts: server 308/0, worker 195/3 skip / 1 pre-existing structure-fail
(untouched). Solution builds clean.

End-to-end alarms-over-gateway now serves the full lmxopcua flow:
client.AcknowledgeAlarm(reference="Galaxy!TestArea.TestMachine_001.TestAlarm001",
operator_user="alice") → gateway parses → IPC AcknowledgeAlarmByName →
worker AlarmAckByName → AVEVA history. The remaining piece for full
parity is a live dev-rig smoke test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:17:15 -04:00
Joseph Doherty 47b1fd422c A.3 (auto-subscribe): SessionManager issues SubscribeAlarms on session open
Adds the missing trigger that activates the worker's wnwrap consumer.
Without this, every session opened in OK state but the consumer never
started, so AcknowledgeAlarm/QueryActiveAlarms returned "alarm consumer
not configured" forever.

New AlarmsOptions config block (under MxGateway:Alarms):
  - Enabled (default false): gates the auto-subscribe path so existing
    deployments without alarm configuration are unaffected.
  - SubscriptionExpression: explicit AVEVA expression like
    \<machine>\Galaxy!<area>.
  - DefaultArea: fallback used when SubscriptionExpression is empty;
    composes \$(MachineName)\Galaxy!$(DefaultArea).
  - RequireSubscribeOnOpen (default false): when true, an auto-subscribe
    failure faults the session; when false, the failure is logged and
    the session stays Ready (data subscriptions keep working, alarms
    return "not subscribed" until the operator retries).

SessionManager.OpenSessionAsync gains a TryAutoSubscribeAlarmsAsync hook
that runs after MarkReady. Skips when alarms are disabled; otherwise
builds a SubscribeAlarmsCommand, invokes it on the session's worker
client, and either logs the resulting status or escalates per
RequireSubscribeOnOpen. SessionManagerException is the failure mode for
the strict path so callers in MxAccessGatewayService surface it as
session-open-failed.

Tests: 7 new unit tests cover the disabled lane, expression-driven
subscribe, DefaultArea fallback, success path, soft-failure (require
off), strict-failure (require on), and missing-config-strict-throw.
Server suite total: 295 pass / 0 fail. Solution builds clean.

End-to-end alarms-over-gateway path is now live (with config). Open a
session against a gateway with Alarms.Enabled=true + a valid
SubscriptionExpression; the worker's wnwrap consumer auto-subscribes;
QueryActiveAlarms streams snapshots; AcknowledgeAlarm acks by GUID.
Reference→GUID resolution (AlarmAckByName worker command) and the live
dev-rig smoke test remain follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:10:13 -04:00
Joseph Doherty 9b21ca3554 A.3 (gateway dispatcher): WorkerAlarmRpcDispatcher routes alarm RPCs over the worker pipe
Replaces NotWiredAlarmRpcDispatcher in DI with a production
implementation that issues the new MxCommandKind.{AcknowledgeAlarm,
QueryActiveAlarms} commands across the IPC and unwraps the resulting
MxCommandReply into the public RPC types.

QueryActiveAlarms is fully wired: builds the QueryActiveAlarmsCommand
(forwarding alarm_filter_prefix), invokes it on the resolved
GatewaySession's worker client, and yields each ActiveAlarmSnapshot
from the QueryActiveAlarmsReplyPayload as the RPC stream. Worker
failures + missing sessions yield an empty stream — matches the
ConditionRefresh contract clients already speak to.

AcknowledgeAlarm is partially wired: the public RPC takes
AlarmFullReference (Provider!Group.Tag), but the worker's wnwrap
consumer acks by GUID. Strategy:
- If AlarmFullReference parses as a canonical GUID, forward it
  directly through MxCommandKind.AcknowledgeAlarm. Native status
  flows back via MxCommandReply.Hresult and the dedicated
  AcknowledgeAlarmReplyPayload.NativeStatus.
- Otherwise, return InvalidRequest with a clear diagnostic naming the
  follow-up — reference→GUID lookup needs a worker-side AlarmAckByName
  command wrapping wwAlarmConsumerClass.AlarmAckByName.

DI: SessionServiceCollectionExtensions registers WorkerAlarmRpcDispatcher
as the default IAlarmRpcDispatcher; MxAccessGatewayService picks it up
via constructor injection. NotWiredAlarmRpcDispatcher is retained for
test fixtures that want the no-side-effect fake.

Tests: 7 new unit tests cover session-not-found short-circuit, GUID-vs-
reference branching, native-status propagation, worker MxaccessFailure
diagnostic propagation, and snapshot-stream yielding. Server test
suite total: 288/0 fail. Solution builds clean.

End-to-end alarms-over-gateway pipeline status:
  consumer → sink → queue (A.2 + A.3 in-process slice)
  worker IPC commands (A.3 worker slice)
  gateway dispatcher (this slice)

Remaining for full E2E:
  - Auto-issue SubscribeAlarms on session open (or add a public
    SubscribeAlarms RPC). Without this trigger the consumer never
    starts and Acknowledge/Query return "not subscribed".
  - AlarmAckByName worker command for ack-by-reference.
  - End-to-end live test against the dev rig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:58:40 -04:00
Joseph Doherty 01f5e6ad91 A.3 (worker IPC slice): proto SubscribeAlarms/Acknowledge/QueryActive commands + executor routing
Adds the worker-side IPC surface for the alarm subsystem so the gateway
can drive the AlarmDispatcher across the named-pipe boundary. Adds four
proto MxCommandKind values + matching command messages and two
MxCommandReply payload variants:

- SubscribeAlarmsCommand(subscription_expression)
- UnsubscribeAlarmsCommand
- AcknowledgeAlarmCommand(alarm_guid, comment, operator_user/node/domain/full_name)
- QueryActiveAlarmsCommand(alarm_filter_prefix)
- AcknowledgeAlarmReplyPayload(native_status)
- QueryActiveAlarmsReplyPayload(repeated ActiveAlarmSnapshot snapshots)

Worker plumbing:

- New IAlarmCommandHandler interface + AlarmCommandHandler production
  impl. Lazy-creates an AlarmDispatcher (with a wnwrap-backed consumer
  by default) on the first SubscribeAlarms; routes Acknowledge / QueryActive /
  Unsubscribe through it. Idempotent under repeated Unsubscribe; rejects
  a second Subscribe without an intervening Unsubscribe; cleans up the
  consumer if the underlying Subscribe call throws.
- MxAccessCommandExecutor: 4 new switch arms map MxCommandKind values to
  IAlarmCommandHandler calls. Acknowledge surfaces the AVEVA native
  status into both MxCommandReply.Hresult and the dedicated
  AcknowledgeAlarmReplyPayload.NativeStatus so gateway-side consumers
  can echo it without unpacking the outer envelope. Invalid GUIDs and
  missing payloads return InvalidRequest; handler exceptions return
  MxaccessFailure with the exception message in DiagnosticMessage.
- MxAccessStaSession: new constructor overload accepts an
  alarmCommandHandlerFactory; it's invoked on the STA thread during
  StartAsync and the resulting handler is passed into the executor.
  ShutdownGracefullyAsync + Dispose tear it down on the STA before the
  data-side cleanup runs.

Tests: 20 new unit tests covering AlarmCommandHandler lazy lifecycle
(Subscribe/Unsubscribe/Acknowledge/Query/Dispose, error paths) and the
executor's 4 alarm switch arms (OK/InvalidRequest/MxaccessFailure paths,
hresult propagation, prefix filtering). Worker test suite total: 192
passed / 3 skipped (live probes) / 1 pre-existing structure-test fail
(untouched).

Deferred to next slice: gateway-side WorkerAlarmRpcDispatcher that
replaces NotWiredAlarmRpcDispatcher, builds + sends these commands across
the IPC, and unwraps the resulting MxCommandReply into AcknowledgeAlarmReply
/ ActiveAlarmSnapshot stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:52:04 -04:00
Joseph Doherty 82eb0ad569 A.3 (in-process slice): AlarmDispatcher wires consumer events onto event queue
Adds the in-process plumbing that connects WnWrapAlarmConsumer's
AlarmTransitionEmitted stream to the worker's MxAccessEventQueue via
MxAccessAlarmEventSink. With this change a transition raised by the
consumer lands as an OnAlarmTransitionEvent proto on the queue,
SessionId attached, ready for IPC dispatch.

Mapping: provider!group.tag → AlarmFullReference, tag → SourceObjectReference,
priority → severity, wnwrap STATE → AlarmConditionState (Active /
ActiveAcked / Inactive — wnwrap's ack-vs-unack-on-cleared distinction
collapses since OPC UA Part 9 doesn't model it). State delta drives
AlarmTransitionKind via the existing AlarmRecordTransitionMapper table.

Holding off on the proto IPC additions (SubscribeAlarms /
AcknowledgeAlarm / QueryActiveAlarms commands + WorkerAlarmRpcDispatcher)
for a follow-up — those touch every layer of the worker IPC and warrant
their own PR. This slice proves the consumer→sink→queue pipeline
end-to-end with unit tests and clears the path for the proto additions
to plug in cleanly.

Tests: 10 new unit tests cover field-by-field mapping, the
"unchanged-state-doesn't-emit" filter, the state→transition kind table,
Subscribe / Acknowledge passthrough, SnapshotActiveAlarms → proto
ActiveAlarmSnapshot mapping, and Dispose detaches the handler. All
passing; total worker test count 172/3 skip / 1 pre-existing structure
fail (untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:52:35 -04:00
Joseph Doherty f711a55be4 A.2: replace AlarmClientConsumer with wnwrap-based polling consumer
Switch the worker's alarm-consumer surface from `aaAlarmManagedClient.AlarmClient`
to `WNWRAPCONSUMERLib.wwAlarmConsumerClass` (CLSID 7AB52E5F-…) hosted by
`wnwrapConsumer.dll`. The new path returns alarm records as a BSTR XML
payload via `GetXmlCurrentAlarms2`, bypassing the FILETIME→DateTime
auto-marshaling that crashed `GetHighPriAlarm` with
ArgumentOutOfRangeException on every poll. Live captured 60/60 polls
clean against `\DESKTOP-6JL3KKO\Galaxy!DEV` while a System Platform
script flipped TestMachine_001.TestAlarm001 every 10s; the GUID,
priority, state (UNACK_ALM ↔ UNACK_RTN), and ASCII-formatted timestamps
arrived end-to-end.

Implementation:
- `Interop.WNWRAPCONSUMERLib.dll` generated via tlbimp, checked in under
  `lib/` so dev boxes don't need the SDK to build.
- New `WnWrapAlarmConsumer` (replaces `AlarmClientConsumer`): owns a
  500ms polling timer, parses `GetXmlCurrentAlarms2` output, diffs the
  snapshot keyed by alarm GUID, and raises one
  `MxAlarmTransitionEvent` per state change. Includes the
  Initialize→Register-before-Subscribe ordering fix found during
  Discovery probe runs.
- New library-agnostic types `MxAlarmSnapshotRecord` /
  `MxAlarmStateKind` / `MxAlarmTransitionEvent` so the proto-build
  path is testable without an AVEVA install.
- `AlarmRecordTransitionMapper` retired the COM-coupled
  `MapTransitionKind(eAlmTransitions)`; new pure helpers
  `ParseStateKind`, `MapTransition(prev, curr)`, and
  `ParseTransitionTimestampUtc` cover XML decode + state-delta logic.
- `IMxAccessAlarmConsumer` event surface changed from
  `EventHandler<AlarmRecord>` to `EventHandler<MxAlarmTransitionEvent>`
  and `SnapshotActiveAlarms()` returns `MxAlarmSnapshotRecord` —
  decoupling the interface from any specific COM library.
- Worker csproj drops `aaAlarmManagedClient` / `IAlarmMgrDataProvider`
  refs; adds `Interop.WNWRAPCONSUMERLib`.

Tests:
- 36 new unit tests (state-string mapping, prev/current → proto kind
  decision table, timestamp UTC reassembly, XML payload parser, 32-char
  hex GUID round-trip) covering everything that doesn't touch the live
  COM surface — all passing.
- Skip-gated `WnWrapConsumerProbeTests.ProbeWnWrapConsumer` archives
  the live capture flow for regression / future probes.

Docs:
- `docs/AlarmClientDiscovery.md` "Option A — captured" section records
  sample XML payloads, the mangled `SetXmlAlarmQuery` round-trip
  (prefer `Subscribe` for filtering), the `GetStatistics`
  AccessViolationException quirk, and the worker-integration outline.

Pre-existing failure noted (separate):
`MxAccessInteropReference_ExistsOnlyInWorkerProject` was already
failing on HEAD — the test project still references `ArchestrA.MxAccess`
for the Skip-gated discovery probes. Not regressed by this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:44:15 -04:00
Joseph Doherty f490ae2593 docs: revise interop fix path — wnwrapConsumer.dll is the right surface
Reflection on aaAlarmManagedClient.AlarmClient shows it implements
only IDisposable (no [ComImport] interface, no class GUID) and
has a single field "CwwAlarmConsumer* m_almUnmanaged". So
AlarmClient is a C++/CLI managed wrapper around a native C++
class -- NOT a COM-interop class. The DateTime conversion happens
INSIDE AVEVA's wrapper IL, not at the .NET-COM marshaling
boundary. There's no separate COM interface to QI to.

Revised approach (in docs/AlarmClientDiscovery.md):

A. wnwrapConsumer.dll -- separate standalone COM library AVEVA
   ships at "C:\Program Files (x86)\Common Files\ArchestrA"
   exposing WNWRAPCONSUMERLib.wwAlarmConsumerClass with
   SetXmlAlarmQuery / GetXmlCurrentAlarms. XML-string output
   bypasses FILETIME marshaling entirely. Best fit -- real COM,
   self-contained, conventional production-grade approach.
B. Patch aaAlarmManagedClient.dll IL -- direct but modifies a
   vendor binary, brittle to upgrades.
C. Reflect into m_almUnmanaged and call native vtable directly
   -- requires reverse-engineering the C++ class layout.

Picking A. Probe restored to Skip; next commit starts the
wnwrapConsumer integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:15:37 -04:00
Joseph Doherty 39f9fd8946 probe: BREAKTHROUGH — alarms flow via canonical \Node\Galaxy!Area, blocked by DateTime marshaling
Two findings that turn the alarm capture path on:

1. Subscription expression: \<MachineName>\Galaxy!<Area> is the
   canonical AlarmClient subscription format per ArchestrA docs:
   \Node\Provider!Area!Filter, with Provider literally "Galaxy"
   (not the Galaxy name) and Node being the machine name. For
   this rig: \DESKTOP-6JL3KKO\Galaxy!DEV catches alarms.
2. InitializeConsumer before RegisterConsumer — discovered
   earlier; bug-fix for PR A.5's AlarmClientConsumer.

With these in place, GetHighPriAlarm returned a record on every
poll for 60s straight (117/117 calls). But every call throws
ArgumentOutOfRangeException: Not a valid Win32 FileTime, because
AlarmRecord has five DateTime fields (ar_Time / ar_OrigTime /
ar_AckTime / ar_RtnTime / ar_SubTime) and AVEVA writes sentinel
FILETIME values for unset ones (e.g., ar_AckTime on an
unacknowledged alarm). The aaAlarmManagedClient.dll auto-marshals
FILETIME -> DateTime and rejects out-of-range values.

GetStatistics still reports total=0 active=0 even with
GetHighPriAlarm returning records — those two APIs have
different views. The active read API for current alarms is
GetHighPriAlarm, not GetStatistics's change array.

So the consumer chain works. The blocking issue is now
extracting the payload past the AVEVA-shipped DateTime
auto-marshaling. Three approaches for the next PR:

1. Patch aaAlarmManagedClient.dll via ildasm/ilasm round-trip.
2. Define a custom [ComImport] interface with safe-blittable
   types and Marshal.QueryInterface to it.
3. Use IDispatch late binding to bypass strong-typed marshaling.

Option 2 is cleanest; needs the AlarmClient COM IID.

Probe changes:

- Subscription expression set to \<MachineName>\Galaxy!DEV.
- GetHighPriAlarm tally counters (ok-with-record vs throw).
- 117 throws / 0 ok-with-record over 60s confirms alarms are
  flowing continuously while the user's flip script runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:06:45 -04:00
Joseph Doherty bb7be14d1d probe: aaAlarmManagedClient receives no alarm data — full consumer chain verified
Sixth probe iteration with every consumer-side knob exhausted:

- Subscriptions tried (all rc=0): \Galaxy!, \Galaxy!*, \Galaxy!,
  \Galaxy!TestArea, \.\Galaxy!.
- Read channels polled at 500ms: GetStatistics, GetHighPriAlarm,
  SFCreateSnapshot + SFGetStatistics.
- Filters: priority 0..32767, qtSummary + qtHistory both tried,
  asAlarmActiveNow.
- AlarmRecord pre-init to FILETIME epoch to dodge marshaler bug
  on default(DateTime).

Result: every read API returns empty for the entire 60s window
even with TestMachine_001.TestAlarm001 firing every 10s and
aaObjectViewer confirming InAlarm transitions. The
aaAlarmManagedClient.AlarmClient is not the receive surface
AVEVA's alarm pipeline routes to in this Galaxy configuration.

The consumer chain is verified working end-to-end: Initialize +
Register + Subscribe all succeed, GetProviders finds the
provider, the WM 0xC275 heartbeat fires at 1Hz to AVEVA's
internal hwnd. There is simply no alarm data flowing through
this consumer surface.

Next investigation is not consumer-side: either find the SDK
aaObjectViewer's alarm panel uses, or query the historian
event storage directly. If alarms only flow via the historian
path on this customer's Galaxy, the worker's PR A.5 architecture
is a dead-end and A.2 needs a different transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:26:29 -04:00
Joseph Doherty 8ac6642bf8 probe: subscribe-parameter sweep — alarms still absent, producer-side blocked
Tried every documented subscription knob with InitializeConsumer
present + provider visible at status 100:

- qtSummary AND qtHistory (the only eQueryType values).
- Priority 1..999 AND 0..32767.
- FilterMask/Spec asNone AND asAlarmActiveNow.

eAlarmFilterState is single-state-valued (asNone=0,
asAlarmActiveNow=1, asAlarmAcked=2, asShelved=3), not flag bits,
so the filter surface is exhausted.

GetStatistics continued to report total=0 active=0 codes=[7]
for every poll across all combinations.

User confirmation: the BoolAlarm extension on
TestMachine_001.TestAlarm001 is evaluating (the $Alarm.InAlarm
sub-attribute flips true/false in lockstep with the script
writes, visible in aaObjectViewer). So the consumer chain is
verified working end-to-end on our side. What's missing is
producer-side publication into the aaAlarmManagedClient stream.

Probable causes (config, not code):

- BoolAlarm extension's "publish to alarm manager" / "Active" /
  "Enabled" flag may be off.
- Alarm-vs-event mode setting may have it routing to events,
  not alarms.
- Platform alarm area may not match the consumer's subscription
  scope.

Resolution path: check the BoolAlarm extension's config in System
Platform IDE; check aaObjectViewer's Active Alarms panel (not
attribute panel) to see if the alarm appears there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:53:26 -04:00
Joseph Doherty 4e8928cf71 probe: InitializeConsumer required — provider visible after, alarms still absent
InitializeConsumer was the missing call. Adding it before
RegisterConsumer makes the \Galaxy! provider appear in
GetProviders (status 0 -> 100 within 500ms). Without Initialize,
GetProviders returns an empty list even though everything else
returns rc=0 (success).

Probe trace 2026-05-01:

  InitializeConsumer -> 0
  RegisterConsumer -> 0
  GetProviders [after Register] -> count=0 list=[]
  Subscribe('\Galaxy!') -> 0
  GetProviders [after Subscribe] -> count=1 list=[  0 \Galaxy!]
  GetProviders [poll #1] -> count=1 list=[100 \Galaxy!]

Despite the provider being at "100% query complete" for the
entire 60s window, GetStatistics continued to report
total=0 active=0 codes=[7] -- no alarm transitions reached the
consumer even with a System Platform script flipping
TestMachine_001.TestAlarm001 every 10s during the run.

So the consumer chain works end-to-end. What's missing is alarm
traffic from the producer side. The next discriminator is
whether ObjectViewer (or another live consumer) sees the alarm
fire while the script runs.

API-ordering bug fix to apply to PR A.5's AlarmClientConsumer
regardless of how A.2 lands: AlarmClientConsumer.Subscribe
should call InitializeConsumer before RegisterConsumer (currently
omits Initialize entirely, which means the provider chain is
never visible from the worker either). That fix lifts a
fundamental bug independent of the polling-vs-callback question.

Probe changes:

- Added InitializeConsumer call before RegisterConsumer.
- Added LogProviders helper that logs only on change; called
  after Register, after Subscribe, and on every poll. Easier
  to spot when the provider chain transitions from empty to
  populated.
- Restored Skip-gating after run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:43:06 -04:00
Joseph Doherty f4423dfb6d probe: GetProviders=0 — alarm path upstream-blocked on dev rig
Extended AlarmClientWmProbeTests to call AlarmClient.GetProviders
after RegisterConsumer. Run 2026-05-01:

  GetProviders -> rc=0 count=0 list=[]

Zero alarm providers visible to the consumer. This explains every
preceding probe run — no providers means no alarm events,
regardless of subscription expression or value writes upstream.
Even with a System Platform script flipping
TestMachine_001.TestAlarm001 every 10s during the run,
GetStatistics reported no transitions, no positions[] entries,
no field changes after t=0.85s.

Possible causes (dev-rig configuration, not code):

1. No $Alarm extension on the test bool — flipping the value
   writes a value but doesn't fire an alarm.
2. AVEVA alarm-manager service (aaAlarmMgr or equivalent) not
   running on this rig.
3. Process security context — providers registered under a
   service account aren't visible to a consumer running under
   a normal user account.

A.2 implementation is blocked on this until at least one provider
is visible. Once a provider exists, the polling-vs-callback
question is answerable in one probe run; without a provider both
paths return the same "nothing happening" answer.

Probe changes:

- Added in-process MxAccess Write attempt (TriggerWriteValue) —
  hit TargetParameterCountException so the Write signature is
  not (handle, item, value); reflection diag added but not
  resolved. Now disabled in favor of external trigger.
- Added GetProviders enumeration after RegisterConsumer.
- Removed firePrint/clearPrint markers; probe is observe-only.
- Added ArchestrA.MxAccess reference to the test project.

Also updated docs/AlarmClientDiscovery.md with the
alarm-provider-visibility section explaining what's blocked
and why.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:37:15 -04:00
Joseph Doherty 3ff4969224 probe: GetStatistics polling viable, Galaxy has no active alarms today
Extended AlarmClientWmProbeTests.ProbeAlarmClientWmMessages to also
call GetStatistics every ~2s during the pump window. Re-ran on the
dev rig 2026-05-01:

- GetStatistics is safely callable from the same thread that did
  RegisterConsumer + Subscribe. Every poll (9 calls / 20s window)
  returned rc=0, no exceptions.
- Galaxy currently has zero active alarms. total=0 active=0
  suppressed=0 newAlarms=0 across every poll. positions[] and
  handles[] arrays were empty.
- changes=1 codes=[7] was constant across all polls, matching the
  constant 1 Hz WM 0xC275 cadence — same heartbeat semantics
  exposed through both the WM path and the pull API.

Confirms the polling design is mechanically viable: GetStatistics
threading-affinity is fine and the call is cheap. The remaining
unknown is whether GetStatistics populates positions[] / handles[]
with real entries when an alarm actually fires. Proving that
requires triggering an alarm — next probe is an MxAccess write to a
$Alarm-extended boolean tag (reference pending).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:16:08 -04:00
Joseph Doherty 12881ca791 docs+test: live AlarmClient WM probe — heartbeat-only, hWnd not used
Added MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs as a Skip-gated
runtime probe. Run on the dev rig 2026-05-01 against the live AVEVA
install (Galaxy reachable, no manual alarm fired). Findings:

- RegisterConsumer(hWnd, ...) and Subscribe("\Galaxy!", ...) both
  return 0 (success). Calls are valid against the deployed assembly.
- A registered-message-class WM (ID 0xC275 in this OS session) fires
  every ~1 second after Subscribe completes. Constant wParam=0x1100,
  constant lParam=0x079E46D8 — looks like a heartbeat / keepalive,
  not a per-change notification.
- Critically, this WM is delivered to AVEVA's own internal window
  (hwnd=0x18032E), NOT to the consumer hWnd we registered. The
  consumer window receives only the standard WM_CREATE / WM_DESTROY
  sequence; no AVEVA traffic in between.

This invalidates the WM_APP-pump design previously documented. The
hWnd parameter to RegisterConsumer appears to be a registration
identity only — AVEVA's notification path runs entirely against
AVEVA's own internal window.

Two viable A.2 designs replace the previous one:

1. Polling. Call GetStatistics on a 500ms timer in the worker's STA
   and react to whatever change set it reports. No window plumbing
   needed. Latency floor = poll period. Matches AVEVA's own
   internal heartbeat cadence.
2. Hook AVEVA's internal window. Discover AVEVA's own hwnd,
   SetWindowSubclass on it, intercept WM 0xC275 on AVEVA's thread.
   Higher fidelity, lower latency, but invasive and fragile across
   AVEVA upgrades — likely a non-starter.

Recommendation in docs/AlarmClientDiscovery.md is option 1 (polling)
unless a follow-up probe with a real fired alarm shows AVEVA does
post change-specific WMs to a different hWnd.

Open follow-up probes documented:

- Fire a real Galaxy alarm during pump and check whether WM 0xC275
  cadence changes or GetStatistics returns non-empty arrays.
- GetStatistics threading affinity test.
- Hook AVEVA's internal window 0x18032E.
- Decompile aaAlarmManagedClient IL for RegisterConsumer to find
  whether WNAL_Register's callback surface is wrapped.

Test project changes:

- Added Reference to aaAlarmManagedClient + IAlarmMgrDataProvider
  (Private=true so the DLL gets copied into bin for test load).
- Test-suite-wide: 127 real tests still pass; both alarm-related
  Skip-gated tests skip cleanly.

Code change to the probe is additive — the worker is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:05:47 -04:00
Joseph Doherty 6e356da092 docs: AlarmClient public surface — managed-event premise wrong, WM_APP required
Reflection probe of the deployed aaAlarmManagedClient.dll
(v1.0.7368.41290) on 2026-05-01 confirmed the public AlarmClient class
exposes zero public events. The PR A.5 design that AlarmClientConsumer
is built on (managed-event surface, no message pump) does not hold
against this assembly.

The actual notification mechanism is WM_APP messaging:
RegisterConsumer(hWnd, ...) takes a window handle because AVEVA's alarm
provider WM_APP-pokes the registered window, then GetStatistics +
GetAlarmExtendedRec pull the change set on each poke.

Practical impact:

- AlarmClientConsumer.AlarmRecordReceived has no production caller.
  RaiseAlarmRecordReceived is invoked only from tests. Subscribe(...)
  returns OK from RegisterConsumer + Subscribe but no notifications
  reach the consumer at runtime because no window is attached.
- Until A.2 lands a hidden message-only window + WindowProc that routes
  WM_APP into MxAccessAlarmEventSink.EnqueueTransition, the gateway's
  MX_EVENT_FAMILY_ON_ALARM_TRANSITION family cannot carry events.
- AcknowledgeByGuid and SnapshotActiveAlarms are pull-style and remain
  correct as written.

Changes:

- docs/AlarmClientDiscovery.md (new) — reflection probe summary, full
  AlarmClient method list, open questions for A.2 implementation.
- AlarmClientConsumer.cs xmldoc — replaced the inaccurate "managed
  event surface" claim with the WM_APP finding; flagged
  AlarmRecordReceived as unreachable in production until the WM_APP
  pump lands.
- MxAccessAlarmEventSink.cs xmldoc — replaced the "verify on dev rig"
  hedge in the wiring plan with the resolved finding; expanded the
  open-questions list (WM_APP message ID, wParam/lParam semantics, STA
  affinity, subscription scope) so the next A.2 PR knows what the
  dev-rig probe needs to answer.

Code-only no-op for the worker; worker builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 06:50:57 -04:00
dohertj2 a739fadb5f Merge pull request 'gateway: alarm-RPC dispatcher seam (PRs A.6 + A.7)' (#117) from track-a6-a7-alarm-rpc-dispatch into main 2026-04-30 22:50:07 -04:00
Joseph Doherty 6b3c117d1e gateway: alarm-RPC dispatcher seam (PRs A.6 + A.7)
Replaces the inline diagnostic strings in PR A.3's AcknowledgeAlarm
+ QueryActiveAlarms handlers with an IAlarmRpcDispatcher seam.

- IAlarmRpcDispatcher (new) — gateway-side abstraction over the
  worker-RPC path that fronts AlarmClient.AlarmAckByGUID and the
  active-alarm walk. AcknowledgeAsync returns the
  AcknowledgeAlarmReply directly; QueryActiveAlarmsAsync yields an
  IAsyncEnumerable<ActiveAlarmSnapshot>.
- NotWiredAlarmRpcDispatcher (new, default impl) — returns
  PROTOCOL_STATUS_OK with a structured worker-pending diagnostic
  on Acknowledge, yields an empty stream on QueryActiveAlarms.
  Same observable shape as PR A.3, but the integration seam is
  now in code instead of hardcoded inside the handler.
- MxAccessGatewayService — handlers delegate to the dispatcher.
  Constructor accepts an optional IAlarmRpcDispatcher (default
  NotWiredAlarmRpcDispatcher); a future WorkerAlarmRpcDispatcher
  registration in DI swaps in the live worker-IPC routing without
  changing the public RPC surface.
- 2 new dispatcher tests pin the not-wired contract; 279 → 281
  total tests, all green.

Worker-side dispatch (translating Acknowledge / QueryActiveAlarms
to the IPC method that calls IMxAccessAlarmConsumer from PR A.5)
is the dev-rig follow-up — it depends on validating the AVEVA
GetAlarmChangesCompleted event subscription against a live alarm
provider before pinning a wire format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:47:42 -04:00
dohertj2 c7d5b83390 Merge pull request 'worker: AlarmClientConsumer + transition mapper (PR A.5)' (#116) from track-a5-alarm-consumer-wiring into main 2026-04-30 22:44:51 -04:00
Joseph Doherty 1ac5bcafb2 worker: AlarmClientConsumer + transition mapper (PR A.5)
Wires the worker-side consumer for AVEVA alarm transitions over the
aaAlarmManagedClient API discovered in the prior foundation PR.

- IAlarmMgrDataProvider.dll referenced — exposes AlarmRecord +
  eAlmTransitions / eQueryType / eSortFlags / eAlarmFilterState.
  Both DLLs (aaAlarmManagedClient + IAlarmMgrDataProvider) load in
  the worker's existing net48 x86 process; no new bitness boundary.
- IMxAccessAlarmConsumer abstraction — Subscribe / AcknowledgeByGuid
  / SnapshotActiveAlarms / AlarmRecordReceived event. Test seam.
- AlarmClientConsumer production wrapper — RegisterConsumer +
  Subscribe + AlarmAckByGUID + GetStatistics-based active-alarm
  walk, all delegated to AlarmClient. Uses AVEVA's managed event
  surface (GetAlarmChangesCompleted on IAlarmMgrDataProvider) so
  no Windows message pump is required — plain .NET events arrive
  on the alarm-client's internal callback thread.
- AlarmRecordTransitionMapper — pure-function helpers:
    MapTransitionKind(eAlmTransitions): ALM→Raise, ACK→Acknowledge,
        RTN→Clear, others (SUB/ENB/DIS/SUP/REL/REMOVE)→Unspecified
        so EventPump's decoding-failure counter records them.
    ComposeFullReference(provider, group, name): Provider!Group.Name
        format matching AVEVA's standard alarm-reference syntax.

Pinned during dev-rig validation (subsequent commits):

1. Confirm RegisterConsumer accepts hWnd=0 — if it requires a real
   hwnd, the worker creates a hidden message-only window and
   passes that handle. The managed event surface should make
   this irrelevant but the AVEVA API is older than its managed
   wrapper.
2. Wire AlarmClientConsumer.AlarmRecordReceived: the AVEVA
   IAlarmMgrDataProvider.GetAlarmChangesCompleted event needs to
   be hooked from inside the AlarmClient — find the proper
   accessor (likely a property exposing the inner provider).
3. AlarmRecord field-by-field translation into the proto event
   uses MxAccessAlarmEventSink.EnqueueTransition (existing
   plumbing). The AlarmRecord field names (ar_OrigTime,
   AlarmName, AckOperatorFullName, AckComment, etc.) are
   pinned in the discovery dump preserved in
   AlarmClientDiscoveryTests.

Tests: 127 pass (4 new ComposeFullReference cases + 1 Skip-gated
discovery probe). Transition-kind enum mapping is dev-rig-validated
rather than unit-tested because the AVEVA assembly is Private=false
on the reference and isn't copied to the test bin directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:42:22 -04:00
dohertj2 e7c2c546b5 Merge pull request 'worker: aaAlarmManagedClient discovery + reference (alarm-helper foundation)' (#115) from track-alarm-helper-discovery into main 2026-04-30 22:20:07 -04:00
Joseph Doherty a14098468b worker: aaAlarmManagedClient discovery + reference (alarm-helper foundation)
Discovers the surface of aaAlarmManagedClient.dll and stages the worker
csproj reference so subsequent PRs can wire native MxAccess alarm
subscription. Replaces the speculative "operator decision needed
between path 1 and path 2" framing in MxAccessAlarmEventSink with the
validated architecture.

Key findings from the discovery probe:

1. aaAlarmManagedClient.dll is x86 + .NET Framework (mixed-mode
   C++/CLI; PE Machine = i386, NativeEntryPoint flag set). The
   "x64-only" framing in the prior follow-up was wrong — confused
   by the file path under Wonderware\Historian\x64\.
   The assembly is bitness- and runtime-compatible with the
   worker (net48 x86), so it loads in the existing process. No
   sub-process needed.

2. AlarmClient is the public class. Its model mirrors MxAccess:
   RegisterConsumer takes a Windows hWnd and the AVEVA alarm
   service WM_APP-pokes that hwnd when alarms change. The worker's
   existing STA + WM_APP pump can drive both the data-change COM
   subscriber and the alarm-client consumer.

3. AlarmAckByGUID(alarmGuid, ackComment, oprName, oprNode,
   oprDomain, oprFullName) — the native ack carries the operator's
   full identity atomically with the comment. Closes the v1
   operator-comment fidelity gap completely.

This PR:

- Adds the aaAlarmManagedClient.dll reference to MxGateway.Worker.
  csproj. Worker still builds clean.
- Adds AlarmClientDiscoveryTests as a Skip-gated reflection probe;
  flip the Skip parameter to dump the public type surface for
  reference. Captured the dump into MxAccessAlarmEventSink
  documentation so it doesn't have to be re-run.
- Replaces MxAccessAlarmEventSink's "two paths forward" doc with
  the actual wiring plan against AlarmClient's RegisterConsumer +
  Subscribe + AlarmAckByGUID surface.

Subsequent PRs (gated on STA + WM_APP integration testing on the
dev rig):

- Wire RegisterConsumer + Subscribe at session-startup; route
  WM_APP messages through GetStatistics + GetAlarmExtendedRec into
  EnqueueTransition.
- Translate gateway-side AcknowledgeAlarm RPC to a worker command
  that calls AlarmAckByGUID with the OPC UA operator's identity;
  replaces the worker-pending diagnostic from PR A.3.
- Translate gateway-side QueryActiveAlarms to a worker command
  that walks GetStatistics's reported handles via GetAlarmExtendedRec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:17:38 -04:00
dohertj2 e030661c1b Merge pull request 'worker: document MXAccess Toolkit alarm-API gap (A.2 follow-up)' (#114) from track-a2-followup-com-api-finding into main 2026-04-30 21:30:58 -04:00
Joseph Doherty 4e933802a7 worker: document MXAccess Toolkit alarm-API gap (A.2 follow-up)
PR A.2 ship-pin discovery: the MXAccess COM Toolkit installed at
C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll
does not expose any alarm-event family. Reflection enumeration of
the assembly confirms ILMXProxyServerEvents and
ILMXProxyServerEvents2 only carry OnDataChange, OnWriteComplete,
OperationComplete, and OnBufferedDataChange — no IAlarmEventSink,
no Alarms collection, no OnAlarmTransition.

AVEVA's separate alarm-subscription managed assemblies
(aaAlarmManagedClient.dll under InTouch\ViewAppFramework\Content\MA\
and ArchestrAAlarmsAndEvents.SDK.Common.dll under
Wonderware\Historian\x64\) exist on this box but are x64-only —
incompatible with the worker's x86 bitness, which is the bitness
constraint the mxaccessgw architecture exists to isolate in the
first place.

This commit replaces the speculative "TBD pin during dev-rig
validation" comment in MxAccessAlarmEventSink with the actual
finding plus the two operator-facing paths forward:

1. Stay on the value-driven sub-attribute path (current production
   behaviour). lmxopcua's AlarmConditionService already synthesizes
   Part 9 transitions from the four MXAccess sub-attributes.
   Operator-comment fidelity is the only v1 regression.

2. Add an x64 alarm-helper sub-process alongside the worker that
   loads aaAlarmManagedClient and forwards transitions to the
   worker over a small named-pipe IPC. Recovers full v1 fidelity
   but adds operational complexity.

Until that decision resolves, the sink's Attach is a no-op, the
worker continues to function for data subscriptions, and
lmxopcua-side AlarmConditionService keeps the sub-attribute
synthesis active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:28:31 -04:00
dohertj2 6c3edf4516 Merge pull request 'gateway: AcknowledgeAlarm + QueryActiveAlarms handler tests (PR A.4)' (#113) from track-a4-conditionrefresh-coverage into main 2026-04-30 21:23:20 -04:00
Joseph Doherty 9de2c0c43d gateway: AcknowledgeAlarm + QueryActiveAlarms handler tests (PR A.4)
Nineteenth (final) PR of the alarms-over-gateway epic. Pins the
public RPC handler contract added in PR A.3:

- AcknowledgeAlarm rejects empty session_id and empty
  alarm_full_reference with InvalidArgument.
- AcknowledgeAlarm with valid input returns OK and a
  worker-pending diagnostic so clients see a successful round-trip
  even before A.2's worker dispatch lands.
- QueryActiveAlarms rejects empty session_id with InvalidArgument.
- QueryActiveAlarms with valid input streams zero snapshots until
  PR A.2 wires the worker-side QueryActiveAlarmsCommand
  (filter-prefix passthrough verified at the proto layer).
- OpenSession advertises both new RPC capability strings
  (unary-acknowledge-alarm, server-stream-active-alarms) so client
  capability negotiation lights up against the contract surface.

Closes Track A's gateway-side surface. The remaining worker
ConditionRefresh walk + integration parity-rig validation lands
during dev-rig hardware validation alongside PR A.2's COM-side
alarm subscription pin.

Tests: 279 passed (was 273; 6 new). Per-handler integration tests
land alongside the dev-rig validation when the worker walks the
real MxAccess active-alarm collection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:20:57 -04:00
dohertj2 bc61598b44 Merge pull request 'worker: alarm event mapper + sink scaffold (PR A.2 — partial)' (#112) from track-a2-worker-alarm-mapper into main 2026-04-30 21:18:53 -04:00
Joseph Doherty 335c952f00 worker: alarm event mapper + sink scaffold (PR A.2 — partial)
Eighteenth PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Lands the proto-build path that
the worker uses to create OnAlarmTransition events. The COM-side
subscription that registers an alarm event sink against the MXAccess
Toolkit is pinned during dev-rig validation — the exact API differs
across AVEVA versions and needs hardware to verify.

Lands today (unit-testable, no hardware needed):
- MxAccessEventMapper.CreateOnAlarmTransition — mechanical proto
  builder. Takes decoded alarm fields (full reference, source
  object, alarm type, transition kind, severity, timestamps,
  operator user/comment, category, description) and produces an
  MxEvent with the OnAlarmTransition body populated. Mirrors the
  pattern of CreateOnDataChange / CreateOnWriteComplete / etc.
- MxAccessAlarmEventSink — scaffolded class with documented
  Attach / Detach + an internal EnqueueTransition entry point.
  When dev-rig validation pins the MXAccess Toolkit alarm
  subscription API, the only edit needed is to wire the COM
  delegate inside Attach to call EnqueueTransition. The mapper
  bridge is already done.

Pending dev-rig validation:
- Pin the MXAccess Toolkit alarm event source COM API (likely one
  of IAlarmEventSink, IAlarmEventSubscription, or a method on
  LMXProxyServerClass — verify against the worker host's installed
  version).
- Add cancellation/cleanup tests once the COM hook is wired.
- Integration test against the parity rig that fires a real Galaxy
  alarm and asserts the gateway emits OnAlarmTransition.

Tests:
- 2 new mapper tests pin the full-payload Acknowledge case and
  the bare-bones Raise case.
- Full Worker.Tests suite green: 123 passed (was 121; 2 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:16:29 -04:00
dohertj2 3256733d24 Merge pull request 'gateway: AcknowledgeAlarm + QueryActiveAlarms RPC handlers (PR A.3)' (#111) from track-a3-gateway-alarm-handlers into main 2026-04-30 21:06:20 -04:00
Joseph Doherty 4f0f03fca5 gateway: AcknowledgeAlarm + QueryActiveAlarms RPC handlers (PR A.3)
Twelfth PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Lands the public RPC handler
surface that PR A.1's proto introduced. The actual worker-side
ack call + active-alarm walk depend on PR A.2 (worker MxAccess
subscription); this PR ensures clients can call the RPCs and
receive a meaningful response without UNIMPLEMENTED at the gRPC
layer.

- AcknowledgeAlarm — validates session_id + alarm_full_reference,
  resolves the session (NotFound on miss), returns a successful
  reply with a structured DiagnosticMessage indicating worker
  dispatch is pending PR A.2. Once A.2 ships, the body translates
  the request into a WorkerCommand and forwards through
  SessionManager.InvokeAsync.
- QueryActiveAlarms — validates session_id, returns an empty
  stream. PR A.4 layers the actual ConditionRefresh implementation
  once the worker's QueryActiveAlarmsCommand is available.
- OpenSessionReply.Capabilities advertises both new RPCs
  (unary-acknowledge-alarm, server-stream-active-alarms) so
  clients can negotiate against the contract surface.

OnAlarmTransition events flow through the existing StreamEvents
path automatically — EventStreamService and MxAccessGrpcMapper
forward whatever family the worker emits without filtering, so
no changes are needed there for A.3.

Tests: full 273-test suite still green. Per-handler unit tests
ship with PR A.4's expanded surface; A.3's stub handlers are
narrow enough that the existing parity-fixture tests cover the
contract round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:03:58 -04:00
dohertj2 9ca200f814 Merge pull request 'clients/rust: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.6)' (#110) from track-e6-rust-alarm-sdk into main 2026-04-30 17:08:37 -04:00
Joseph Doherty fe19c478c0 clients/rust: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.6)
Eleventh PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Mirrors PR E.2's .NET surface
on the Rust async SDK. Depends on PR E.1 (regen, merged).

- GatewayClient::acknowledge_alarm — async unary call. Uses the
  existing unary_request helper (call timeout) and routes failures
  through Error mapping; non-OK protocol status promotes to
  Error::ProtocolStatus via ensure_protocol_success.
- GatewayClient::query_active_alarms — async server-streaming call
  returning a new ActiveAlarmStream type alias (parallel to
  EventStream). Errors are pre-mapped from tonic::Status; dropping
  the stream cancels the call cooperatively.
- GATEWAY_PROTOCOL_VERSION bumped 2 → 3 to match the .NET contract.
- FakeGateway test impl extends to satisfy the new trait methods so
  client_behavior.rs builds. Two new integration tests cover the
  new SDK methods.

Tests:
- 12 unit + 10 client_behavior + 4 proto_fixtures = 26 tests, all
  pass under cargo test (Rust 1.x via existing toolchain).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:06:13 -04:00
dohertj2 d0bc78cd43 Merge pull request 'clients/java: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.5)' (#109) from track-e5-java-alarm-sdk into main 2026-04-30 17:04:02 -04:00
Joseph Doherty 730fdc93e0 clients/java: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.5)
Tenth PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Mirrors PR E.2's .NET surface
on the Java SDK. Depends on PR E.1 (regen, merged).

- MxGatewayClient.acknowledgeAlarm — blocking unary call, validates
  protocol status via the existing MxGatewayErrors helper. Wraps
  RuntimeException through MxGatewayErrors.fromGrpc for typed
  failure mapping.
- MxGatewayClient.acknowledgeAlarmAsync — CompletableFuture variant
  using the future stub.
- MxGatewayClient.queryActiveAlarms — async server-streaming RPC
  observed via a new MxGatewayActiveAlarmsSubscription handle
  (parallel to MxGatewayEventSubscription; the existing
  subscription class is hard-typed to MxEvent so a parallel type
  was simpler than retrofitting generics).
- MxGatewayClientVersion bumps GATEWAY_PROTOCOL_VERSION 2 → 3 to
  match the .NET contract; CLI version-string assertions updated
  to match.

Java SDK build green via Gradle 9.4.1 (mxgateway-client + mxgateway-cli).
17 tasks, all tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:01:35 -04:00
dohertj2 55470e3e09 Merge pull request 'clients/go: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.4)' (#108) from track-e4-go-alarm-sdk into main 2026-04-30 16:57:18 -04:00
Joseph Doherty b4016e738c clients/go: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.4)
Ninth PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Mirrors PR E.2's .NET surface
on the Go SDK. Depends on PR E.1 (regen, merged).

- Client.AcknowledgeAlarm — context-aware unary call routed through
  the existing callContext helper (default 30s timeout). Failures
  wrap into *GatewayError; protocol-status non-OK promotes to typed
  protocol errors via EnsureProtocolSuccess.
- Client.QueryActiveAlarms — context-streaming wrapper around the
  generated MxAccessGateway_QueryActiveAlarmsClient. Caller drives
  the stream via Recv(); cancelling ctx releases it.
- types.go re-exports the four new generated types
  (AcknowledgeAlarmRequest/Reply, QueryActiveAlarmsRequest,
  ActiveAlarmSnapshot) plus the AlarmTransitionKind /
  AlarmConditionState enums and the
  QueryActiveAlarmsClient stream alias.
- version.go bumps GatewayProtocolVersion 1 → 3 to match the .NET
  contract; the const was previously stale and the bump fixes the
  pre-existing TestOpenSessionFixtureProtocolVersions failure that
  was masked because the fixture had not been regenerated until A.1.

Tests:
- 4 new tests in alarms_test.go — request shape + auth metadata,
  nil-request rejection, Unauthenticated mapping, snapshot
  streaming over bufconn, filter-prefix passthrough.
- All Go test suites green: cmd/mxgw-go + mxgateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:54:22 -04:00
dohertj2 10004879f6 Merge pull request 'clients/python: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.3)' (#107) from track-e3-python-alarm-sdk into main 2026-04-30 16:52:53 -04:00
Joseph Doherty 168bb9a39a clients/python: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.3)
Eighth PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Mirrors PR E.2's .NET surface
on the Python async SDK. Depends on PR E.1 (regen, merged).

- GatewayClient.acknowledge_alarm — async unary call routed through
  the existing _unary helper. ensure_protocol_success raises typed
  gateway errors for non-OK protocol statuses; map_rpc_error wraps
  RpcError → MxGatewayAuthenticationError /
  MxGatewayAuthorizationError on Unauthenticated /
  PermissionDenied responses.
- GatewayClient.query_active_alarms — async iterator over
  ActiveAlarmSnapshot. Mirrors stream_events_raw's cancel-on-close
  pattern via a dedicated _canceling_active_alarms_iterator (typed
  for ActiveAlarmSnapshot).

Tests:
- 6 new tests in test_alarms.py — request shape, Unauthenticated +
  PermissionDenied mapping, snapshot streaming, filter prefix
  passthrough, cancel-on-aclose semantics.
- Full Python test suite: 39 passed (was 33; 6 new).

CLI verb (alarms subscribe / acknowledge / query-active) deferred —
the SDK surface is what lmxopcua consumes; CLI follow-up shares the
JSON output shape with E.2's .NET CLI for cross-language tooling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:50:31 -04:00
dohertj2 a7edc8f8bf Merge pull request 'clients/dotnet: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.2)' (#106) from track-e2-dotnet-alarm-sdk into main 2026-04-30 16:39:35 -04:00
Joseph Doherty 0765eb4de3 clients/dotnet: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.2)
Seventh PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Depends on PR A.1 (proto, merged)
and E.1 (regen, merged).

Hand-written .NET SDK methods on top of the regenerated proto types:

- MxGatewayClient.AcknowledgeAlarmAsync — routes through the existing
  safe-unary retry pipeline (Acks are idempotent at MxAccess), maps
  Unauthenticated/PermissionDenied RpcExceptions to typed
  MxGatewayAuthenticationException / MxGatewayAuthorizationException
  via GrpcMxGatewayClientTransport.MapRpcException.
- MxGatewayClient.QueryActiveAlarmsAsync — server-streaming
  IAsyncEnumerable<ActiveAlarmSnapshot> mirroring the StreamEvents
  pattern.
- IMxGatewayClientTransport extended; GrpcMxGatewayClientTransport
  implements both methods using the regenerated grpc client.
- FakeGatewayTransport extended with capture lists, exception queue,
  and reply / snapshot enqueue helpers.

CLI version-string assertions updated for the GatewayProtocolVersion
2 → 3 bump from A.1.

The CLI alarms verb (subscribe / acknowledge / query-active) is
deferred to a follow-up — keeping this PR focused on the SDK surface
that lmxopcua's GalaxyDriver consumes in PR B.2. The other-language
SDKs (E.3-E.6) layer the same shape on the regen.

Tests:
- 6 new MxGatewayClientAlarmsTests — request shape, cancellation
  honor (linked-token via retry pipeline), Unauthenticated mapping,
  streaming snapshot enumeration, filter prefix passthrough,
  cancellation during enumeration.
- Full client test suite: 57 passed (was 51; 6 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:37:12 -04:00
dohertj2 26d0e2c471 Merge pull request 'clients: regenerate Python + Go protos for alarm RPCs (PR E.1)' (#105) from track-e1-proto-regen into main 2026-04-30 15:47:09 -04:00
Joseph Doherty 65d83b1400 clients: regenerate Python + Go protos for alarm RPCs (PR E.1)
Pure mechanical regen following PR A.1 (alarm-transition event family
+ AcknowledgeAlarm / QueryActiveAlarms public RPCs). Ran:

- clients/python/generate-proto.ps1 → mxaccess_gateway_pb2.py +
  mxaccess_gateway_pb2_grpc.py.
- clients/go/generate-proto.ps1 → mxaccess_gateway.pb.go +
  mxaccess_gateway_grpc.pb.go + galaxy_repository.pb.go (whitespace
  diff from upstream protoc minor version).

The .NET binding regenerates on csproj rebuild via Grpc.Tools — its
artifact (Generated/MxaccessGateway*.cs) was already updated as part
of A.1's commit. Java + Rust regen happens at build time via the
gradle plugin / build.rs respectively, with no committed output to
update.

Smoke-imported the regenerated Python descriptors:
  OnAlarmTransitionEvent.DESCRIPTOR.fields → alarm_full_reference,
    alarm_type_name, category, current_value, description, ...
  AcknowledgeAlarmRequest.DESCRIPTOR.fields → alarm_full_reference,
    client_correlation_id, comment, operator_user, session_id
  ActiveAlarmSnapshot.DESCRIPTOR.fields → alarm_full_reference,
    alarm_type_name, category, current_state, current_value, ...

PRs E.2 - E.6 layer hand-written SDK methods on top of the regenerated
types — those land per-language as separate PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:44:42 -04:00
dohertj2 7b621e3f64 Merge pull request 'proto: alarm-transition family + ack/query RPCs (PR A.1)' (#104) from track-a1-alarm-proto into main 2026-04-30 15:37:08 -04:00
Joseph Doherty 0f88a953d7 proto: add alarm-transition event family + ack/query RPCs (PR A.1)
First PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md in lmxopcua). Pure contract-surface
change — no functional wiring yet. Worker-side subscription (A.2),
gateway-side dispatch + ack handler (A.3), and ConditionRefresh
(A.4) follow.

mxaccess_gateway.proto:

- Extend MxEventFamily with MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5.
- Extend MxEvent.body oneof with OnAlarmTransitionEvent on_alarm_transition = 24.
- Add OnAlarmTransitionEvent message carrying the full MxAccess alarm
  payload (full reference, source object, alarm-type-name, transition
  kind, raw severity, original raise timestamp, transition timestamp,
  operator user/comment, category, description, current/limit value).
  Mapping to OPC UA 0-1000 severity ladder happens server-side in
  lmxopcua's MxAccessSeverityMapper (B.1) — gateway preserves the
  native MxAccess scale.
- Add AlarmTransitionKind enum (Raise / Acknowledge / Clear / Retrigger).
- Add ActiveAlarmSnapshot + AlarmConditionState for the
  ConditionRefresh stream.
- Add public RPCs AcknowledgeAlarm (unary) and QueryActiveAlarms
  (server-streaming) on MxAccessGateway service.
- Add AcknowledgeAlarmRequest/Reply + QueryActiveAlarmsRequest.

GatewayContractInfo.GatewayProtocolVersion bumps 2 -> 3. Fixture
manifests (proto-inputs, behavior, parity, golden OpenSessionReply)
and protoset descriptor regenerated.

Tests: round-trip serialization for the new messages with
all-fields-populated and empty-optional-fields cases; oneof
last-write-wins guard between OnDataChange and OnAlarmTransition;
descriptor service-method enumeration includes the two new RPCs.
All 273 existing tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:34:35 -04:00
Joseph Doherty ddad573b75 Merge origin/main with local pending work and update AGENTS.md references
- Resolve 14 conflicts from popping local stash on top of origin's
  eed1e88 + 8d3352f doc-comment additions (11 mechanical, plus
  version.rs, DashboardAuthenticatorTests.cs, DashboardGalaxyProjector.cs)
- Fix 4 test files that used AGENTS.md as the repo-root sentinel
  (now use CLAUDE.md, since AGENTS.md was removed in 4731ab5)
- Redirect 10 doc citations from AGENTS.md to the matching gateway.md
  sections (Value Model, Status Model, Security, STA Worker Thread
  Model, gRPC Layer rule, cancellation rule)

Verified: solution build clean, x86 worker build clean, 266/266
gateway tests passing, 121/121 worker tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:13:33 -04:00
Joseph Doherty 8d3352f2c6 Add idiomatic documentation to Go, Java, Python, and Rust clients 2026-04-30 12:04:46 -04:00
Joseph Doherty eed1e88a37 Add XML documentation across gateway, worker, and .NET client 2026-04-30 11:49:58 -04:00
Joseph Doherty 4731ab535c Remove AGENTS.md superseded by CLAUDE.md
The operational rules from AGENTS.md (parity contract, source-update
verification matrix, MXAccess/Galaxy analysis sources, dashboard
constraints, gateway-doesn't-reattach-orphans) are now in CLAUDE.md.
Architecture details remain in gateway.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 10:23:19 -04:00
Joseph Doherty 51a9dadf62 Align docs with StyleGuide and add CLAUDE.md
- Rename 16 kebab-case docs to PascalCase per StyleGuide
- Move per-language client design docs from docs/ to clients/<lang>/
  alongside their READMEs
- Add ## Related Documentation sections to 15 docs that lacked one
- Fix sentence-case violations in H3 headings (StyleGuide rule)
- Update cross-references in gateway.md, client READMEs, scripts,
  and generate-proto.ps1 helpers to follow the new paths
- Add CLAUDE.md with build/test commands, the source-update
  verification matrix, the parity-first contract, and pointers
  to MXAccess and Galaxy Repository analysis sources

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 10:19:22 -04:00
Joseph Doherty 133c83029b Add Galaxy repository API and clients 2026-04-29 07:27:00 -04:00
Joseph Doherty 047d875fe6 Fix remaining reliability findings 2026-04-28 06:38:05 -04:00
Joseph Doherty b0041c5d18 Fix reliability findings 2026-04-28 06:27:01 -04:00
Joseph Doherty 907aa49aea Improve gateway reliability and client e2e coverage 2026-04-28 06:11:18 -04:00
Joseph Doherty 4fc355b357 Improve gateway reliability and dashboard docs 2026-04-28 00:13:22 -04:00
Joseph Doherty bd4a09a35e Add Polly resilience policies 2026-04-27 15:37:56 -04:00
Joseph Doherty d431ff9660 Fix dashboard static assets and add client e2e scripts 2026-04-27 12:10:40 -04:00
Joseph Doherty 3d11ac3316 Add bulk MXAccess subscription commands 2026-04-26 22:29:27 -04:00
Joseph Doherty daff16cfd2 Add orchestration prompt 2026-04-26 21:31:41 -04:00
dohertj2 6ce61a4f77 Merge pull request #100 from agent-2/issue-50-client-packaging-documentation
Issue #50: document client packaging
2026-04-26 21:28:47 -04:00
Joseph Doherty 4ea2c4fd86 Issue #50: clarify packaging API key placeholders 2026-04-26 21:26:28 -04:00
dohertj2 09e01de9c8 Merge pull request #101 from agent-1/issue-49-cross-language-smoke-test-matrix
Issue #49: add cross-language smoke test matrix
2026-04-26 21:24:29 -04:00
Joseph Doherty 41a2d70f8f Merge remote-tracking branch 'origin/main' into agent-2/issue-50-client-packaging-documentation 2026-04-26 21:23:06 -04:00
Joseph Doherty 79f73e04fd Issue #49: add cross-language smoke matrix 2026-04-26 21:21:49 -04:00
Joseph Doherty f2118f7028 Issue #50: document client packaging 2026-04-26 21:20:43 -04:00
dohertj2 9159f6f093 Merge pull request #99 from agent-1/issue-48-implement-java-client-session-values-errors-and-cli
Issue #48: implement Java client session values errors and CLI
2026-04-26 21:04:24 -04:00
Joseph Doherty d6939432f9 Issue #48: implement Java client session values errors and CLI 2026-04-26 20:59:28 -04:00
dohertj2 02143ef7e2 Merge pull request #98 from agent-2/issue-35-parity-fixture-matrix
Issue #35: add parity fixture matrix
2026-04-26 20:54:24 -04:00
dohertj2 c032852065 Merge pull request #97 from agent-3/issue-46-implement-python-async-client-values-errors-and-cli
Issue #46: implement Python async client values errors and CLI
2026-04-26 20:50:10 -04:00
Joseph Doherty 1d93e77234 Merge remote-tracking branch 'origin/main' into agent-2/issue-35-parity-fixture-matrix 2026-04-26 20:49:43 -04:00
Joseph Doherty 0a670eb381 Issue #35: add parity fixture matrix 2026-04-26 20:47:05 -04:00
Joseph Doherty b57662aae7 Issue #46: implement Python async client values errors and CLI 2026-04-26 20:46:18 -04:00
dohertj2 14afb325c3 Merge pull request #96 from agent-1/issue-47-scaffold-java-gradle-build
Issue #47: scaffold Java Gradle build
2026-04-26 20:42:39 -04:00
Joseph Doherty af42891d5a Issue #47: scaffold Java Gradle build 2026-04-26 20:36:27 -04:00
dohertj2 01a51df053 Merge pull request #95 from agent-2/issue-44-implement-rust-client-session-values-errors-and-cli
Issue #44: implement Rust client session values errors and CLI
2026-04-26 20:34:28 -04:00
Joseph Doherty 89a8fb876a Issue #44: implement Rust client session values errors and CLI 2026-04-26 20:30:04 -04:00
dohertj2 c58358fad9 Merge pull request #94 from agent-3/issue-45-scaffold-python-package
Issue #45: scaffold Python package
2026-04-26 20:28:13 -04:00
dohertj2 8d312a6d2e Merge pull request #93 from agent-1/issue-40-implement-dotnet-values-status-errors-and-cli
Issue #40: implement .NET values status errors and CLI
2026-04-26 20:22:58 -04:00
Joseph Doherty f861a8b3b8 Issue #45: scaffold Python package 2026-04-26 20:22:35 -04:00
Joseph Doherty 499708b2a2 Issue #40: implement .NET values status errors and CLI 2026-04-26 20:17:02 -04:00
dohertj2 191b724f95 Merge pull request #92 from agent-3/issue-42-implement-go-client-session-values-errors-and-cli
Issue #42: implement Go client session values errors and CLI
2026-04-26 20:14:56 -04:00
Joseph Doherty 8793011838 Issue #42: implement Go client session values errors and CLI 2026-04-26 20:09:58 -04:00
dohertj2 b275eedb44 Merge pull request #91 from agent-2/issue-34-worker-live-mxaccess-smoke-test
Issue #34: Worker Live MXAccess Smoke Test
2026-04-26 20:06:58 -04:00
Joseph Doherty a9ef6d10d4 Issue #34: handle worktree roots in live smoke tests 2026-04-26 20:03:21 -04:00
Joseph Doherty 0f17a1d1d9 Add live MXAccess worker smoke test 2026-04-26 19:58:33 -04:00
dohertj2 160343aff4 Merge pull request #90 from agent-3/issue-43-scaffold-rust-workspace
Issue #43: scaffold Rust workspace
2026-04-26 19:52:33 -04:00
dohertj2 8ef98b8beb Merge pull request #89 from agent-1/issue-39-implement-dotnet-gatewayclient-and-session
Issue #39: implement .NET GatewayClient and session
2026-04-26 19:50:37 -04:00
Joseph Doherty f049d3e603 Merge remote-tracking branch 'origin/main' into agent-3/issue-43-scaffold-rust-workspace 2026-04-26 19:49:30 -04:00
Joseph Doherty ee88f9d647 Scaffold Rust client workspace 2026-04-26 19:47:26 -04:00
Joseph Doherty 6e34efd1a5 Merge remote-tracking branch 'origin/main' into agent-1/issue-39-implement-dotnet-gatewayclient-and-session 2026-04-26 19:47:17 -04:00
Joseph Doherty 01d6c33156 Implement .NET gateway client sessions 2026-04-26 19:45:43 -04:00
dohertj2 ec4e2f687e Merge pull request #88 from agent-2/issue-33-implement-graceful-shutdown
Issue #33: implement graceful shutdown
2026-04-26 19:44:00 -04:00
Joseph Doherty f7929cc12f Merge remote-tracking branch 'origin/main' into agent-2/issue-33-implement-graceful-shutdown
# Conflicts:
#	src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs
#	src/MxGateway.Worker/Ipc/WorkerPipeClient.cs
#	src/MxGateway.Worker/Ipc/WorkerPipeSession.cs
2026-04-26 19:41:04 -04:00
Joseph Doherty d890eff862 Implement graceful worker shutdown 2026-04-26 19:36:22 -04:00
dohertj2 9dcd4baff2 Merge pull request #86 from agent-3/issue-41-scaffold-go-module
Issue #41: scaffold Go module
2026-04-26 19:33:44 -04:00
dohertj2 7a0743496f Merge pull request #87 from agent-1/issue-38-scaffold-dotnet-client-projects
Issue #38: scaffold .NET client projects
2026-04-26 19:31:28 -04:00
Joseph Doherty bcfbd1cfc8 Merge remote-tracking branch 'origin/main' into agent-3/issue-41-scaffold-go-module 2026-04-26 19:30:16 -04:00
Joseph Doherty 8e3b0c1c4a Scaffold Go client module 2026-04-26 19:27:27 -04:00
863 changed files with 273069 additions and 13644 deletions
+1
View File
@@ -45,6 +45,7 @@ build/
out/
tmp/
temp/
install/
# .NET
**/bin/
-481
View File
@@ -1,481 +0,0 @@
# MXAccess Gateway Agent Guide
Repository: https://gitea.dohertylan.com/dohertj2/mxaccessgw
This project builds a gateway that gives modern clients full MXAccess parity
without requiring those clients to load MXAccess COM, run as x86, or own an STA
message pump. Treat the installed MXAccess COM component as the compatibility
baseline.
Toolchain paths, versions, and external analysis locations are recorded in
`docs/toolchain-links.md`. Use that file before searching for compilers,
runtimes, protobuf tools, MXAccess notes, or Galaxy Repository SQL notes.
Implementation planning is recorded in `docs/implementation-plan-index.md`.
Follow the order there unless the user explicitly reprioritizes: gateway first,
MXAccess worker instance second, clients third.
## Core Contract
Preserve MXAccess behavior first:
- public MXAccess command semantics,
- native MXAccess event families,
- STA/message-pump delivery behavior,
- installed-provider quirks,
- HRESULT/status/value marshaling,
- per-client isolation.
Do not simplify, normalize, or "fix" MXAccess behavior unless an explicit
non-parity mode is being implemented and tested. `MxAsbClient` and managed NMX
are future acceleration paths only; they do not define the parity contract.
## Architecture
The intended split is:
```text
client
-> gRPC over TCP
-> .NET 10 x64 gateway
-> session manager
-> per-session .NET Framework 4.8 x86 worker process
-> dedicated STA thread
-> MXAccess COM instance
-> Windows/COM message pump
-> command queue
-> event sink
```
The gateway must never instantiate or call MXAccess directly. All MXAccess COM
interaction belongs in the worker process on its dedicated STA thread.
The worker must not host public gRPC. Gateway-to-worker communication should use
a small local IPC protocol, with named pipes and protobuf-framed messages as the
default design.
## Runtime Targets
- Gateway: .NET 10, C#, x64 preferred, ASP.NET Core gRPC.
- Worker: .NET Framework 4.8, C#, x86 by default.
- Worker IPC: one bidirectional named pipe per worker.
- Worker process model: one external client session maps to one worker by
default.
## Style Guides
Follow the project documentation guide and the language guide for every changed
area:
| Area | Style guide |
|------|-------------|
| Documentation | `StyleGuide.md` |
| Gateway, worker, .NET client, and C# tests | `docs/style-guides/CSharpStyleGuide.md` |
| Public gRPC and worker IPC contracts | `docs/style-guides/ProtobufStyleGuide.md` |
| Go client | `docs/style-guides/GoStyleGuide.md` |
| Rust client | `docs/style-guides/RustStyleGuide.md` |
| Python client | `docs/style-guides/PythonStyleGuide.md` |
| Java client | `docs/style-guides/JavaStyleGuide.md` |
When a change crosses languages, apply every affected style guide. Generated
code follows its generator output; do not hand-edit it to match handwritten
style.
## Expected Layout
Prefer this structure unless there is a strong reason to adjust it:
```text
src/MxGateway.Contracts/
Protos/
mxaccess_gateway.proto
mxaccess_worker.proto
Generated/
src/MxGateway.Server/
Program.cs
Sessions/
Workers/
Grpc/
Dashboard/
Metrics/
src/MxGateway.Worker/
Program.cs
Ipc/
Sta/
MxAccess/
Conversion/
src/MxGateway.Tests/
contract tests
gateway session tests
fake worker tests
src/MxGateway.Worker.Tests/
value/status conversion tests
STA queue tests
src/MxGateway.IntegrationTests/
optional live MXAccess tests
clients/dotnet/
.NET 10 C# client library, test CLI, and tests
clients/go/
Go client module, test CLI, and tests
clients/rust/
Rust client crate, test CLI, and tests
clients/python/
Python client package, test CLI, and tests
clients/java/
Java client library, test CLI, and tests
```
The contracts project may multi-target, or the `.proto` files may be shared as
source inputs to both gateway and worker builds.
## Public API Shape
The external API should be session-oriented. Initial rollout should prefer
unary `OpenSession`, `CloseSession`, and `Invoke`, plus server-streaming
`StreamEvents`. Add a bidirectional `Session` stream after the command and event
model is stable.
Do not compress MXAccess into generic verbs too early. Use a command enum with
method-specific payloads so parity can be tested method by method.
Core MXAccess commands to represent:
- `Register`
- `Unregister`
- `AddItem`
- `AddItem2`
- `RemoveItem`
- `Advise`
- `UnAdvise`
- `AdviseSupervisory`
- `AddBufferedItem`
- `SetBufferedUpdateInterval`
- `Suspend`
- `Activate`
- `Write`
- `Write2`
- `WriteSecured`
- `WriteSecured2`
- `AuthenticateUser`
- `ArchestrAUserToId`
Diagnostics may include `Ping`, `GetSessionState`, `GetWorkerInfo`,
`DrainEvents`, and `ShutdownWorker`.
## Event Requirements
Represent every public MXAccess event family:
- `OnDataChange`
- `OnWriteComplete`
- `OperationComplete`
- `OnBufferedDataChange`
Preserve per-worker event order. The gateway must not reorder events emitted by
the same MXAccess instance.
Event DTOs should carry event family, session id, server handle, item handle,
value, quality, timestamp, `MXSTATUS_PROXY[]` equivalent, raw HRESULT/status
fields when available, event sequence, worker timestamp, and gateway receive
timestamp.
## Value And Status Rules
Use a protobuf value union that can represent COM `VARIANT` values and arrays.
When a value cannot be losslessly converted, preserve both the best typed
projection and enough raw diagnostic metadata to reproduce the case.
Represent `MXSTATUS_PROXY` explicitly. Do not collapse status arrays into a
single success flag.
Command replies should include protocol status, COM HRESULT if available,
MXAccess return values, method-specific out parameters, and status arrays where
the MXAccess method emits them.
## Galaxy Repository SQL Discovery
Galaxy tags, hierarchy, and attribute details can be queried from the AVEVA /
Wonderware System Platform Galaxy Repository SQL Server database. Use this as a
discovery and metadata path only; runtime MXAccess parity still belongs to the
MXAccess-backed worker unless an explicit non-parity backend is being designed.
Full notes, schema details, screenshots, and query examples are in:
```text
C:\Users\dohertj2\Desktop\lmxopcua\gr
```
Important files in that notes directory:
- `connectioninfo.md` - SQL Server connection details and `sqlcmd` usage.
- `layout.md` - hierarchy vs `tag_name` relationship.
- `build_layout_plan.md` - extraction plan for hierarchy and attributes.
- `schema.md` and `ddl/` - Galaxy Repository schema reference.
- `queries/hierarchy.sql` - deployed object hierarchy.
- `queries/attributes.sql` - user-defined dynamic attributes.
- `queries/attributes_extended.sql` - system plus user-defined attributes.
- `queries/change_detection.sql` - deployment-change polling via
`galaxy.time_of_last_deploy`.
Current documented connection is SQL Server `localhost`, database `ZB`, Windows
Auth. Example:
```powershell
sqlcmd -S localhost -d ZB -E -Q "SELECT time_of_last_deploy FROM galaxy;"
```
Key tables from the notes are `gobject`, `template_definition`,
`dynamic_attribute`, `attribute_definition`, `primitive_instance`, and
`galaxy`. The hierarchy uses contained names for human-readable browsing, while
runtime tag references use globally unique `tag_name` values such as
`<tag_name>.<AttributeName>`.
## MXAccess Analysis Source
Use the local MXAccess analysis project when answering questions about installed
MXAccess classes, interfaces, fields, events, HRESULT/status behavior, value
projection, captures, and parity gaps:
```text
C:\Users\dohertj2\Desktop\mxaccess
```
Primary files:
- `README.md` - overview of available analysis and capture artifacts.
- `docs/MXAccess-Public-API.md` - COM class, ProgID, CLSID, method list,
event signatures, `MxDataType`, `MxStatus`, and `MXSTATUS_PROXY`.
- `docs/MXAccess-Reverse-Engineering.md` - installed runtime path and x86 COM
constraints.
- `docs/Current-Sprint-State.md` and `docs/DotNet10-Native-Library-Plan.md` -
current parity gaps and managed native-client research status.
- `src/MxTraceHarness/` - x86 MXAccess harness examples using the real COM
interop assembly.
- `captures/` and `analysis/` - observed native behavior and generated
reverse-engineering artifacts.
Concrete MXAccess COM target from the analysis:
- class: `ArchestrA.MxAccess.LMXProxyServerClass`
- CLSID: `{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}`
- ProgID: `LMXProxy.LMXProxyServer.1`
- version-independent ProgID: `LMXProxy.LMXProxyServer`
- registered server: `C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll`
- interop assembly:
`C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
- threading model: `Apartment`
## Worker Rules
Each worker owns:
- one process,
- one MXAccess session,
- one dedicated STA thread,
- one MXAccess COM object,
- one inbound command queue,
- one outbound event queue.
All MXAccess operations must run on the STA. A plain blocking queue is not
enough for the STA; the STA loop must pump Windows/COM messages and service
queued commands.
Do not block the STA on pipe writes, gRPC calls, or slow consumers. Event
handlers should convert event args, enqueue outbound events, and return to
pumping messages.
On graceful shutdown, reject new commands, optionally clean up active MXAccess
handles, detach events, release the COM object, uninitialize COM, and exit. If
graceful shutdown exceeds the configured timeout, the gateway may kill the
worker.
## IPC Rules
Default pipe name shape:
```text
mxaccess-gateway-{gatewayProcessId}-{sessionId}
```
Frame messages as:
```text
uint32 little-endian payload_length
payload_length bytes protobuf WorkerEnvelope
```
Every envelope should include protocol version, session id, monotonic sender
sequence, correlation id, and a typed body. Protocol version mismatch should
fail session creation.
Pipe security should be local-machine only, with ACLs restricted to the gateway
identity and launched worker identity. Prefer a per-session nonce handshake.
## Gateway Rules
The gateway is responsible for:
- public TCP/gRPC API,
- Blazor Server dashboard using Bootstrap CSS/JS only,
- authn/authz when needed,
- session creation and teardown,
- worker launch and lifecycle management,
- command routing,
- event streaming,
- leases, heartbeats, timeouts, and quotas,
- worker kill/restart policy,
- metrics and structured logs.
The gRPC layer should stay thin: validate request, find session, call the
session worker client, map worker replies to public replies, and stream events.
Keep MXAccess-specific translation logic testable outside the gRPC handlers.
Dashboard code should also stay thin and read-only for v1. Use a snapshot
service over session/worker/metrics state; do not let Razor components mutate
gateway sessions or workers directly. Do not use MudBlazor or other Blazor UI
component libraries.
Gateway restart should not try to reattach old workers in the first version.
Terminate orphaned workers on startup if that behavior is implemented.
## Command, Timeout, And Cancellation Semantics
Command lifecycle:
```text
client gRPC command
gateway validates session and payload
gateway assigns correlation id
gateway writes WorkerCommand to pipe
worker queues command to STA
STA executes MXAccess method
worker captures return/out/status/HRESULT
worker sends WorkerCommandReply
gateway completes gRPC response
```
Canceling a gRPC call should stop waiting in the gateway, but it cannot safely
abort an in-flight COM call on the STA. Hard cancellation means killing the
worker process.
If a command wedges the STA beyond a configured grace period, the gateway should
kill the worker and fail the session.
## Backpressure Policy
Worker outbound events must use a bounded queue. For parity testing, prefer
fail-fast behavior over silent drops. Production coalescing or drop policies
must be explicit and observable.
The gateway should preserve per-session event order, apply backpressure from
slow gRPC streams, and disconnect or coalesce only according to an explicit
policy.
## Security And Logging
Use TLS for remote gRPC when crossing machine boundaries. Authentication may be
Windows auth, mTLS, or a deployment-specific token.
Commands that write, authenticate users, or alter runtime state need explicit
authorization design.
Never log passwords or raw credential values for `AuthenticateUser`,
`WriteSecured`, or related secured operations. Do not log full values by
default; make value logging opt-in and redacted.
## Testing Expectations
Use focused tests for:
- contract/protobuf compatibility,
- gateway session state and worker lifecycle,
- gateway behavior with a fake worker,
- worker value/status conversion,
- STA queue and message-pump behavior.
Live MXAccess integration tests are optional but should be isolated because they
depend on installed COM components and provider behavior.
Parity tests should compare direct MXAccess behavior against the gateway:
- return values,
- HRESULTs and exceptions,
- event sequence,
- value projection,
- quality/status arrays,
- invalid handle behavior,
- cross-server handle behavior,
- cleanup behavior.
Known important parity areas:
- `WriteSecured` may fail before a value-bearing NMX body is emitted.
- `WriteSecured2` can succeed in observed native paths.
- `OperationComplete` is distinct from write completion.
- `OnBufferedDataChange` has a distinct public event shape.
- Invalid handles and cross-server handles have specific exception/status
behavior.
- STA message pumping is required for event delivery.
## Source Update Workflow
When source code changes, build the affected component before handing work
back. If the change crosses component boundaries, build each affected component
instead of relying on a single top-level build.
Use the native build and test command for each changed area:
| Changed area | Required verification |
|--------------|-----------------------|
| Contracts or `.proto` files | regenerate generated code, then build gateway, worker, and every generated client touched by the contract |
| Gateway server, sessions, workers, gRPC, dashboard, or metrics | build the .NET 10 gateway project and run affected gateway or fake-worker tests |
| Worker IPC, STA, MXAccess, or conversion code | build the .NET Framework 4.8 x86 worker project and run affected worker tests |
| Shared test infrastructure | run every test suite that consumes the changed helpers |
| .NET client | build the .NET client library, CLI, and tests |
| Go client | run Go formatting, build, and tests for the Go module |
| Rust client | run Rust formatting, build or check, and tests for the Rust crate |
| Python client | run Python formatting or linting if configured, package/build checks, and tests |
| Java client | build the Java client library, CLI, and tests |
| Integration tests | run them only when the required MXAccess COM component, provider state, and external services are available; otherwise document why they were skipped |
Update affected documentation in the same change as the source update. This
includes `gateway.md`, component design docs under `docs/`, client docs, API
contract notes, test instructions, and operational guidance. Documentation must
follow `StyleGuide.md`: write technical present-tense prose, explain the reason
for non-obvious choices, use exact code names, specify languages on code
blocks, use relative links for internal docs, and avoid stale temporary notes.
Source code and contract changes must also follow the relevant language guide
from the Style Guides section.
Do not leave documentation describing old behavior after changing public APIs,
contracts, configuration, build steps, security behavior, event shapes, value
conversion, status mapping, lifecycle rules, or client semantics.
## Implementation Priority
Build the smallest end-to-end slice first:
1. .NET 10 gateway starts.
2. Client calls `OpenSession`.
3. Gateway launches .NET Framework 4.8 x86 worker.
4. Worker creates STA and MXAccess COM object.
5. Client calls `Register`.
6. Client calls `AddItem`.
7. Client calls `Advise`.
8. Worker forwards one `OnDataChange` event to the gateway.
9. Gateway streams the event to the client.
10. Client calls `CloseSession`.
11. Gateway shuts down the worker.
That slice proves the high-risk requirements: process isolation, STA ownership,
message pumping, command routing, and event streaming.
+125
View File
@@ -0,0 +1,125 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
`mxaccessgw` is the MXAccess Gateway: a gRPC service that gives modern (.NET, Go, Rust, Python, Java) clients full MXAccess parity without forcing them to load 32-bit MXAccess COM, run x86, or own an STA message pump.
The architecture is a two-process design — read `gateway.md` before making structural changes:
- **Gateway** (`src/MxGateway.Server`, .NET 10, x64): ASP.NET Core gRPC server. Owns the public API, sessions, auth, the Blazor dashboard, and the Galaxy Repository SQL browse RPCs. **Never instantiates MXAccess COM directly.**
- **Worker** (`src/MxGateway.Worker`, .NET Framework 4.8, **x86**): one process per session. Owns one MXAccess COM instance on a dedicated STA, pumps Windows messages, and converts COM events to protobuf.
- **IPC**: gateway↔worker uses one bidirectional named pipe per worker (`mxaccess-gateway-{gatewayPid}-{sessionId}`) with length-prefixed `WorkerEnvelope` protobuf frames. Gateway hosts the pipe server and launches the worker. **gRPC is not used inside the worker** — .NET Framework 4.8 doesn't have a first-class gRPC stack.
- **Contracts** (`src/MxGateway.Contracts`): multi-targets `net10.0;net48` and owns the `.proto` files (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`). All other projects consume the generated types from here. Do not hand-edit anything under `Generated/`.
The worker must do all MXAccess COM calls on its dedicated STA thread, and the STA loop must pump Windows messages (`MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage`) so MXAccess events deliver. A plain blocking queue on an STA is not enough.
## Build, Test, Run
```powershell
# Full solution build (gateway, worker, contracts, tests)
dotnet build src/MxGateway.sln
# Worker must be built x86 — the gateway looks for MxGateway.Worker.exe under bin\x86
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86
# Gateway tests (no MXAccess required — uses FakeWorkerHarness)
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj
dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform=x86
# Run gateway locally (defaults bound under MxGateway:* in src/MxGateway.Server/appsettings.json)
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
# API-key admin CLI (same exe, "apikey" subcommand)
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin
```
Single test by name (xUnit `--filter`):
```powershell
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
```
Live MXAccess integration tests are **opt-in** because they need installed MXAccess COM and live provider state:
```powershell
$env:MXGATEWAY_RUN_LIVE_MXACCESS_TESTS = "1"
dotnet test src/MxGateway.IntegrationTests/MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests
```
Live LDAP tests use `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`. See `docs/GatewayTesting.md` for the full opt-in matrix and `LiveMxAccessFactAttribute` / `LiveLdapFactAttribute` for the gating logic.
## Clients
Each language client is in `clients/<lang>/` with its own README. They all consume the shared `.proto` files in `src/MxGateway.Contracts/Protos`:
- `clients/dotnet`: `dotnet build clients/dotnet/MxGateway.Client.sln`
- `clients/python`: `python -m pip install -e ".[dev]"; python -m pytest`
- `clients/rust`: `cargo test --workspace; cargo clippy --workspace --all-targets -- -D warnings`
- `clients/java`: `gradle test` (Java 21)
- Go client lives alongside as `mxgw-go` in the cross-language matrix
End-to-end matrix runner (needs running gateway + worker + valid API key):
```powershell
$env:MXGATEWAY_API_KEY = "<api-key>"
powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
```
## Repository-Specific Conventions
- **Build properties** (`src/Directory.Build.props`) enforce `Nullable=enable`, `TreatWarningsAsErrors=true`, latest analyzers, and `EnforceCodeStyleInBuild=true`. New warnings break the build — fix them, don't suppress unless the suppression has a narrow reason.
- **Style guides** in `docs/style-guides/` are authoritative. Follow `CSharpStyleGuide.md` for gateway/worker/.NET-client code: file-scoped namespaces, `sealed` by default, `Async` suffix on Task-returning methods, MXAccess-aligned names (`MxStatusProxy`, `ServerHandle`, `ItemHandle`, `HResult`).
- **MXAccess parity is the contract.** Don't "fix" surprising MXAccess behavior (e.g., `WriteSecured` failing before a value-bearing NMX body, distinct `OperationComplete` semantics, invalid-handle exceptions) unless the client explicitly opts into a non-parity mode. The installed MXAccess COM component is the baseline.
- **Don't synthesize events.** The gateway forwards only events the worker emits; it never invents `OperationComplete` from write completion or command replies.
- **One worker per session, one event subscriber per session** (v1). Multi-subscriber fan-out and reconnectable sessions are explicitly out of scope — see `docs/DesignDecisions.md`.
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
- **Generated code** under `src/MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
## Source Update Workflow
When source code changes, build and test the affected component before reporting work done. If the change crosses component boundaries, build each affected component — don't rely on a single top-level build:
| Changed area | Required verification |
|---|---|
| Contracts or `.proto` files | regenerate generated code, then build gateway, worker, and every generated client touched by the contract |
| Gateway server, sessions, workers, gRPC, dashboard, metrics | `dotnet build src/MxGateway.Server` and run affected gateway / fake-worker tests |
| Worker IPC, STA, MXAccess, conversion | `dotnet build src/MxGateway.Worker -p:Platform=x86` and run worker tests |
| .NET client | `dotnet build clients/dotnet/MxGateway.Client.sln` and run its tests |
| Go client | `gofmt`, `go build ./...`, `go test ./...` from `clients/go` |
| Rust client | `cargo fmt`, `cargo check --workspace`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings` from `clients/rust` |
| Python client | `python -m pytest` from `clients/python` |
| Java client | `gradle test` from `clients/java` |
| Integration tests | run only when MXAccess COM, provider state, and external services are available; otherwise document why skipped |
## Design Sources To Consult Before Non-Trivial Changes
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=lmxopcua,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
- `docs/GatewayTesting.md` — fake worker harness, live MXAccess smoke, parity matrix, cross-language smoke matrix.
- `docs/ToolchainLinks.md` — installed compiler/SDK paths on this dev box (.NET 10.0.201, Go 1.26.2, Rust 1.95, Python 3.12.10, Temurin 21, protoc 34.1, etc.).
External analysis sources referenced by design docs:
- `C:\Users\dohertj2\Desktop\mxaccess` — MXAccess analysis project. Key files: `docs/MXAccess-Public-API.md` (COM class, ProgID, CLSID, method list, event signatures, `MxDataType`, `MxStatus`, `MXSTATUS_PROXY`), `docs/MXAccess-Reverse-Engineering.md` (installed runtime path, x86 COM constraints), `docs/Current-Sprint-State.md` (parity gaps), `src/MxTraceHarness/` (x86 harness using the real COM interop), `captures/` and `analysis/` (observed native behavior).
- `C:\Users\dohertj2\Desktop\lmxopcua\gr` — Galaxy Repository (`ZB` SQL DB) notes. Key files: `connectioninfo.md`, `layout.md`, `schema.md`, `queries/hierarchy.sql`, `queries/attributes.sql`, `queries/attributes_extended.sql`, `queries/change_detection.sql`. Connection is SQL Server `localhost`, database `ZB`, Windows Auth.
## Authentication
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled.
## Process / Platform Notes
- Working tree is on Windows (`C:\Users\dohertj2\Desktop\mxaccessgw`). PowerShell is the native shell for tooling commands; bash is fine for git/grep/find.
- The worker reference to `ArchestrA.MXAccess.dll` uses an absolute `HintPath` to `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`. The worker only builds where MXAccess is installed (this dev box).
- The repo is not a git repository at the top level — there's no `.git` directory in the working tree.
+140
View File
@@ -0,0 +1,140 @@
# Code Review Process
This document describes how to perform a comprehensive, per-module code review of
the `mxaccessgw` codebase and how to track findings to resolution.
A **module** is one buildable project under `src/` (e.g. `src/ZB.MOM.WW.MxGateway.Worker`)
or one language client under `clients/` (e.g. `clients/rust`). Each module has
its own folder under `code-reviews/` containing a single `findings.md`.
## 1. Before you start
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
- For a `src/` project, `<Module>` is the project name with the `ZB.MOM.WW.MxGateway.`
prefix stripped — `src/ZB.MOM.WW.MxGateway.Server` is reviewed in `code-reviews/Server/`.
- For a language client, `<Module>` is `Client.<Lang>``clients/rust` is
reviewed in `code-reviews/Client.Rust/`.
2. Identify the design context for the module:
- `gateway.md` — top-level architecture, command/event surface, IPC envelope,
STA thread model, fault handling.
- The relevant component design docs under `docs/` (e.g.
`docs/MxAccessWorkerInstanceDesign.md`, `docs/GatewayProcessDesign.md`,
`docs/Sessions.md`, `docs/Authentication.md`, `docs/GalaxyRepository.md`).
- `docs/DesignDecisions.md` for the v1 design choices.
- The **Repository-Specific Conventions** and **Process / Platform Notes** in
`CLAUDE.md`.
3. Record the exact commit being reviewed: `git rev-parse --short HEAD`. Every
review is a snapshot — a finding only means something relative to a known
commit.
4. Open `code-reviews/<Module>/findings.md` and fill in the header table
(reviewer, date, commit SHA, status).
## 2. Review checklist
Work through **every** category below for the module. A comprehensive review
means the checklist is completed even where it produces no findings — record
"No issues found" for a category rather than leaving it ambiguous.
1. **Correctness & logic bugs** — off-by-one, null handling, incorrect
conditionals, misuse of APIs, broken edge cases.
2. **mxaccessgw conventions** — the rules in `CLAUDE.md` and the style guides
under `docs/style-guides/`: the gateway never instantiates MXAccess COM
directly; all MXAccess COM calls run on the worker's dedicated STA thread and
the STA loop pumps Windows messages; IPC uses one bidirectional named pipe per
worker carrying length-prefixed `WorkerEnvelope` protobuf frames; MXAccess
parity is the contract (don't "fix" surprising MXAccess behaviour, never
synthesize events); one worker and one event subscriber per session; the
gateway terminates orphan workers on startup and does not reattach; C# style
(file-scoped namespaces, `sealed` by default, `Async` suffix, MXAccess-aligned
names); no Blazor UI component libraries; no logging of secrets or full tag
values; generated code is never hand-edited.
3. **Concurrency & thread safety** — shared mutable state, STA affinity, race
conditions, correct use of `async`/`await`, locking, disposal races.
4. **Error handling & resilience** — exception paths, worker crash / reconnect
handling, fail-fast event backpressure, transient vs permanent error
classification, graceful degradation, correct gRPC status codes.
5. **Security** — authentication/authorization checks, API-key scope enforcement,
input validation, SQL injection in the Galaxy Repository RPCs, secret
handling, the dashboard anonymous-localhost bypass, logging of sensitive data.
6. **Performance & resource management**`IDisposable` disposal, pipe / stream
/ COM lifetimes, buffering and back-pressure, unnecessary allocations on hot
paths, N+1 queries.
7. **Design-document adherence** — does the code match `gateway.md`, the relevant
`docs/` component designs, `docs/DesignDecisions.md`, and `CLAUDE.md`? Flag
both code that drifts from the design and design docs that are now stale.
8. **Code organization & conventions** — namespace hierarchy, project layout, the
Options pattern, separation of concerns, additive-only contract evolution.
9. **Testing coverage** — are the module's behaviours covered by tests
(`src/ZB.MOM.WW.MxGateway.Tests`, `src/ZB.MOM.WW.MxGateway.Worker.Tests`,
`src/ZB.MOM.WW.MxGateway.IntegrationTests`)? Note untested critical paths and missing
edge-case tests.
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
undocumented non-obvious behaviour.
## 3. Recording findings
Add one entry per finding to the `## Findings` section of the module's
`findings.md`, using the entry format in
[`_template/findings.md`](code-reviews/_template/findings.md).
- **Finding ID** — `<Module>-NNN`, numbered sequentially within the module and
never reused (e.g. `Worker-001`). IDs are permanent even after resolution.
- **Severity:**
- **Critical** — data loss, security breach, crash/deadlock, or outage.
- **High** — incorrect behaviour with significant impact; no safe workaround.
- **Medium** — incorrect or risky behaviour with limited impact or a workaround.
- **Low** — minor issues, style, maintainability, documentation.
- **Category** — one of the 10 checklist categories above.
- **Location** — `file:line` (clickable), or a list of locations.
- **Description** — what is wrong and why it matters.
- **Recommendation** — concrete suggested fix.
After recording findings, update the module header table (status, open-finding
count) and regenerate the base README (step 5).
## 4. Marking an item resolved
Findings are **never deleted** — they are an audit trail. To close one, change
its **Status** and complete the **Resolution** field:
- `Open` — newly recorded, not yet addressed.
- `In Progress` — a fix is actively being worked on.
- `Resolved` — fixed. The Resolution field must state the fixing commit SHA, the
date, and a one-line description of the fix.
- `Won't Fix` — intentionally not fixed. The Resolution field must justify why.
- `Deferred` — valid but postponed. The Resolution field must say what it is
waiting on (e.g. a tracked issue or a later milestone).
`Resolved`, `Won't Fix`, and `Deferred` findings are all considered **closed**.
`Open` and `In Progress` are **pending** and appear in the base README's Pending
Findings table.
## 5. Updating the base README
`code-reviews/README.md` holds the single cross-module view (the Module Status
table and the Pending / Closed Findings tables). It is **generated** from the
per-module `findings.md` files — do not edit it by hand.
After any review or status change, regenerate it:
```
python code-reviews/regen-readme.py
```
`regen-readme.py --check` exits non-zero if `README.md` is stale, if a module
header's `Open findings` count disagrees with its finding statuses, or if a
finding carries an unrecognised Status value. The PowerShell wrapper
`scripts/check-code-reviews-readme.ps1` runs that check and is the intended hook
for CI or a pre-commit step.
> The repo's installed `python` is the real interpreter; the bare `python3`
> alias resolves to the Windows Store stub and fails. Use `python`.
The per-module `findings.md` files are the source of truth; `README.md` is the
aggregated index and must always agree with them — which the script guarantees.
## 6. Re-reviewing a module
Re-reviews append to the same `findings.md`. Update the header to the new commit
and date, continue the finding numbering from the last used ID, and leave prior
findings (including closed ones) in place as history.
+21
View File
@@ -0,0 +1,21 @@
<Project>
<PropertyGroup>
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
<Authors>Joseph Doherty</Authors>
<Company>ZB MOM WW</Company>
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
<Product>MxAccessGateway Client</Product>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
<Version>0.1.0</Version>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Default: do NOT pack. Each project opts in. -->
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
@@ -6,8 +6,8 @@ Provide an idiomatic .NET 10 C# client library for MXAccess Gateway, plus a test
CLI and unit tests. This client is for modern .NET callers and must not load
MXAccess COM.
Follow the [C# Style Guide](./style-guides/CSharpStyleGuide.md) for
handwritten code and the [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
Follow the [C# Style Guide](../../docs/style-guides/CSharpStyleGuide.md) for
handwritten code and the [Protobuf Style Guide](../../docs/style-guides/ProtobufStyleGuide.md)
for generated contract inputs.
## Projects
@@ -16,9 +16,9 @@ Recommended layout:
```text
clients/dotnet/
MxGateway.Client.sln
MxGateway.Client/
MxGateway.Client.csproj
ZB.MOM.WW.MxGateway.Client.slnx
ZB.MOM.WW.MxGateway.Client/
ZB.MOM.WW.MxGateway.Client.csproj
GatewayClient.cs
MxGatewaySession.cs
MxGatewayClientOptions.cs
@@ -26,14 +26,14 @@ clients/dotnet/
Conversion/
Errors/
Generated/
MxGateway.Client.Cli/
MxGateway.Client.Cli.csproj
ZB.MOM.WW.MxGateway.Client.Cli/
ZB.MOM.WW.MxGateway.Client.Cli.csproj
Program.cs
Commands/
MxGateway.Client.Tests/
MxGateway.Client.Tests.csproj
MxGateway.Client.IntegrationTests/
MxGateway.Client.IntegrationTests.csproj
ZB.MOM.WW.MxGateway.Client.Tests/
ZB.MOM.WW.MxGateway.Client.Tests.csproj
ZB.MOM.WW.MxGateway.Client.IntegrationTests/
ZB.MOM.WW.MxGateway.Client.IntegrationTests.csproj
```
Target framework:
@@ -43,7 +43,7 @@ Target framework:
```
The scaffold uses a project reference to
`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated protobuf and
`src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` for generated protobuf and
gRPC types. `clients/dotnet/generated` remains reserved for client-local
generator output if the .NET client later needs to decouple from the contracts
project.
@@ -83,6 +83,12 @@ public sealed class MxGatewaySession : IAsyncDisposable
public Task<int> AddItem2Async(int serverHandle, string item, string context, CancellationToken ct = default);
public Task AdviseAsync(int serverHandle, int itemHandle, CancellationToken ct = default);
public Task UnAdviseAsync(int serverHandle, int itemHandle, CancellationToken ct = default);
public Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(int serverHandle, IReadOnlyList<string> tagAddresses, CancellationToken ct = default);
public Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(int serverHandle, IReadOnlyList<int> itemHandles, CancellationToken ct = default);
public Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(int serverHandle, IReadOnlyList<int> itemHandles, CancellationToken ct = default);
public Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(int serverHandle, IReadOnlyList<int> itemHandles, CancellationToken ct = default);
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(int serverHandle, IReadOnlyList<string> tagAddresses, CancellationToken ct = default);
public Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(int serverHandle, IReadOnlyList<int> itemHandles, CancellationToken ct = default);
public Task WriteAsync(int serverHandle, int itemHandle, MxValue value, int userId, CancellationToken ct = default);
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken ct = default);
public Task CloseAsync(CancellationToken ct = default);
@@ -101,16 +107,42 @@ public sealed class MxGatewayClientOptions
public required string ApiKey { get; init; }
public bool UseTls { get; init; }
public string? CaCertificatePath { get; init; }
public bool RequireCertificateValidation { get; init; }
public string? ServerNameOverride { get; init; }
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
public MxGatewayClientRetryOptions Retry { get; init; } = new();
public ILoggerFactory? LoggerFactory { get; init; }
}
```
The .NET client applies a bounded Polly retry policy only to idempotent calls:
`CloseSession` and diagnostic `Invoke` commands such as `Ping`,
`GetSessionState`, and `GetWorkerInfo`. It does not retry `OpenSession`, event
streams, writes, secured writes, authentication, registration, item management,
or subscription changes because those calls can partially succeed in MXAccess.
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
library constructor unless a helper explicitly says it does that.
### TLS trust posture
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). To make that usable, TLS is **lenient by default**: when `UseTls` is set
and `CaCertificatePath` is empty, `CreateHttpHandler` installs a
`RemoteCertificateValidationCallback` that returns `true`, so the gateway's
self-signed certificate is accepted without verification.
To verify the gateway instead:
- set `CaCertificatePath` to pin a CA — validated via a `CustomRootTrust`
`X509Chain` against that root, and the callback additionally rejects a
hostname/SAN mismatch (`RemoteCertificateNameMismatch`); or
- set `RequireCertificateValidation` to `true` to keep the default OS/system-trust
verification on a connection with no pinned CA.
Pinning a CA always wins over the lenient default.
## Auth Interceptor
Use a gRPC call credentials/interceptor layer to attach:
@@ -153,7 +185,7 @@ reply.EnsureMxAccessSuccess();
## Test CLI
Project: `MxGateway.Client.Cli`.
Project: `ZB.MOM.WW.MxGateway.Client.Cli`.
Command examples:
@@ -198,3 +230,10 @@ MXGATEWAY_TEST_ITEM=<item>
Integration smoke should open, register, add, advise, stream for bounded time,
and close.
## Related Documentation
- [Client Libraries Detailed Design](../../docs/ClientLibrariesDesign.md)
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
- [Client Packaging](../../docs/ClientPackaging.md)
- [C# Style Guide](../../docs/style-guides/CSharpStyleGuide.md)
@@ -1,48 +0,0 @@
using MxGateway.Client;
namespace MxGateway.Client.Cli;
public static class MxGatewayClientCli
{
public static int Run(
string[] args,
TextWriter standardOutput,
TextWriter standardError)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(standardOutput);
ArgumentNullException.ThrowIfNull(standardError);
if (args.Length is 0 || IsHelp(args[0]))
{
WriteUsage(standardOutput);
return 0;
}
if (string.Equals(args[0], "version", StringComparison.OrdinalIgnoreCase))
{
standardOutput.WriteLine(
$"gateway-protocol={MxGatewayClientContractInfo.GatewayProtocolVersion}");
standardOutput.WriteLine(
$"worker-protocol={MxGatewayClientContractInfo.WorkerProtocolVersion}");
return 0;
}
standardError.WriteLine($"Unknown command '{args[0]}'.");
WriteUsage(standardError);
return 2;
}
private static bool IsHelp(string value)
{
return string.Equals(value, "-h", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "--help", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "help", StringComparison.OrdinalIgnoreCase);
}
private static void WriteUsage(TextWriter writer)
{
writer.WriteLine("mxgw-dotnet version");
writer.WriteLine("mxgw-dotnet --help");
}
}
@@ -1,3 +0,0 @@
using MxGateway.Client.Cli;
return MxGatewayClientCli.Run(args, Console.Out, Console.Error);
@@ -1,20 +0,0 @@
using MxGateway.Client.Cli;
namespace MxGateway.Client.Tests;
public sealed class MxGatewayClientCliTests
{
[Fact]
public void Run_Version_PrintsCompiledProtocolVersions()
{
using var output = new StringWriter();
using var error = new StringWriter();
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
Assert.Equal(0, exitCode);
Assert.Contains("gateway-protocol=1", output.ToString());
Assert.Contains("worker-protocol=1", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
}
@@ -1,28 +0,0 @@
namespace MxGateway.Client.Tests;
public sealed class MxGatewayClientOptionsTests
{
[Fact]
public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds()
{
var options = new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
};
options.Validate();
}
[Fact]
public void Validate_WithEmptyApiKey_Throws()
{
var options = new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "",
};
Assert.Throws<ArgumentException>(options.Validate);
}
}
-76
View File
@@ -1,76 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client", "MxGateway.Client\MxGateway.Client.csproj", "{7CF9ED88-1F32-4040-BEB1-D0902E304C70}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj", "{9AB807A8-0469-40F7-A000-D240F36B6E5D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Cli", "MxGateway.Client.Cli\MxGateway.Client.Cli.csproj", "{EB061E77-2475-4322-9257-3F2456DD141C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Tests", "MxGateway.Client.Tests\MxGateway.Client.Tests.csproj", "{B77B5A8E-0C53-4419-9BCD-227C9753A074}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.ActiveCfg = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.Build.0 = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.ActiveCfg = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.Build.0 = Debug|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.Build.0 = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.ActiveCfg = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.Build.0 = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.ActiveCfg = Release|Any CPU
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.Build.0 = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.ActiveCfg = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.Build.0 = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.ActiveCfg = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.Build.0 = Debug|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.Build.0 = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.ActiveCfg = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.Build.0 = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.ActiveCfg = Release|Any CPU
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.Build.0 = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.ActiveCfg = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.Build.0 = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.ActiveCfg = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.Build.0 = Debug|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.Build.0 = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.ActiveCfg = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.Build.0 = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.ActiveCfg = Release|Any CPU
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.Build.0 = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.ActiveCfg = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.Build.0 = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.ActiveCfg = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.Build.0 = Debug|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.Build.0 = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.ActiveCfg = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.Build.0 = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.ActiveCfg = Release|Any CPU
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,41 +0,0 @@
using Grpc.Net.Client;
using MxGateway.Contracts.Proto;
namespace MxGateway.Client;
/// <summary>
/// Provides the initial .NET client entry point and raw generated gRPC client.
/// </summary>
public sealed class MxGatewayClient : IAsyncDisposable
{
private readonly GrpcChannel _channel;
private MxGatewayClient(GrpcChannel channel)
{
_channel = channel;
RawClient = new MxAccessGateway.MxAccessGatewayClient(channel);
}
public MxAccessGateway.MxAccessGatewayClient RawClient { get; }
public static MxGatewayClient Create(MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
var channel = GrpcChannel.ForAddress(
options.Endpoint,
new GrpcChannelOptions
{
LoggerFactory = options.LoggerFactory,
});
return new MxGatewayClient(channel);
}
public ValueTask DisposeAsync()
{
_channel.Dispose();
return ValueTask.CompletedTask;
}
}
@@ -1,58 +0,0 @@
using Microsoft.Extensions.Logging;
namespace MxGateway.Client;
/// <summary>
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
/// </summary>
public sealed class MxGatewayClientOptions
{
public required Uri Endpoint { get; init; }
public required string ApiKey { get; init; }
public bool UseTls { get; init; }
public string? CaCertificatePath { get; init; }
public string? ServerNameOverride { get; init; }
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
public ILoggerFactory? LoggerFactory { get; init; }
public void Validate()
{
ArgumentNullException.ThrowIfNull(Endpoint);
if (!Endpoint.IsAbsoluteUri)
{
throw new ArgumentException(
"The gateway endpoint must be an absolute URI.",
nameof(Endpoint));
}
if (string.IsNullOrWhiteSpace(ApiKey))
{
throw new ArgumentException(
"The gateway API key must not be empty.",
nameof(ApiKey));
}
if (ConnectTimeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(ConnectTimeout),
"The connect timeout must be greater than zero.");
}
if (DefaultCallTimeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(DefaultCallTimeout),
"The default call timeout must be greater than zero.");
}
}
}
+322 -6
View File
@@ -7,11 +7,11 @@ CLI, and unit tests.
| Project | Purpose |
|---------|---------|
| `MxGateway.Client` | .NET 10 library entry point and raw gRPC client access. |
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
| `MxGateway.Client.Tests` | Unit tests for the scaffold and generated contract wiring. |
| `ZB.MOM.WW.MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
| `ZB.MOM.WW.MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
| `ZB.MOM.WW.MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so
The projects reference `src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` so
the client compiles against the same generated protobuf and gRPC types as the
gateway. `clients/dotnet/generated` remains reserved for generator output if a
future client build switches to client-local `Grpc.Tools` generation.
@@ -19,6 +19,322 @@ future client build switches to client-local `Grpc.Tools` generation.
## Build And Test
```powershell
dotnet build clients/dotnet/MxGateway.Client.sln
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
```
## Packaging
Create local library and CLI artifacts from the repository root:
```powershell
$dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet'
dotnet pack clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput"
dotnet publish clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet
```
The library package references the shared contracts project at build time. The
published CLI runs from `artifacts/clients/dotnet/mxgw-dotnet`.
## Regenerating Protobuf Bindings
The .NET client uses the generated C# types from
`src/ZB.MOM.WW.MxGateway.Contracts/Generated`. Regenerate those files through the
contracts project:
```powershell
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
```
## Client Usage
`MxGatewayClient` opens a gRPC channel to the gateway and attaches the API key
to every unary and streaming call as `authorization: Bearer <api-key>`.
Cancellation tokens passed to the public methods flow to the generated gRPC
call. Client-side cancellation stops waiting for the gateway response; it does
not abort an MXAccess COM call that is already executing inside a worker.
```csharp
await using MxGatewayClient client = MxGatewayClient.Create(
new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = apiKey,
});
MxGatewaySession session = await client.OpenSessionAsync();
try
{
int serverHandle = await session.RegisterAsync("sample-client");
int itemHandle = await session.AddItemAsync(
serverHandle,
"Area001.Pump001.Speed");
await session.AdviseAsync(serverHandle, itemHandle);
}
finally
{
await session.CloseAsync();
}
```
Use `OpenSessionRawAsync`, `CloseSessionRawAsync`, `InvokeAsync`, and
`StreamEventsAsync` when tests or parity tools need direct generated protobuf
messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
available, and command helpers have `*RawAsync` variants when callers need the
complete `MxCommandReply`.
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds),
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
reference, optional comment, ack target). All three accept a cancellation
token and pass through the `MxGateway:Alarms` configuration on the
server — when alarms are disabled, the gateway returns an empty list / empty
stream rather than failing.
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
the first `CloseSessionReply` instead of sending another close request.
## Values, Status, And Errors
The client provides extension helpers for generated protobuf values. Use
`ToMxValue()` on .NET scalar values and typed arrays to create `MxValue`
instances for `Write` and `Write2`. Use `ToClrValue()` and
`GetProjectionKind()` when test or diagnostic code needs to inspect generated
`MxValue` replies while preserving `rawDiagnostic`, raw data type fields, and
raw byte payloads.
`MxStatusProxy.IsSuccess()` and `ToDiagnosticSummary()` expose MXAccess status
arrays without collapsing them into a single gateway success flag. Command
reply helpers follow the same split:
```csharp
reply.EnsureProtocolSuccess();
reply.EnsureMxAccessSuccess();
```
`EnsureProtocolSuccess()` raises gateway, session, worker, or command
exceptions for gateway-level failures. It leaves
`PROTOCOL_STATUS_CODE_MXACCESS_FAILURE` to `EnsureMxAccessSuccess()` so callers
can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess
itself rejects a command. `MxAccessException.Reply` contains the raw generated
reply.
## CLI Usage
The test CLI supports deterministic JSON output for automation:
```powershell
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- version --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- register --session-id <id> --client-name mxgw-dotnet-cli --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
```
`smoke` opens a session, registers a client, adds one item, advises it,
optionally writes a value when `--type` and `--value` are supplied, reads a
bounded event stream, and closes the session in a `finally` block. CLI error
output redacts API keys supplied through `--api-key`.
## Galaxy Repository Browse
`GalaxyRepositoryClient` is a separate read-only wrapper around the
`GalaxyRepository` gRPC service exposed by the same gateway. It shares the API
key auth interceptor with `MxGatewayClient` and requires the `metadata:read`
scope server-side. Use it to probe the ZB SQL connection, watch
`time_of_last_deploy` for redeployments, and enumerate the deployed Galaxy
object hierarchy plus each object's dynamic attributes.
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = apiKey,
});
bool ok = await repository.TestConnectionAsync();
DateTime? lastDeploy = await repository.GetLastDeployTimeAsync();
IReadOnlyList<GalaxyObject> objects = await repository.DiscoverHierarchyAsync();
foreach (GalaxyObject galaxyObject in objects)
{
Console.WriteLine($"{galaxyObject.TagName} ({galaxyObject.ContainedName})");
foreach (GalaxyAttribute attribute in galaxyObject.Attributes)
{
Console.WriteLine($" {attribute.AttributeName} -> {attribute.FullTagReference}");
}
}
```
Use `DiscoverHierarchyOptions` to request a server-side slice without pulling
the full Galaxy:
```csharp
IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
new DiscoverHierarchyOptions
{
RootContainedPath = "Area1/Line3",
TagNameGlob = "Pump_*",
IncludeAttributes = false,
});
```
The CLI exposes the same operations:
```powershell
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
```
### Browsing lazily
For UI trees or OPC UA bridges, use `BrowseChildrenAsync` to walk one level at a
time instead of paging the full hierarchy. Pass an empty request for root objects;
subsequent calls supply `ParentGobjectId`, `ParentTagName`, or
`ParentContainedPath`. Each child's `ChildHasChildren[i]` tells you whether to
draw an expand triangle. Filter fields match `DiscoverHierarchy`. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```csharp
BrowseChildrenReply roots = await repository.BrowseChildrenAsync(
new BrowseChildrenRequest());
for (int i = 0; i < roots.Children.Count; i++)
{
GalaxyObject child = roots.Children[i];
bool hasChildren = roots.ChildHasChildren[i];
Console.WriteLine($"{child.TagName} expand={hasChildren}");
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey });
IReadOnlyList<LazyBrowseNode> roots = await repository.BrowseAsync();
foreach (LazyBrowseNode root in roots)
{
if (root.HasChildrenHint)
{
await root.ExpandAsync();
}
foreach (LazyBrowseNode child in root.Children)
{
Console.WriteLine($"{child.Object.TagName} ({(child.HasChildrenHint ? "has children" : "leaf")})");
}
}
```
`ExpandAsync` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`BrowseAsync` again from the root.
### Watching deploy events
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
server emits a bootstrap event with the current state on subscribe, then one
event per new `time_of_last_deploy`. Pass a `lastSeenDeployTime` to suppress the
bootstrap when the caller already holds the current deploy time. Use the
monotonic `Sequence` field to detect dropped events: gaps mean the
per-subscriber server-side buffer overflowed and the caller should reconcile.
Streaming RPCs are not wrapped by the unary safe-read retry pipeline. The
caller is responsible for reopening the stream on transient failures.
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(options);
DateTimeOffset? lastSeen = null;
await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
lastSeen,
cancellationToken))
{
Console.WriteLine(
$"seq={evt.Sequence} objects={evt.ObjectCount} attributes={evt.AttributeCount}");
if (evt.TimeOfLastDeployPresent && evt.TimeOfLastDeploy is not null)
{
lastSeen = evt.TimeOfLastDeploy.ToDateTimeOffset();
}
}
```
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
```powershell
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
```
Use TLS options for a secured gateway:
```powershell
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
```
### TLS trust
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`UseTls` / `--tls`) with
no pinned CA accepts whatever certificate the gateway presents. To verify
instead, pin a CA with `CaCertificatePath` / `--ca-file` (this path also enforces
the certificate hostname/SAN match), or set `RequireCertificateValidation` to
force OS/system-trust verification without pinning. Use `ServerNameOverride` /
`--server-name` when the dialed host differs from the certificate SAN. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
```powershell
$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'http://localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
```
## Installing as a NuGet Package
The client publishes to the internal Gitea NuGet feed at
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`.
Add the feed once:
````bash
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
--name dohertj2-gitea \
--username <gitea-username> \
--password <gitea-token-or-password> \
--store-password-in-clear-text
````
Then add the package to your project:
````bash
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.0
````
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
- [.NET Client Detailed Design](./DotnetClientDesign.md)
@@ -0,0 +1,138 @@
using System.Globalization;
namespace ZB.MOM.WW.MxGateway.Client.Cli;
/// <summary>Parses command-line arguments into flags and named values.</summary>
internal sealed class CliArguments
{
private readonly Dictionary<string, string> _values = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _flags = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Initializes a new instance by parsing the given command-line arguments.</summary>
/// <param name="args">Unparsed command-line arguments; flags prefixed with '--' and values follow their flag.</param>
public CliArguments(IEnumerable<string> args)
{
string? pendingName = null;
foreach (string arg in args)
{
if (arg.StartsWith("--", StringComparison.Ordinal))
{
if (pendingName is not null)
{
_flags.Add(pendingName);
}
pendingName = arg[2..];
continue;
}
if (pendingName is null)
{
throw new ArgumentException($"Unexpected argument '{arg}'.");
}
_values[pendingName] = arg;
pendingName = null;
}
if (pendingName is not null)
{
_flags.Add(pendingName);
}
}
/// <summary>Returns whether the named flag was present in the arguments.</summary>
/// <param name="name">The flag name (without '--' prefix).</param>
public bool HasFlag(string name)
{
return _flags.Contains(name);
}
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
public string? GetOptional(string name)
{
return _values.TryGetValue(name, out string? value)
? value
: null;
}
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
public string GetRequired(string name)
{
string? value = GetOptional(name);
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"Missing required option --{name}.");
}
return value;
}
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
public int GetInt32(string name, int? defaultValue = null)
{
string? value = GetOptional(name);
if (string.IsNullOrWhiteSpace(value))
{
if (defaultValue.HasValue)
{
return defaultValue.Value;
}
throw new ArgumentException($"Missing required option --{name}.");
}
return int.Parse(value, CultureInfo.InvariantCulture);
}
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param>
public uint GetUInt32(string name, uint defaultValue)
{
string? value = GetOptional(name);
return string.IsNullOrWhiteSpace(value)
? defaultValue
: uint.Parse(value, CultureInfo.InvariantCulture);
}
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param>
public ulong GetUInt64(string name, ulong defaultValue)
{
string? value = GetOptional(name);
return string.IsNullOrWhiteSpace(value)
? defaultValue
: ulong.Parse(value, CultureInfo.InvariantCulture);
}
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param>
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
{
string? value = GetOptional(name);
if (string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}
if (value.EndsWith("ms", StringComparison.OrdinalIgnoreCase))
{
return TimeSpan.FromMilliseconds(double.Parse(value[..^2], CultureInfo.InvariantCulture));
}
if (value.EndsWith('s'))
{
return TimeSpan.FromSeconds(double.Parse(value[..^1], CultureInfo.InvariantCulture));
}
return TimeSpan.Parse(value, CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,108 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Cli;
public interface IMxGatewayCliClient : IAsyncDisposable
{
/// <summary>
/// Opens a new gateway session.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The session open reply.</returns>
Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Closes an open gateway session.
/// </summary>
/// <param name="request">Session close request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The session close reply.</returns>
Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Invokes an MXAccess command on the session.
/// </summary>
/// <param name="request">The command request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The command reply.</returns>
Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Streams events from the gateway session.
/// </summary>
/// <param name="request">The stream events request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of events.</returns>
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Acknowledges an active MXAccess alarm condition through the gateway.
/// </summary>
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Attaches to the gateway's central alarm feed — the current active-alarm
/// snapshot followed by live transitions.
/// </summary>
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of alarm feed messages.</returns>
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Tests connection to the Galaxy Repository.
/// </summary>
/// <param name="request">The connection test request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The connection test reply.</returns>
Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Gets the last deployment time from the Galaxy Repository.
/// </summary>
/// <param name="request">The last deploy time request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The last deploy time reply.</returns>
Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Discovers the Galaxy Repository hierarchy.
/// </summary>
/// <param name="request">The discover hierarchy request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The discover hierarchy reply.</returns>
Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Watches for deployment events from the Galaxy Repository.
/// </summary>
/// <param name="request">The watch deploy events request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of deployment events.</returns>
IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,113 @@
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Cli;
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
{
private readonly MxGatewayClient _client;
private readonly Lazy<GalaxyRepositoryClient> _galaxyClient;
/// <summary>
/// Initializes a new instance of the <see cref="MxGatewayCliClientAdapter"/> that bridges the CLI to the gateway client.
/// </summary>
/// <param name="client">The gateway client to adapt.</param>
public MxGatewayCliClientAdapter(MxGatewayClient client)
{
_client = client;
_galaxyClient = new Lazy<GalaxyRepositoryClient>(
() => GalaxyRepositoryClient.Create(_client.Options));
}
/// <inheritdoc />
public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CancellationToken cancellationToken)
{
return _client.OpenSessionRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CancellationToken cancellationToken)
{
return _client.CloseSessionRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken)
{
return _client.InvokeAsync(request, cancellationToken);
}
/// <inheritdoc />
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken)
{
return _client.StreamEventsAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken)
{
return _client.AcknowledgeAlarmAsync(request, cancellationToken);
}
/// <inheritdoc />
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CancellationToken cancellationToken)
{
return _client.StreamAlarmsAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.TestConnectionRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.GetLastDeployTimeRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.DiscoverHierarchyRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_galaxyClient.IsValueCreated)
{
await _galaxyClient.Value.DisposeAsync().ConfigureAwait(false);
}
await _client.DisposeAsync().ConfigureAwait(false);
}
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.MxGateway.Client.Cli;
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
internal static class MxGatewayCliSecretRedactor
{
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
/// <param name="value">The message text to redact.</param>
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
public static string Redact(string value, string? apiKey)
{
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
{
return value;
}
return value.Replace(apiKey, "[redacted]", StringComparison.Ordinal);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
using ZB.MOM.WW.MxGateway.Client.Cli;
return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error);
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
</ItemGroup>
<PropertyGroup>
@@ -0,0 +1,34 @@
using Grpc.Core;
using Grpc.Net.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Live smoke tests for the BrowseChildren RPC. Skipped by default; set
/// MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to run against a real gateway.
/// </summary>
public sealed class BrowseChildrenSmokeTests
{
/// <summary>
/// Verifies that BrowseChildren returns a non-zero cache sequence and
/// a consistent children/child-has-children count from a live gateway.
/// </summary>
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
{
string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
Assert.False(string.IsNullOrEmpty(apiKey), "MXGATEWAY_API_KEY must be set.");
using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
GalaxyRepository.GalaxyRepositoryClient client = new(channel);
Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);
Assert.True(reply.CacheSequence > 0UL);
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
}
}
@@ -0,0 +1,207 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Fake Galaxy Repository client transport for testing.
/// </summary>
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
{
/// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options;
/// <summary>
/// Gets the raw gRPC client; always null for the fake.
/// </summary>
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
/// <summary>
/// Gets the list of TestConnection RPC calls made by the client.
/// </summary>
public List<(TestConnectionRequest Request, CallOptions CallOptions)> TestConnectionCalls { get; } = [];
/// <summary>
/// Gets the list of GetLastDeployTime RPC calls made by the client.
/// </summary>
public List<(GetLastDeployTimeRequest Request, CallOptions CallOptions)> GetLastDeployTimeCalls { get; } = [];
/// <summary>
/// Gets the list of DiscoverHierarchy RPC calls made by the client.
/// </summary>
public List<(DiscoverHierarchyRequest Request, CallOptions CallOptions)> DiscoverHierarchyCalls { get; } = [];
/// <summary>
/// Gets or sets the reply to return from TestConnection; defaults to successful response.
/// </summary>
public TestConnectionReply TestConnectionReply { get; set; } = new() { Ok = true };
/// <summary>
/// Gets or sets the reply to return from GetLastDeployTime; defaults to no deploy time present.
/// </summary>
public GetLastDeployTimeReply GetLastDeployTimeReply { get; set; } = new() { Present = false };
/// <summary>
/// Gets or sets the reply to return from DiscoverHierarchy; defaults to empty response.
/// </summary>
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
/// <summary>Gets the queue of discover hierarchy replies; dequeued in FIFO order.</summary>
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from TestConnection; dequeued in FIFO order.
/// </summary>
public Queue<Exception> TestConnectionExceptions { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from GetLastDeployTime; dequeued in FIFO order.
/// </summary>
public Queue<Exception> GetLastDeployTimeExceptions { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from DiscoverHierarchy; dequeued in FIFO order.
/// </summary>
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The TestConnectionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions)
{
TestConnectionCalls.Add((request, callOptions));
if (TestConnectionExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(TestConnectionReply);
}
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The GetLastDeployTimeRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions)
{
GetLastDeployTimeCalls.Add((request, callOptions));
if (GetLastDeployTimeExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(GetLastDeployTimeReply);
}
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The DiscoverHierarchyRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions)
{
DiscoverHierarchyCalls.Add((request, callOptions));
if (DiscoverHierarchyExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(
DiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
? reply
: DiscoverHierarchyReply);
}
/// <summary>Records BrowseChildren RPC calls made by the client.</summary>
public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = [];
/// <summary>Default reply returned from BrowseChildren when the queue is empty.</summary>
public BrowseChildrenReply BrowseChildrenReply { get; set; } = new();
/// <summary>Queue of replies returned from BrowseChildren; dequeued in FIFO order.</summary>
public Queue<BrowseChildrenReply> BrowseChildrenReplies { get; } = new();
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The BrowseChildrenRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions)
{
BrowseChildrenCalls.Add((request, callOptions));
if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
{
return Task.FromException<BrowseChildrenReply>(exception);
}
return Task.FromResult(
BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)
? reply
: BrowseChildrenReply);
}
/// <summary>
/// Gets the list of WatchDeployEvents RPC calls made by the client.
/// </summary>
public List<(WatchDeployEventsRequest Request, CallOptions CallOptions)> WatchDeployEventsCalls { get; } = [];
/// <summary>
/// Gets or sets the list of events to stream from WatchDeployEvents.
/// </summary>
public List<DeployEvent> WatchDeployEvents { get; } = [];
/// <summary>
/// Gets or sets the exception to throw from WatchDeployEvents, if any.
/// </summary>
public Exception? WatchDeployEventsException { get; set; }
/// <summary>
/// When set, awaited before each event yield so tests can observe cancellation
/// mid-stream. Receives the call's cancellation token.
/// </summary>
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
/// <summary>
/// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event.
/// </summary>
/// <param name="request">The WatchDeployEventsRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions)
{
WatchDeployEventsCalls.Add((request, callOptions));
if (WatchDeployEventsException is not null)
{
throw WatchDeployEventsException;
}
foreach (DeployEvent deployEvent in WatchDeployEvents)
{
if (WatchDeployEventsBeforeYield is not null)
{
await WatchDeployEventsBeforeYield(callOptions.CancellationToken).ConfigureAwait(false);
}
callOptions.CancellationToken.ThrowIfCancellationRequested();
yield return deployEvent;
}
}
}
@@ -0,0 +1,279 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Fake implementation of IMxGatewayClientTransport for testing.
/// </summary>
internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMxGatewayClientTransport
{
private readonly Queue<MxCommandReply> _invokeReplies = new();
private readonly List<MxEvent> _events = [];
/// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options;
/// <summary>
/// Gets null, since this is a test fake without a real gRPC client.
/// </summary>
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
/// <summary>
/// Gets the list of captured OpenSessionAsync calls.
/// </summary>
public List<(OpenSessionRequest Request, CallOptions CallOptions)> OpenSessionCalls { get; } = [];
/// <summary>
/// Gets the list of captured CloseSessionAsync calls.
/// </summary>
public List<(CloseSessionRequest Request, CallOptions CallOptions)> CloseSessionCalls { get; } = [];
/// <summary>
/// Gets the list of captured InvokeAsync calls.
/// </summary>
public List<(MxCommandRequest Request, CallOptions CallOptions)> InvokeCalls { get; } = [];
/// <summary>
/// Gets the list of captured StreamEventsAsync calls.
/// </summary>
public List<(StreamEventsRequest Request, CallOptions CallOptions)> StreamEventsCalls { get; } = [];
/// <summary>
/// Gets the list of captured AcknowledgeAlarmAsync calls.
/// </summary>
public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = [];
/// <summary>
/// Gets the list of captured QueryActiveAlarmsAsync calls.
/// </summary>
public List<(QueryActiveAlarmsRequest Request, CallOptions CallOptions)> QueryActiveAlarmsCalls { get; } = [];
/// <summary>
/// Gets the list of captured StreamAlarmsAsync calls.
/// </summary>
public List<(StreamAlarmsRequest Request, CallOptions CallOptions)> StreamAlarmsCalls { get; } = [];
/// <summary>
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
/// </summary>
public Queue<Exception> AcknowledgeAlarmExceptions { get; } = new();
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
private readonly List<AlarmFeedMessage> _alarmFeedMessages = [];
/// <summary>
/// Gets or sets the reply to return from OpenSessionAsync.
/// </summary>
public OpenSessionReply OpenSessionReply { get; set; } = new()
{
SessionId = "session-fixture",
BackendName = "mxaccess-worker",
GatewayProtocolVersion = 1,
WorkerProtocolVersion = 1,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
};
/// <summary>
/// Gets or sets the reply to return from CloseSessionAsync.
/// </summary>
public CloseSessionReply CloseSessionReply { get; set; } = new()
{
SessionId = "session-fixture",
FinalState = SessionState.Closed,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
};
/// <summary>
/// Gets the queue of exceptions to throw from OpenSessionAsync.
/// </summary>
public Queue<Exception> OpenSessionExceptions { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from CloseSessionAsync.
/// </summary>
public Queue<Exception> CloseSessionExceptions { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from InvokeAsync.
/// </summary>
public Queue<Exception> InvokeExceptions { get; } = new();
/// <summary>
/// Verifies that the OpenSessionAsync call is recorded and returns the configured reply.
/// </summary>
/// <param name="request">The OpenSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CallOptions callOptions)
{
OpenSessionCalls.Add((request, callOptions));
if (OpenSessionExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(OpenSessionReply);
}
/// <summary>
/// Verifies that the CloseSessionAsync call is recorded and returns the configured reply.
/// </summary>
/// <param name="request">The CloseSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CallOptions callOptions)
{
CloseSessionCalls.Add((request, callOptions));
if (CloseSessionExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(CloseSessionReply);
}
/// <summary>
/// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply.
/// </summary>
/// <param name="request">The MxCommandRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CallOptions callOptions)
{
InvokeCalls.Add((request, callOptions));
if (InvokeExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(_invokeReplies.Dequeue());
}
/// <summary>
/// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events.
/// </summary>
/// <param name="request">The StreamEventsRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CallOptions callOptions)
{
StreamEventsCalls.Add((request, callOptions));
foreach (MxEvent gatewayEvent in _events)
{
callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return gatewayEvent;
}
}
/// <summary>
/// Enqueues a reply to be returned from the next InvokeAsync call.
/// </summary>
/// <param name="reply">The reply to enqueue.</param>
public void AddInvokeReply(MxCommandReply reply)
{
_invokeReplies.Enqueue(reply);
}
/// <summary>
/// Enqueues an event to be yielded from StreamEventsAsync.
/// </summary>
/// <param name="gatewayEvent">The event to enqueue.</param>
public void AddEvent(MxEvent gatewayEvent)
{
_events.Add(gatewayEvent);
}
/// <summary>
/// Records the acknowledge call and returns the next enqueued reply (or default).
/// </summary>
/// <param name="request">The acknowledge alarm request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CallOptions callOptions)
{
AcknowledgeAlarmCalls.Add((request, callOptions));
if (AcknowledgeAlarmExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(_acknowledgeReplies.Count > 0
? _acknowledgeReplies.Dequeue()
: new AcknowledgeAlarmReply
{
CorrelationId = request.ClientCorrelationId,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok },
});
}
/// <summary>
/// Records the query call and yields each enqueued snapshot.
/// </summary>
/// <param name="request">The query active alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions)
{
QueryActiveAlarmsCalls.Add((request, callOptions));
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
{
callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return snapshot;
}
}
/// <summary>Enqueues an acknowledge reply.</summary>
/// <param name="reply">The acknowledge reply to enqueue.</param>
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
{
_acknowledgeReplies.Enqueue(reply);
}
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
/// <param name="snapshot">The snapshot to enqueue.</param>
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
{
_activeAlarmSnapshots.Add(snapshot);
}
/// <summary>
/// Records the stream-alarms call and yields each enqueued feed message.
/// </summary>
/// <param name="request">The stream alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions)
{
StreamAlarmsCalls.Add((request, callOptions));
foreach (AlarmFeedMessage message in _alarmFeedMessages)
{
callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return message;
}
}
/// <summary>Enqueues an alarm feed message to be yielded from StreamAlarmsAsync.</summary>
/// <param name="message">The alarm feed message to enqueue.</param>
public void AddAlarmFeedMessage(AlarmFeedMessage message)
{
_alarmFeedMessages.Add(message);
}
}
@@ -0,0 +1,416 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class GalaxyRepositoryClientTests
{
/// <summary>
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
/// </summary>
[Fact]
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
{
using CancellationTokenSource cancellation = new();
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync(cancellation.Token);
Assert.True(ok);
var call = Assert.Single(transport.TestConnectionCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
}
/// <summary>
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
/// </summary>
[Fact]
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionReply = new TestConnectionReply { Ok = false };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync();
Assert.False(ok);
}
/// <summary>
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
/// </summary>
[Fact]
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply { Present = false };
await using GalaxyRepositoryClient client = CreateClient(transport);
DateTime? deployTime = await client.GetLastDeployTimeAsync();
Assert.Null(deployTime);
Assert.Single(transport.GetLastDeployTimeCalls);
}
/// <summary>
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
/// </summary>
[Fact]
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
{
DateTime expected = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply
{
Present = true,
TimeOfLastDeploy = Timestamp.FromDateTime(expected),
};
await using GalaxyRepositoryClient client = CreateClient(transport);
DateTime? deployTime = await client.GetLastDeployTimeAsync();
Assert.NotNull(deployTime);
Assert.Equal(expected, deployTime!.Value);
}
/// <summary>
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
/// </summary>
[Fact]
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "page-2",
TotalObjectCount = 2,
Objects =
{
new GalaxyObject
{
GobjectId = 12,
TagName = "DelmiaReceiver_001",
ContainedName = "DelmiaReceiver",
BrowseName = "TestMachine_001/DelmiaReceiver",
ParentGobjectId = 5,
Attributes =
{
new GalaxyAttribute
{
AttributeName = "DownloadPath",
FullTagReference = "DelmiaReceiver_001.DownloadPath",
MxDataType = 8,
DataTypeName = "MxString",
},
},
},
},
});
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
TotalObjectCount = 2,
Objects =
{
new GalaxyObject
{
GobjectId = 13,
TagName = "DelmiaReceiver_002",
},
},
});
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<GalaxyObject> objects = await client.DiscoverHierarchyAsync();
Assert.Equal(2, objects.Count);
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
Assert.Equal(5000, transport.DiscoverHierarchyCalls[0].Request.PageSize);
Assert.Equal("", transport.DiscoverHierarchyCalls[0].Request.PageToken);
Assert.Equal("page-2", transport.DiscoverHierarchyCalls[1].Request.PageToken);
GalaxyObject obj = objects[0];
Assert.Equal(12, obj.GobjectId);
Assert.Equal("DelmiaReceiver_001", obj.TagName);
GalaxyAttribute attribute = Assert.Single(obj.Attributes);
Assert.Equal("DownloadPath", attribute.AttributeName);
Assert.Equal("DelmiaReceiver_001.DownloadPath", attribute.FullTagReference);
}
/// <summary>
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
/// </summary>
[Fact]
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
{
using CancellationTokenSource cancellation = new();
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync(cancellation.Token);
var call = Assert.Single(transport.DiscoverHierarchyCalls);
// The retry pipeline links the caller token with a per-call timeout token,
// so the transport sees the linked token rather than the caller's directly.
// Verify the link relationship by cancelling the caller and checking the
// call-side token reflects it.
Assert.False(call.CallOptions.CancellationToken.IsCancellationRequested);
}
/// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary>
[Fact]
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "7:1",
});
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
{
NextPageToken = "7:1",
});
await using GalaxyRepositoryClient client = CreateClient(transport);
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
async () => await client.DiscoverHierarchyAsync());
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
/// </summary>
[Fact]
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync(new DiscoverHierarchyOptions
{
RootContainedPath = "Area1/Line3",
MaxDepth = 2,
CategoryIds = [10, 13],
TemplateChainContains = ["Pump"],
TagNameGlob = "Pump_*",
IncludeAttributes = false,
AlarmBearingOnly = true,
HistorizedOnly = true,
});
DiscoverHierarchyRequest request = Assert.Single(transport.DiscoverHierarchyCalls).Request;
Assert.Equal(DiscoverHierarchyRequest.RootOneofCase.RootContainedPath, request.RootCase);
Assert.Equal("Area1/Line3", request.RootContainedPath);
Assert.Equal(2, request.MaxDepth);
Assert.Equal([10, 13], request.CategoryIds);
Assert.Equal(["Pump"], request.TemplateChainContains);
Assert.Equal("Pump_*", request.TagNameGlob);
Assert.True(request.HasIncludeAttributes);
Assert.False(request.IncludeAttributes);
Assert.True(request.AlarmBearingOnly);
Assert.True(request.HistorizedOnly);
}
/// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary>
[Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionExceptions.Enqueue(CreateTransientRpcException());
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync();
Assert.True(ok);
Assert.Equal(2, transport.TestConnectionCalls.Count);
}
/// <summary>
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
/// </summary>
[Fact]
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyExceptions.Enqueue(CreateTransientRpcException());
transport.DiscoverHierarchyReply = new DiscoverHierarchyReply();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync();
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
}
/// <summary>
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
/// </summary>
[Fact]
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
DateTime deployTime = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
transport.WatchDeployEvents.Add(new DeployEvent
{
Sequence = 1,
ObservedAt = Timestamp.FromDateTime(deployTime),
TimeOfLastDeploy = Timestamp.FromDateTime(deployTime),
TimeOfLastDeployPresent = true,
ObjectCount = 7,
AttributeCount = 42,
});
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await foreach (DeployEvent evt in client.WatchDeployEventsAsync())
{
received.Add(evt);
}
DeployEvent only = Assert.Single(received);
Assert.Equal(1ul, only.Sequence);
Assert.Equal(7, only.ObjectCount);
Assert.Equal(42, only.AttributeCount);
Assert.True(only.TimeOfLastDeployPresent);
var call = Assert.Single(transport.WatchDeployEventsCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
// No last_seen_deploy_time supplied → request leaves the field unset.
Assert.Null(call.Request.LastSeenDeployTime);
}
/// <summary>
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
/// </summary>
[Fact]
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
DateTime t0 = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
for (int index = 1; index <= 3; index++)
{
transport.WatchDeployEvents.Add(new DeployEvent
{
Sequence = (ulong)index,
ObservedAt = Timestamp.FromDateTime(t0.AddSeconds(index)),
TimeOfLastDeploy = Timestamp.FromDateTime(t0.AddSeconds(index)),
TimeOfLastDeployPresent = true,
ObjectCount = 10 + index,
AttributeCount = 100 + index,
});
}
DateTimeOffset lastSeen = new(t0, TimeSpan.Zero);
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await foreach (DeployEvent evt in client.WatchDeployEventsAsync(lastSeen))
{
received.Add(evt);
}
Assert.Equal(3, received.Count);
Assert.Equal(new ulong[] { 1, 2, 3 }, received.Select(e => e.Sequence).ToArray());
Assert.Equal(new[] { 11, 12, 13 }, received.Select(e => e.ObjectCount).ToArray());
var call = Assert.Single(transport.WatchDeployEventsCalls);
Assert.NotNull(call.Request.LastSeenDeployTime);
Assert.Equal(t0, call.Request.LastSeenDeployTime!.ToDateTime());
}
/// <summary>
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
/// </summary>
[Fact]
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
// Add many events; the test will cancel after the first.
for (int index = 1; index <= 10; index++)
{
transport.WatchDeployEvents.Add(new DeployEvent { Sequence = (ulong)index });
}
using CancellationTokenSource cancellation = new();
// Cancel before the second yield by wiring the fake's pre-yield hook.
int yields = 0;
transport.WatchDeployEventsBeforeYield = _ =>
{
yields++;
if (yields >= 2)
{
cancellation.Cancel();
}
return Task.CompletedTask;
};
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (DeployEvent evt in client
.WatchDeployEventsAsync(cancellationToken: cancellation.Token))
{
received.Add(evt);
}
});
// The first event yields before cancellation triggers on the second pass.
Assert.Single(received);
Assert.Equal(1ul, received[0].Sequence);
}
/// <summary>
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
/// </summary>
[Fact]
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
GalaxyRepositoryClient client = CreateClient(transport);
await client.DisposeAsync();
Assert.Throws<ObjectDisposedException>(() =>
client.WatchDeployEventsAsync());
}
/// <summary>
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
/// </summary>
[Fact]
public async Task TestConnectionAsync_ThrowsAfterDisposal()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
GalaxyRepositoryClient client = CreateClient(transport);
await client.DisposeAsync();
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.TestConnectionAsync());
}
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
{
return new GalaxyRepositoryClient(transport.Options, transport);
}
private static FakeGalaxyRepositoryTransport CreateTransport()
{
return new FakeGalaxyRepositoryTransport(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
private static RpcException CreateTransientRpcException()
{
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
}
}
@@ -0,0 +1,221 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Tests for the <see cref="LazyBrowseNode"/> walker over the BrowseChildren RPC.
/// </summary>
public sealed class LazyBrowseNodeTests
{
/// <summary>
/// Verifies that calling BrowseAsync with no parent returns the root nodes
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
/// </summary>
[Fact]
public async Task Browse_NoParent_ReturnsRoots()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true), BuildObject(2, "Other")],
childHasChildren: [true, false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
Assert.Equal(2, roots.Count);
Assert.Equal("Plant", roots[0].Object.TagName);
Assert.True(roots[0].HasChildrenHint);
Assert.False(roots[0].IsExpanded);
Assert.Equal("Other", roots[1].Object.TagName);
Assert.False(roots[1].HasChildrenHint);
Assert.False(roots[1].IsExpanded);
}
/// <summary>
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
/// </summary>
[Fact]
public async Task Expand_PopulatesChildrenAndMarksExpanded()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(10, "Line1")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
Assert.True(roots[0].IsExpanded);
Assert.Single(roots[0].Children);
Assert.Equal("Line1", roots[0].Children[0].Object.TagName);
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
/// </summary>
[Fact]
public async Task Expand_CalledTwice_NoSecondRpc()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(10, "Line1")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
await roots[0].ExpandAsync();
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
/// </summary>
[Fact]
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
// Queue the failure for the upcoming ExpandAsync call so it consumes
// the exception on its first RPC rather than the BrowseAsync above.
transport.BrowseChildrenExceptions.Enqueue(
new MxGatewayException(
"Parent not found",
new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
Assert.False(roots[0].IsExpanded);
Assert.Empty(roots[0].Children);
}
/// <summary>
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
/// </summary>
[Fact]
public async Task Expand_MultiPageSiblings_GathersAllPages()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
// Roots
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(7, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
// First child page (2 children) with a next token
BrowseChildrenReply childPage1 = BuildReply(
children: [BuildObject(70, "ChildA"), BuildObject(71, "ChildB")],
childHasChildren: [false, false],
cacheSequence: 1);
childPage1.NextPageToken = "7:abc:2";
transport.BrowseChildrenReplies.Enqueue(childPage1);
// Second child page (1 child) with no next token
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(72, "ChildC")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
Assert.Equal(3, roots[0].Children.Count);
Assert.Equal(3, transport.BrowseChildrenCalls.Count);
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
}
/// <summary>
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
/// </summary>
[Fact]
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 7));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(2, "Mixer_001")],
childHasChildren: [false],
cacheSequence: 7));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
// Fire ten concurrent expands of the same node.
Task[] tasks = Enumerable.Range(0, 10)
.Select(_ => roots[0].ExpandAsync())
.ToArray();
await Task.WhenAll(tasks);
Assert.True(roots[0].IsExpanded);
Assert.Single(roots[0].Children);
// 1 roots fetch + exactly 1 expand fetch = 2 total
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
/// </summary>
[Fact]
public async Task Browse_WithFilter_ForwardsToRequest()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.BrowseAsync(new BrowseChildrenOptions
{
TagNameGlob = "Mixer*",
AlarmBearingOnly = true,
});
BrowseChildrenRequest request = Assert.Single(transport.BrowseChildrenCalls).Request;
Assert.Equal("Mixer*", request.TagNameGlob);
Assert.True(request.AlarmBearingOnly);
}
private static GalaxyObject BuildObject(int id, string tag, bool isArea = false)
=> new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea };
private static BrowseChildrenReply BuildReply(
IReadOnlyList<GalaxyObject> children,
IReadOnlyList<bool> childHasChildren,
ulong cacheSequence)
{
BrowseChildrenReply reply = new() { TotalChildCount = children.Count, CacheSequence = cacheSequence };
reply.Children.AddRange(children);
reply.ChildHasChildren.AddRange(childHasChildren);
return reply;
}
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
=> new(transport.Options, transport);
private static FakeGalaxyRepositoryTransport CreateTransport()
=> new(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
@@ -0,0 +1,81 @@
using Google.Protobuf;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxCommandReplyExtensionsTests
{
/// <summary>Verifies that successful replies pass both protocol and MxAccess success checks.</summary>
[Fact]
public void EnsureSuccess_WithRegisterFixture_ReturnsReply()
{
MxCommandReply reply = ReadReplyFixture("register.ok.reply.json");
Assert.Same(reply, reply.EnsureProtocolSuccess());
Assert.Same(reply, reply.EnsureMxAccessSuccess());
}
/// <summary>Verifies that MxAccess failures throw with preserved HResult and status details.</summary>
[Fact]
public void EnsureMxAccessSuccess_WithFailureFixture_PreservesHResultAndStatuses()
{
MxCommandReply reply = ReadReplyFixture("write.mxaccess-failure.reply.json");
reply.EnsureProtocolSuccess();
MxAccessException exception = Assert.Throws<MxAccessException>(
reply.EnsureMxAccessSuccess);
Assert.Equal(-2147220992, exception.HResultCode);
Assert.Equal(reply.Statuses.Count, exception.Statuses.Count);
Assert.Equal(reply, exception.Reply);
Assert.Contains("0x80040200", exception.Message);
}
/// <summary>Verifies that session-not-found protocol failures throw the correct gateway exception.</summary>
[Fact]
public void EnsureProtocolSuccess_WithSessionFailure_ThrowsSessionException()
{
MxCommandReply reply = new()
{
SessionId = "session-missing",
CorrelationId = "correlation",
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.SessionNotFound,
Message = "Session was not found.",
},
};
MxGatewaySessionException exception = Assert.Throws<MxGatewaySessionException>(
reply.EnsureProtocolSuccess);
Assert.Equal("session-missing", exception.SessionId);
Assert.Equal(ProtocolStatusCode.SessionNotFound, exception.ProtocolStatus?.Code);
}
private static MxCommandReply ReadReplyFixture(string fileName)
{
DirectoryInfo directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string path = Path.Combine(
directory.FullName,
"clients",
"proto",
"fixtures",
"behavior",
"command-replies",
fileName);
if (File.Exists(path))
{
return JsonParser.Default.Parse<MxCommandReply>(File.ReadAllText(path));
}
directory = directory.Parent!;
}
throw new FileNotFoundException(fileName);
}
}
@@ -0,0 +1,194 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// PR E.2 — pins the .NET SDK surface for the new alarm RPCs:
/// <see cref="MxGatewayClient.AcknowledgeAlarmAsync"/> and
/// <see cref="MxGatewayClient.QueryActiveAlarmsAsync"/>.
/// </summary>
public sealed class MxGatewayClientAlarmsTests
{
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
[Fact]
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddAcknowledgeReply(new AcknowledgeAlarmReply
{
CorrelationId = "corr-1",
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Status = new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingLmx,
},
});
await using MxGatewayClient client = CreateClient(transport);
AcknowledgeAlarmReply reply = await client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
{
ClientCorrelationId = "corr-1",
AlarmFullReference = "Tank01.Level.HiHi",
Comment = "investigating",
OperatorUser = "alice",
});
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(MxStatusCategory.Ok, reply.Status.Category);
var call = Assert.Single(transport.AcknowledgeAlarmCalls);
Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference);
Assert.Equal("investigating", call.Request.Comment);
Assert.Equal("alice", call.Request.OperatorUser);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
}
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
[Fact]
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
{
// Acks are routed through the safe-unary retry pipeline (idempotent at the
// MxAccess level), so the transport-side cancellation token is a linked one
// rather than the caller's original. Verify cancellation by tripping the source
// and asserting the call observes it.
using CancellationTokenSource cancellation = new();
cancellation.Cancel();
FakeGatewayTransport transport = CreateTransport();
await using MxGatewayClient client = CreateClient(transport);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
client.AcknowledgeAlarmAsync(
new AcknowledgeAlarmRequest
{
AlarmFullReference = "Tank01.Level.HiHi",
Comment = string.Empty,
OperatorUser = "alice",
},
cancellation.Token));
}
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
[Fact]
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
{
FakeGatewayTransport transport = CreateTransport();
transport.AcknowledgeAlarmExceptions.Enqueue(
new RpcException(new Status(StatusCode.Unauthenticated, "expired key")));
await using MxGatewayClient client = CreateClient(transport);
// Note: the FakeGatewayTransport surfaces RpcException directly (it does not run
// through GrpcMxGatewayClientTransport's mapping); the fake's contract here is to
// pass the exception verbatim. RpcException → typed exception mapping is covered
// in the GrpcMxGatewayClientTransport-level tests; the SDK-level test pins the
// pass-through shape so a future migration to direct mapping won't silently
// change observable behaviour.
var ex = await Assert.ThrowsAsync<RpcException>(
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
{
AlarmFullReference = "Tank01.Level.HiHi",
Comment = string.Empty,
OperatorUser = "alice",
}));
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
}
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
[Fact]
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked));
await using MxGatewayClient client = CreateClient(transport);
List<ActiveAlarmSnapshot> snapshots = [];
await foreach (ActiveAlarmSnapshot snapshot in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
{
SessionId = "session-fixture",
}))
{
snapshots.Add(snapshot);
}
Assert.Equal(2, snapshots.Count);
Assert.Equal("Tank01.Level.HiHi", snapshots[0].AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
Assert.Single(transport.QueryActiveAlarmsCalls);
}
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
[Fact]
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
{
FakeGatewayTransport transport = CreateTransport();
await using MxGatewayClient client = CreateClient(transport);
await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(new QueryActiveAlarmsRequest
{
SessionId = "session-fixture",
AlarmFilterPrefix = "Tank01.",
}))
{
// no snapshots enqueued; just verifying the request passes through
}
var call = Assert.Single(transport.QueryActiveAlarmsCalls);
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
}
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
[Fact]
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank01.Level.HiHi", AlarmConditionState.Active));
transport.AddActiveAlarmSnapshot(MakeSnapshot("Tank02.Level.HiHi", AlarmConditionState.Active));
await using MxGatewayClient client = CreateClient(transport);
using CancellationTokenSource cancellation = new();
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
{
await foreach (ActiveAlarmSnapshot _ in client.QueryActiveAlarmsAsync(
new QueryActiveAlarmsRequest { SessionId = "session-fixture" },
cancellation.Token))
{
cancellation.Cancel();
}
});
}
private static ActiveAlarmSnapshot MakeSnapshot(string fullReference, AlarmConditionState state)
{
return new ActiveAlarmSnapshot
{
AlarmFullReference = fullReference,
SourceObjectReference = fullReference.Split('.')[0],
AlarmTypeName = "AnalogLimitAlarm.HiHi",
Severity = 750,
CurrentState = state,
Category = "Process",
Description = "Tank high-high level",
OriginalRaiseTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc)),
LastTransitionTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc)),
};
}
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
{
return new MxGatewayClient(transport.Options, transport);
}
private static FakeGatewayTransport CreateTransport()
{
return new FakeGatewayTransport(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,10 @@
using MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts;
namespace MxGateway.Client.Tests;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientContractInfoTests
{
/// <summary>Verifies that the client's gateway protocol version matches the shared contract definition.</summary>
[Fact]
public void GatewayProtocolVersion_MatchesSharedContract()
{
@@ -12,6 +13,7 @@ public sealed class MxGatewayClientContractInfoTests
MxGatewayClientContractInfo.GatewayProtocolVersion);
}
/// <summary>Verifies that the client's worker protocol version matches the shared contract definition.</summary>
[Fact]
public void WorkerProtocolVersion_MatchesSharedContract()
{
@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientOptionsTests
{
/// <summary>Verifies that options with valid endpoint and API key pass validation.</summary>
[Fact]
public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds()
{
var options = new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
};
options.Validate();
}
/// <summary>Verifies that empty API key causes validation to fail.</summary>
[Fact]
public void Validate_WithEmptyApiKey_Throws()
{
var options = new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "",
};
Assert.Throws<ArgumentException>(options.Validate);
}
/// <summary>Verifies that invalid retry options cause validation to fail.</summary>
[Fact]
public void Validate_WithInvalidRetryOptions_Throws()
{
var options = new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
Retry = new MxGatewayClientRetryOptions { MaxAttempts = 0 },
};
Assert.Throws<ArgumentOutOfRangeException>(options.Validate);
}
}
@@ -0,0 +1,324 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Grpc.Core;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>Tests for MxGatewaySession and client command behavior.</summary>
public sealed class MxGatewayClientSessionTests
{
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
[Fact]
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
{
using CancellationTokenSource cancellation = new();
FakeGatewayTransport transport = CreateTransport();
await using MxGatewayClient client = CreateClient(transport);
await client.OpenSessionRawAsync(new OpenSessionRequest(), cancellation.Token);
var call = Assert.Single(transport.OpenSessionCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
Assert.Equal(cancellation.Token, call.CallOptions.CancellationToken);
}
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
[Fact]
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
{
FakeGatewayTransport transport = CreateTransport();
transport.OpenSessionReply.WorkerProcessId = 1234;
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
Assert.Equal("session-fixture", session.SessionId);
Assert.Same(transport.OpenSessionReply, session.OpenSessionReply);
Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId);
}
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
[Fact]
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Register,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Register = new RegisterReply { ServerHandle = 12 },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
int serverHandle = await session.RegisterAsync("fixture-client");
Assert.Equal(12, serverHandle);
var call = Assert.Single(transport.InvokeCalls);
Assert.Equal("session-fixture", call.Request.SessionId);
Assert.False(string.IsNullOrWhiteSpace(call.Request.ClientCorrelationId));
Assert.Equal(MxCommandKind.Register, call.Request.Command.Kind);
Assert.Equal("fixture-client", call.Request.Command.Register.ClientName);
}
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
[Fact]
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.AddItem2,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
AddItem2 = new AddItem2Reply { ItemHandle = 34 },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
int itemHandle = await session.AddItem2Async(12, "Area001.Pump001.Speed", "runtime");
Assert.Equal(34, itemHandle);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.AddItem2, request.Command.Kind);
Assert.Equal(12, request.Command.AddItem2.ServerHandle);
Assert.Equal("Area001.Pump001.Speed", request.Command.AddItem2.ItemDefinition);
Assert.Equal("runtime", request.Command.AddItem2.ItemContext);
}
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
[Fact]
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Write,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxValue value = new()
{
DataType = MxDataType.Integer,
VariantType = "VT_I4",
Int32Value = 123,
};
MxCommandReply reply = await session.WriteRawAsync(12, 34, value, 56);
Assert.Equal(MxCommandKind.Write, reply.Kind);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
Assert.Equal(12, request.Command.Write.ServerHandle);
Assert.Equal(34, request.Command.Write.ItemHandle);
Assert.Same(value, request.Command.Write.Value);
Assert.Equal(56, request.Command.Write.UserId);
}
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
[Fact]
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Write2,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
MxValue value = 123.ToMxValue();
MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue();
MxCommandReply reply = await session.Write2RawAsync(12, 34, value, timestampValue, 56);
Assert.Equal(MxCommandKind.Write2, reply.Kind);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.Write2, request.Command.Kind);
Assert.Equal(12, request.Command.Write2.ServerHandle);
Assert.Equal(34, request.Command.Write2.ItemHandle);
Assert.Same(value, request.Command.Write2.Value);
Assert.Same(timestampValue, request.Command.Write2.TimestampValue);
Assert.Equal(56, request.Command.Write2.UserId);
}
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
[Fact]
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.SubscribeBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
SubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult
{
ServerHandle = 12,
TagAddress = "Area001.Pump001.Speed",
ItemHandle = 34,
WasSuccessful = true,
},
},
},
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
IReadOnlyList<SubscribeResult> results = await session.SubscribeBulkAsync(
12,
["Area001.Pump001.Speed"]);
SubscribeResult result = Assert.Single(results);
Assert.Equal(34, result.ItemHandle);
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
Assert.Equal(MxCommandKind.SubscribeBulk, request.Command.Kind);
Assert.Equal(12, request.Command.SubscribeBulk.ServerHandle);
Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses);
}
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
[Fact]
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddEvent(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnDataChange,
WorkerSequence = 1,
});
transport.AddEvent(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnWriteComplete,
WorkerSequence = 2,
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
List<ulong> sequences = [];
await foreach (MxEvent gatewayEvent in session.StreamEventsAsync(afterWorkerSequence: 0))
{
sequences.Add(gatewayEvent.WorkerSequence);
}
Assert.Equal([1UL, 2UL], sequences);
StreamEventsRequest request = Assert.Single(transport.StreamEventsCalls).Request;
Assert.Equal("session-fixture", request.SessionId);
}
/// <summary>Verifies that close is explicit and idempotent.</summary>
[Fact]
public async Task CloseAsync_IsExplicitAndIdempotent()
{
FakeGatewayTransport transport = CreateTransport();
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
CloseSessionReply first = await session.CloseAsync();
CloseSessionReply second = await session.CloseAsync();
Assert.Same(first, second);
var call = Assert.Single(transport.CloseSessionCalls);
Assert.Equal("session-fixture", call.Request.SessionId);
}
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
[Fact]
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
{
FakeGatewayTransport transport = CreateTransport();
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Ping,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await session.InvokeAsync(new MxCommandRequest
{
SessionId = session.SessionId,
Command = new MxCommand { Kind = MxCommandKind.Ping, Ping = new PingCommand() },
});
Assert.Equal(2, transport.InvokeCalls.Count);
}
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
[Fact]
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
{
FakeGatewayTransport transport = CreateTransport();
transport.OpenSessionExceptions.Enqueue(CreateTransientRpcException());
await using MxGatewayClient client = CreateClient(transport);
await Assert.ThrowsAsync<RpcException>(async () => await client.OpenSessionAsync());
Assert.Single(transport.OpenSessionCalls);
}
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
[Fact]
public async Task InvokeAsync_DoesNotRetryWriteCommand()
{
FakeGatewayTransport transport = CreateTransport();
transport.InvokeExceptions.Enqueue(CreateTransientRpcException());
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await Assert.ThrowsAsync<RpcException>(async () =>
await session.WriteRawAsync(1, 2, 3.ToMxValue(), userId: 0));
Assert.Single(transport.InvokeCalls);
}
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
[Fact]
public async Task InvokeHelpers_PassCancellationTokenToTransport()
{
using CancellationTokenSource cancellation = new();
FakeGatewayTransport transport = CreateTransport();
transport.AddInvokeReply(new MxCommandReply
{
SessionId = "session-fixture",
Kind = MxCommandKind.Advise,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
await session.AdviseAsync(12, 34, cancellation.Token);
Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken);
}
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
{
return new MxGatewayClient(transport.Options, transport);
}
private static FakeGatewayTransport CreateTransport()
{
return new FakeGatewayTransport(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
private static RpcException CreateTransientRpcException()
{
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
}
}
@@ -0,0 +1,85 @@
using System.Net.Http;
using System.Net.Security;
using ZB.MOM.WW.MxGateway.Client;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientTlsHandlerTests
{
/// <summary>
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
/// the handler installs an accept-all callback so the gateway's self-signed cert is trusted.
/// The callback must return true regardless of chain errors.
/// </summary>
[Fact]
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
};
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
/// <summary>
/// Verifies that when RequireCertificateValidation is true, the callback is left null
/// so the OS trust store performs validation.
/// </summary>
[Fact]
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
RequireCertificateValidation = true,
};
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
}
}
public sealed class GalaxyRepositoryClientTlsHandlerTests
{
/// <summary>
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
/// the Galaxy client handler installs an accept-all callback so the gateway's self-signed cert is trusted.
/// The callback must return true regardless of chain errors.
/// </summary>
[Fact]
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
};
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
}
/// <summary>
/// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null
/// so the OS trust store performs validation.
/// </summary>
[Fact]
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
{
MxGatewayClientOptions options = new()
{
Endpoint = new Uri("https://localhost:5120"),
ApiKey = "k",
UseTls = true,
RequireCertificateValidation = true,
};
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
}
}
@@ -1,7 +1,8 @@
namespace MxGateway.Client.Tests;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayGeneratedContractTests
{
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
[Fact]
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
{
@@ -0,0 +1,58 @@
using System.Text.Json;
using Google.Protobuf;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxStatusProxyExtensionsTests
{
/// <summary>Verifies that fixture statuses correctly project success and preserve raw integer fields.</summary>
[Fact]
public void FixtureStatuses_ProjectSuccessAndPreserveRawFields()
{
using JsonDocument document = JsonDocument.Parse(ReadFixture(
"statuses",
"status-conversion-cases.json"));
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
{
MxStatusProxy status = JsonParser.Default.Parse<MxStatusProxy>(
testCase.GetProperty("status").GetRawText());
int success = testCase.GetProperty("status").GetProperty("success").GetInt32();
Assert.Equal(success != 0 && status.Category is MxStatusCategory.Ok, status.IsSuccess());
Assert.Equal(
testCase.GetProperty("status").GetProperty("rawCategory").GetInt32(),
status.RawCategory);
Assert.Equal(
testCase.GetProperty("status").GetProperty("rawDetectedBy").GetInt32(),
status.RawDetectedBy);
}
}
private static string ReadFixture(string category, string fileName)
{
DirectoryInfo directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string path = Path.Combine(
directory.FullName,
"clients",
"proto",
"fixtures",
"behavior",
category,
fileName);
if (File.Exists(path))
{
return File.ReadAllText(path);
}
directory = directory.Parent!;
}
throw new FileNotFoundException(fileName);
}
}
@@ -0,0 +1,82 @@
using System.Text.Json;
using Google.Protobuf;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxValueExtensionsTests
{
/// <summary>Verifies that scalar values are converted to correctly-typed MxValue protobuf messages.</summary>
[Fact]
public void ToMxValue_WithScalarValues_CreatesTypedProtobufValues()
{
Assert.Equal(MxValue.KindOneofCase.BoolValue, true.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.Int32Value, 123.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.Int64Value, 123L.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.FloatValue, 1.25F.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.DoubleValue, 2.5D.ToMxValue().KindCase);
Assert.Equal(MxValue.KindOneofCase.StringValue, "alpha".ToMxValue().KindCase);
}
/// <summary>Verifies that array values are converted to array-kind MxValue messages with correct element types and dimensions.</summary>
[Fact]
public void ToMxValue_WithArrays_CreatesTypedArrayProtobufValues()
{
MxValue value = new[] { "alpha", "beta" }.ToMxValue();
Assert.Equal(MxValue.KindOneofCase.ArrayValue, value.KindCase);
Assert.Equal(MxArray.ValuesOneofCase.StringValues, value.ArrayValue.ValuesCase);
Assert.Equal(["alpha", "beta"], value.ArrayValue.StringValues.Values);
Assert.Equal([2U], value.ArrayValue.Dimensions);
}
/// <summary>Verifies that fixture test cases project to expected MxValue kinds and preserve raw type metadata.</summary>
[Fact]
public void FixtureValues_ProjectExpectedKindsAndPreserveRawMetadata()
{
using JsonDocument document = JsonDocument.Parse(ReadFixture(
"values",
"value-conversion-cases.json"));
foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray())
{
string expectedKind = testCase.GetProperty("expectedKind").GetString()!;
MxValue value = JsonParser.Default.Parse<MxValue>(
testCase.GetProperty("value").GetRawText());
Assert.Equal(expectedKind, value.GetProjectionKind());
if (testCase.GetProperty("id").GetString() is "raw-fallback.variant")
{
Assert.Equal(32767, value.RawDataType);
Assert.Equal([1, 2, 3, 4, 5], Assert.IsType<byte[]>(value.ToClrValue()));
}
}
}
private static string ReadFixture(string category, string fileName)
{
DirectoryInfo directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
string path = Path.Combine(
directory.FullName,
"clients",
"proto",
"fixtures",
"behavior",
category,
fileName);
if (File.Exists(path))
{
return File.ReadAllText(path);
}
directory = directory.Parent!;
}
throw new FileNotFoundException(fileName);
}
}
@@ -19,8 +19,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
<ProjectReference Include="..\MxGateway.Client.Cli\MxGateway.Client.Cli.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client\ZB.MOM.WW.MxGateway.Client.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Client.Cli\ZB.MOM.WW.MxGateway.Client.Cli.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="../../src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj" />
<Project Path="ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj" />
<Project Path="ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj" />
<Project Path="ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj" />
</Solution>
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Filters and shape options for <see cref="GalaxyRepositoryClient.BrowseAsync(BrowseChildrenOptions, System.Threading.CancellationToken)"/>.
/// Mirror of <see cref="DiscoverHierarchyOptions"/> for the lazy-browse path.
/// </summary>
public sealed class BrowseChildrenOptions
{
/// <summary>Restrict to children whose Galaxy category is in this set.</summary>
public IReadOnlyList<int> CategoryIds { get; init; } = [];
/// <summary>Restrict to children whose template chain contains any of these tokens.</summary>
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
public string? TagNameGlob { get; init; }
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
public bool? IncludeAttributes { get; init; }
/// <summary>Restrict to children that bear at least one alarm attribute.</summary>
public bool AlarmBearingOnly { get; init; }
/// <summary>Restrict to children that have at least one historized attribute.</summary>
public bool HistorizedOnly { get; init; }
}
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Filters and shape options for <see cref="GalaxyRepositoryClient.DiscoverHierarchyAsync(DiscoverHierarchyOptions, System.Threading.CancellationToken)"/>.
/// </summary>
/// <remarks>
/// Hand-written ergonomic wrapper around the generated
/// <c>DiscoverHierarchyRequest</c>: lets callers express a Galaxy-browse
/// slice with .NET-friendly nullable scalars and collection initializers,
/// without touching the protobuf message's <c>oneof root</c> directly.
/// </remarks>
public sealed class DiscoverHierarchyOptions
{
/// <summary>Restrict to the subtree rooted at this Galaxy <c>gobject_id</c>.</summary>
public int? RootGobjectId { get; init; }
/// <summary>Restrict to the subtree rooted at the object with this tag name.</summary>
public string? RootTagName { get; init; }
/// <summary>Restrict to the subtree rooted at this <c>contained_name</c> path.</summary>
public string? RootContainedPath { get; init; }
/// <summary>Maximum traversal depth, measured from the chosen root.</summary>
public int? MaxDepth { get; init; }
/// <summary>Restrict to objects whose Galaxy category is in this set.</summary>
public IReadOnlyList<int> CategoryIds { get; init; } = [];
/// <summary>Restrict to objects whose template chain contains any of these tokens.</summary>
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
public string? TagNameGlob { get; init; }
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
public bool? IncludeAttributes { get; init; }
/// <summary>Restrict to objects that bear at least one alarm attribute.</summary>
public bool AlarmBearingOnly { get; init; }
/// <summary>Restrict to objects that have at least one historized attribute.</summary>
public bool HistorizedOnly { get; init; }
}
@@ -0,0 +1,549 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using Polly;
using System.Net.Http;
using System.Net.Security;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
/// All RPCs are read-only metadata calls that share the gateway's API-key auth
/// interceptor and require the <c>metadata:read</c> scope server-side.
/// </summary>
public sealed class GalaxyRepositoryClient : IAsyncDisposable
{
private const int DiscoverHierarchyPageSize = 5000;
private const int BrowseChildrenPageSize = 500;
private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed;
/// <summary>
/// Initializes a Galaxy Repository client with custom transport and options.
/// </summary>
/// <param name="options">Client options.</param>
/// <param name="transport">The underlying gRPC transport.</param>
internal GalaxyRepositoryClient(
MxGatewayClientOptions options,
IGalaxyRepositoryClientTransport transport)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
Options = options;
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
options.Retry,
options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
_channel = null;
}
private GalaxyRepositoryClient(
GrpcChannel channel,
IGalaxyRepositoryClientTransport transport)
{
_channel = channel;
_transport = transport;
Options = transport.Options;
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
Options.Retry,
Options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
}
/// <summary>
/// Client options used to configure timeouts, authentication, and retry policy.
/// </summary>
public MxGatewayClientOptions Options { get; }
/// <summary>
/// The underlying generated gRPC client for advanced operations.
/// </summary>
public GalaxyRepository.GalaxyRepositoryClient RawClient =>
_transport.RawClient
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
/// <summary>
/// Creates a Galaxy Repository client with the given options, establishing a new gRPC channel.
/// </summary>
/// <param name="options">Client options.</param>
/// <returns>A new client instance.</returns>
public static GalaxyRepositoryClient Create(MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
HttpMessageHandler handler = CreateHttpHandler(options);
var channel = GrpcChannel.ForAddress(
options.Endpoint,
new GrpcChannelOptions
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
MaxSendMessageSize = options.MaxGrpcMessageBytes,
});
return new GalaxyRepositoryClient(
channel,
new GrpcGalaxyRepositoryClientTransport(
options,
new GalaxyRepository.GalaxyRepositoryClient(channel)));
}
/// <summary>
/// Probes the Galaxy Repository database connection. Returns true when the
/// gateway can reach the configured ZB SQL Server.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if connection is successful, false otherwise.</returns>
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
{
TestConnectionReply reply = await TestConnectionRawAsync(
new TestConnectionRequest(),
cancellationToken)
.ConfigureAwait(false);
return reply.Ok;
}
/// <summary>
/// Probes the Galaxy Repository database connection without result wrapping.
/// </summary>
/// <param name="request">The test connection request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<TestConnectionReply> TestConnectionRawAsync(
TestConnectionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.TestConnectionAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Returns the timestamp of the most recent Galaxy deployment, or
/// <see langword="null"/> when no deployment has been recorded.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deployment timestamp, or null if not recorded.</returns>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken cancellationToken = default)
{
GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync(
new GetLastDeployTimeRequest(),
cancellationToken)
.ConfigureAwait(false);
if (!reply.Present || reply.TimeOfLastDeploy is null)
{
return null;
}
return reply.TimeOfLastDeploy.ToDateTime();
}
/// <summary>
/// Returns the most recent Galaxy deployment timestamp without result wrapping.
/// </summary>
/// <param name="request">The last deploy-time request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<GetLastDeployTimeReply> GetLastDeployTimeRawAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.GetLastDeployTimeAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Enumerates the deployed Galaxy object hierarchy. Each <see cref="GalaxyObject"/>
/// includes its dynamic attributes so callers can determine which tag references
/// they may subscribe to via the MxAccessGateway service.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
{
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
}
/// <summary>Discovers the Galaxy object hierarchy.</summary>
/// <param name="options">Client configuration options.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
DiscoverHierarchyOptions options,
CancellationToken cancellationToken = default)
{
List<GalaxyObject> objects = [];
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
string pageToken = string.Empty;
do
{
DiscoverHierarchyRequest request = CreateDiscoverHierarchyRequest(options);
request.PageSize = DiscoverHierarchyPageSize;
request.PageToken = pageToken;
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
request,
cancellationToken)
.ConfigureAwait(false);
objects.AddRange(reply.Objects);
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken)
&& !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return objects;
}
private static DiscoverHierarchyRequest CreateDiscoverHierarchyRequest(DiscoverHierarchyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
DiscoverHierarchyRequest request = new()
{
AlarmBearingOnly = options.AlarmBearingOnly,
HistorizedOnly = options.HistorizedOnly,
};
if (options.RootGobjectId.HasValue)
{
request.RootGobjectId = options.RootGobjectId.Value;
}
else if (!string.IsNullOrWhiteSpace(options.RootTagName))
{
request.RootTagName = options.RootTagName;
}
else if (!string.IsNullOrWhiteSpace(options.RootContainedPath))
{
request.RootContainedPath = options.RootContainedPath;
}
if (options.MaxDepth.HasValue)
{
request.MaxDepth = options.MaxDepth.Value;
}
request.CategoryIds.Add(options.CategoryIds);
request.TemplateChainContains.Add(options.TemplateChainContains);
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
{
request.TagNameGlob = options.TagNameGlob;
}
if (options.IncludeAttributes.HasValue)
{
request.IncludeAttributes = options.IncludeAttributes.Value;
}
return request;
}
/// <summary>
/// Enumerates the Galaxy object hierarchy without result wrapping.
/// </summary>
/// <param name="request">The discover-hierarchy request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<DiscoverHierarchyReply> DiscoverHierarchyRawAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.DiscoverHierarchyAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>Returns root-level browse nodes (objects with no parent).</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(CancellationToken cancellationToken = default)
=> BrowseAsync(null, cancellationToken);
/// <summary>Returns root-level browse nodes filtered by the given options.</summary>
/// <param name="options">Browse filter options. Null applies no filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
public async Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
BrowseChildrenOptions? options,
CancellationToken cancellationToken = default)
{
BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions();
List<LazyBrowseNode> roots = [];
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective);
request.PageToken = pageToken;
BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return roots;
}
/// <summary>Issues a raw BrowseChildren RPC without result wrapping.</summary>
/// <param name="request">The browse-children request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
BrowseChildrenRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)),
cancellationToken);
}
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
{
ArgumentNullException.ThrowIfNull(options);
BrowseChildrenRequest request = new()
{
PageSize = BrowseChildrenPageSize,
AlarmBearingOnly = options.AlarmBearingOnly,
HistorizedOnly = options.HistorizedOnly,
};
request.CategoryIds.Add(options.CategoryIds);
request.TemplateChainContains.Add(options.TemplateChainContains);
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
{
request.TagNameGlob = options.TagNameGlob;
}
if (options.IncludeAttributes.HasValue)
{
request.IncludeAttributes = options.IncludeAttributes.Value;
}
return request;
}
/// <summary>
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
/// current state on subscribe so callers can prime their cache, then emits one event
/// per new <c>time_of_last_deploy</c>. Pass <paramref name="lastSeenDeployTime"/> to
/// suppress the bootstrap when the caller already holds the current deploy time.
/// </summary>
/// <remarks>
/// Streaming RPCs are not wrapped by the unary safe-read retry pipeline. If the
/// stream is interrupted the caller must reopen it; the server does not guarantee
/// at-least-once delivery beyond the per-subscriber buffer (gaps in
/// <see cref="DeployEvent.Sequence"/> indicate dropped events).
/// </remarks>
/// <param name="lastSeenDeployTime">Optional timestamp to suppress the bootstrap event.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of deploy events.</returns>
public IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
DateTimeOffset? lastSeenDeployTime = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
WatchDeployEventsRequest request = new();
if (lastSeenDeployTime is { } seen)
{
request.LastSeenDeployTime = Timestamp.FromDateTimeOffset(seen);
}
return WatchDeployEventsRawAsync(request, cancellationToken);
}
/// <summary>
/// Subscribes to Galaxy deploy events without result wrapping.
/// </summary>
/// <param name="request">The watch-deploy-events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of raw deploy events.</returns>
public IAsyncEnumerable<DeployEvent> WatchDeployEventsRawAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return WatchDeployEventsCoreAsync(request, cancellationToken);
}
private async IAsyncEnumerable<DeployEvent> WatchDeployEventsCoreAsync(
WatchDeployEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (DeployEvent deployEvent in _transport
.WatchDeployEventsAsync(request, CreateStreamCallOptions(cancellationToken))
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return deployEvent;
}
}
/// <summary>
/// Closes the gRPC channel and releases resources.
/// </summary>
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel?.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>
/// Creates gRPC call options with the client's default timeout and API-key authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The call options.</returns>
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
}
/// <summary>
/// Creates gRPC call options for streaming RPCs with the stream timeout and API-key authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The stream call options.</returns>
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.StreamTimeout);
}
/// <summary>
/// Creates gRPC call options with the specified timeout and API-key authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="timeout">Optional timeout duration.</param>
/// <returns>The call options.</returns>
internal CallOptions CreateCallOptions(
CancellationToken cancellationToken,
TimeSpan? timeout)
{
Metadata headers = new()
{
{ "authorization", $"Bearer {Options.ApiKey}" },
};
return new CallOptions(
headers,
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
cancellationToken);
}
private async Task<T> ExecuteSafeUnaryAsync<T>(
Func<CancellationToken, Task<T>> call,
CancellationToken cancellationToken)
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(Options.DefaultCallTimeout);
return await _safeUnaryRetryPipeline.ExecuteAsync(
async token => await call(token).ConfigureAwait(false),
timeout.Token)
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options);
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
ConnectTimeout = options.ConnectTimeout,
};
if (options.UseTls)
{
handler.SslOptions = new SslClientAuthenticationOptions();
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
{
handler.SslOptions.TargetHost = options.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
{
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
{
return false;
}
if (certificate is null)
{
return false;
}
using X509Chain customChain = new();
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
X509Certificate2 certificateToValidate = certificate as X509Certificate2
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
return customChain.Build(certificateToValidate);
};
}
else if (!options.RequireCertificateValidation)
{
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
}
}
return handler;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}
@@ -0,0 +1,159 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// gRPC implementation of IGalaxyRepositoryClientTransport.
/// </summary>
internal sealed class GrpcGalaxyRepositoryClientTransport(
MxGatewayClientOptions options,
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
{
/// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options;
/// <summary>
/// Gets the underlying gRPC client.
/// </summary>
public GalaxyRepository.GalaxyRepositoryClient RawClient { get; } = rawClient;
/// <inheritdoc />
GalaxyRepository.GalaxyRepositoryClient? IGalaxyRepositoryClientTransport.RawClient => RawClient;
/// <inheritdoc />
public async Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.TestConnectionAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.GetLastDeployTimeAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.DiscoverHierarchyAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.BrowseChildrenAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<DeployEvent> call = RawClient.WatchDeployEvents(request, callOptions);
IAsyncStreamReader<DeployEvent> responseStream = call.ResponseStream;
while (true)
{
DeployEvent? deployEvent;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
deployEvent = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return deployEvent;
}
}
/// <inheritdoc />
IAsyncEnumerable<DeployEvent> IGalaxyRepositoryClientTransport.WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions)
{
return WatchDeployEventsAsync(request, callOptions);
}
private static Exception MapRpcException(
RpcException exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
{
return new OperationCanceledException(
exception.Status.Detail,
exception,
cancellationToken);
}
return exception.StatusCode switch
{
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
exception.Status.Detail,
innerException: exception),
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
exception.Status.Detail,
innerException: exception),
_ => new MxGatewayException(exception.Status.Detail, exception),
};
}
}
@@ -0,0 +1,243 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// gRPC implementation of IMxGatewayClientTransport.
/// </summary>
internal sealed class GrpcMxGatewayClientTransport(
MxGatewayClientOptions options,
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
{
/// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options;
/// <summary>
/// Gets the underlying gRPC client.
/// </summary>
public MxAccessGateway.MxAccessGatewayClient RawClient { get; } = rawClient;
/// <inheritdoc />
MxAccessGateway.MxAccessGatewayClient? IMxGatewayClientTransport.RawClient => RawClient;
/// <inheritdoc />
public async Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.OpenSessionAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.CloseSessionAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.InvokeAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<MxEvent> call = RawClient.StreamEvents(request, callOptions);
IAsyncStreamReader<MxEvent> responseStream = call.ResponseStream;
while (true)
{
MxEvent? gatewayEvent;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
gatewayEvent = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return gatewayEvent;
}
}
/// <inheritdoc />
IAsyncEnumerable<MxEvent> IMxGatewayClientTransport.StreamEventsAsync(
StreamEventsRequest request,
CallOptions callOptions)
{
return StreamEventsAsync(request, callOptions);
}
/// <inheritdoc />
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.AcknowledgeAlarmAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc />
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<ActiveAlarmSnapshot> call = RawClient.QueryActiveAlarms(request, callOptions);
IAsyncStreamReader<ActiveAlarmSnapshot> responseStream = call.ResponseStream;
while (true)
{
ActiveAlarmSnapshot? snapshot;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
snapshot = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return snapshot;
}
}
/// <inheritdoc />
IAsyncEnumerable<ActiveAlarmSnapshot> IMxGatewayClientTransport.QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions)
{
return QueryActiveAlarmsAsync(request, callOptions);
}
/// <inheritdoc />
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<AlarmFeedMessage> call = RawClient.StreamAlarms(request, callOptions);
IAsyncStreamReader<AlarmFeedMessage> responseStream = call.ResponseStream;
while (true)
{
AlarmFeedMessage? message;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
message = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return message;
}
}
/// <inheritdoc />
IAsyncEnumerable<AlarmFeedMessage> IMxGatewayClientTransport.StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions)
{
return StreamAlarmsAsync(request, callOptions);
}
private static Exception MapRpcException(
RpcException exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
{
return new OperationCanceledException(
exception.Status.Detail,
exception,
cancellationToken);
}
return exception.StatusCode switch
{
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
exception.Status.Detail,
innerException: exception),
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
exception.Status.Detail,
innerException: exception),
_ => new MxGatewayException(exception.Status.Detail, exception),
};
}
}
@@ -0,0 +1,49 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Transport layer for Galaxy Repository gRPC operations.</summary>
internal interface IGalaxyRepositoryClientTransport
{
/// <summary>Gets the client options used to configure this transport.</summary>
MxGatewayClientOptions Options { get; }
/// <summary>Gets the underlying gRPC client, or <c>null</c> if not yet initialized.</summary>
GalaxyRepository.GalaxyRepositoryClient? RawClient { get; }
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
/// <param name="request">The test connection request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions);
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
/// <param name="request">The get last deploy time request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions);
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
/// <param name="request">The discover hierarchy request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions);
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
/// <param name="request">The browse children request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions);
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
/// <param name="request">The watch deploy events request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions);
}
@@ -0,0 +1,89 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
internal interface IMxGatewayClientTransport
{
/// <summary>
/// Gets the client configuration options.
/// </summary>
MxGatewayClientOptions Options { get; }
/// <summary>
/// Gets the underlying gRPC client, if available.
/// </summary>
MxAccessGateway.MxAccessGatewayClient? RawClient { get; }
/// <summary>
/// Opens a new gateway session.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>The session open reply.</returns>
Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CallOptions callOptions);
/// <summary>
/// Closes an open gateway session.
/// </summary>
/// <param name="request">Session close request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>The session close reply.</returns>
Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CallOptions callOptions);
/// <summary>
/// Invokes an MXAccess command on the session.
/// </summary>
/// <param name="request">The command request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>The command reply.</returns>
Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CallOptions callOptions);
/// <summary>
/// Streams events from the session.
/// </summary>
/// <param name="request">The stream events request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>An async enumerable of events.</returns>
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CallOptions callOptions);
/// <summary>
/// Acknowledges an active MXAccess alarm condition.
/// </summary>
/// <param name="request">The acknowledge request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>The acknowledge reply with native MxStatus.</returns>
Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CallOptions callOptions);
/// <summary>
/// Streams a snapshot of all alarms currently in Active or ActiveAcked state — the
/// ConditionRefresh equivalent for the gateway.
/// </summary>
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>An async enumerable of active-alarm snapshots.</returns>
IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions);
/// <summary>
/// Attaches to the gateway's central alarm feed — the current active-alarm
/// snapshot followed by live transitions.
/// </summary>
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>An async enumerable of alarm feed messages.</returns>
IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CallOptions callOptions);
}
@@ -0,0 +1,101 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying
/// <see cref="GalaxyObject"/> and exposes <see cref="ExpandAsync"/> to fetch
/// its direct children on demand. Expansion is one-shot: a second call is a
/// no-op. Pagination of large sibling sets is handled internally.
/// </summary>
public sealed class LazyBrowseNode
{
private readonly GalaxyRepositoryClient _client;
private readonly BrowseChildrenOptions _options;
private readonly List<LazyBrowseNode> _children = [];
private readonly SemaphoreSlim _expandLock = new(1, 1);
private bool _isExpanded;
internal LazyBrowseNode(
GalaxyRepositoryClient client,
GalaxyObject @object,
bool hasChildrenHint,
BrowseChildrenOptions options)
{
_client = client;
Object = @object;
HasChildrenHint = hasChildrenHint;
_options = options;
}
/// <summary>The underlying Galaxy object for this node.</summary>
public GalaxyObject Object { get; }
/// <summary>True when the server reports this node has at least one matching descendant.</summary>
public bool HasChildrenHint { get; }
/// <summary>Direct children loaded by <see cref="ExpandAsync"/>; empty until then.</summary>
public IReadOnlyList<LazyBrowseNode> Children => _children;
/// <summary>True after the first <see cref="ExpandAsync"/> call completes.</summary>
public bool IsExpanded => _isExpanded;
/// <summary>
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
/// Idempotent: subsequent calls are no-ops.
/// </summary>
/// <remarks>
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
/// (after the first completes) return immediately.
/// </remarks>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task ExpandAsync(CancellationToken cancellationToken = default)
{
if (_isExpanded)
{
return;
}
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_isExpanded)
{
return;
}
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
request.ParentGobjectId = Object.GobjectId;
request.PageToken = pageToken;
BrowseChildrenReply reply = await _client
.BrowseChildrenRawAsync(request, cancellationToken)
.ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
_isExpanded = true;
}
finally
{
_expandLock.Release();
}
}
}
@@ -0,0 +1,30 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary>
public sealed class MxAccessException : MxGatewayCommandException
{
/// <summary>Initializes a new instance with the given message, reply, and optional inner exception.</summary>
/// <param name="message">The error message describing the MXAccess failure.</param>
/// <param name="reply">The MxCommandReply containing the failure details (statuses, HResult, etc.).</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxAccessException(
string message,
MxCommandReply reply,
Exception? innerException = null)
: base(
message,
reply.SessionId,
reply.CorrelationId,
reply.ProtocolStatus,
reply.HasHresult ? reply.Hresult : null,
reply.Statuses.ToArray(),
innerException)
{
Reply = reply;
}
/// <summary>Gets the underlying MxCommandReply containing full failure details.</summary>
public MxCommandReply Reply { get; }
}
@@ -0,0 +1,101 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Extension methods for checking MxCommandReply success conditions.</summary>
public static class MxCommandReplyExtensions
{
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
/// <param name="reply">The command reply to check.</param>
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
ProtocolStatusCode code = reply.ProtocolStatus?.Code
?? ProtocolStatusCode.Unspecified;
if (code is ProtocolStatusCode.Ok or ProtocolStatusCode.MxaccessFailure)
{
return reply;
}
throw CreateProtocolException(reply, code);
}
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
/// <param name="reply">The command reply to check.</param>
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
bool mxAccessFailure = reply.ProtocolStatus?.Code is ProtocolStatusCode.MxaccessFailure;
bool hResultFailure = reply.HasHresult && reply.Hresult != 0;
bool statusFailure = reply.Statuses.Any(status => !status.IsSuccess());
if (!mxAccessFailure && !hResultFailure && !statusFailure)
{
return reply;
}
throw new MxAccessException(CreateMxAccessMessage(reply), reply);
}
private static MxGatewayException CreateProtocolException(
MxCommandReply reply,
ProtocolStatusCode code)
{
string message = CreateProtocolMessage(reply);
int? hResult = reply.HasHresult ? reply.Hresult : null;
MxStatusProxy[] statuses = reply.Statuses.ToArray();
return code switch
{
ProtocolStatusCode.SessionNotFound or ProtocolStatusCode.SessionNotReady
=> new MxGatewaySessionException(
message,
reply.SessionId,
reply.CorrelationId,
reply.ProtocolStatus,
hResult,
statuses),
ProtocolStatusCode.WorkerUnavailable
=> new MxGatewayWorkerException(
message,
reply.SessionId,
reply.CorrelationId,
reply.ProtocolStatus,
hResult,
statuses),
_
=> new MxGatewayCommandException(
message,
reply.SessionId,
reply.CorrelationId,
reply.ProtocolStatus,
hResult,
statuses),
};
}
private static string CreateProtocolMessage(MxCommandReply reply)
{
string statusMessage = string.IsNullOrWhiteSpace(reply.ProtocolStatus?.Message)
? "Gateway protocol failure."
: reply.ProtocolStatus.Message;
return $"{statusMessage} code={reply.ProtocolStatus?.Code}; session={reply.SessionId}; correlation={reply.CorrelationId}";
}
private static string CreateMxAccessMessage(MxCommandReply reply)
{
string statusSummary = reply.Statuses.Count is 0
? "no MXSTATUS_PROXY entries"
: string.Join("; ", reply.Statuses.Select(status => status.ToDiagnosticSummary()));
string hResult = reply.HasHresult
? $"0x{reply.Hresult:X8}"
: "none";
return $"MXAccess command failed. kind={reply.Kind}; hresult={hResult}; statuses={statusSummary}";
}
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary>
public sealed class MxGatewayAuthenticationException : MxGatewayException
{
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the authentication failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewayAuthenticationException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary>
public sealed class MxGatewayAuthorizationException : MxGatewayException
{
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the authorization failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewayAuthorizationException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -0,0 +1,374 @@
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Polly;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
/// </summary>
public sealed class MxGatewayClient : IAsyncDisposable
{
private readonly GrpcChannel _channel;
private readonly IMxGatewayClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
/// </summary>
/// <param name="options">Client configuration options.</param>
/// <param name="transport">Transport implementation for gateway communication.</param>
internal MxGatewayClient(
MxGatewayClientOptions options,
IMxGatewayClientTransport transport)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
Options = options;
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
options.Retry,
options.LoggerFactory?.CreateLogger<MxGatewayClient>());
_channel = null!;
}
private MxGatewayClient(
GrpcChannel channel,
IMxGatewayClientTransport transport)
{
_channel = channel;
_transport = transport;
Options = transport.Options;
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
Options.Retry,
Options.LoggerFactory?.CreateLogger<MxGatewayClient>());
}
/// <summary>
/// Gets the client configuration options.
/// </summary>
public MxGatewayClientOptions Options { get; }
/// <summary>
/// Gets the underlying generated gRPC client.
/// </summary>
public MxAccessGateway.MxAccessGatewayClient RawClient =>
_transport.RawClient
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
/// <summary>
/// Creates a new gateway client with the given options.
/// </summary>
/// <param name="options">Client configuration options.</param>
/// <returns>A new gateway client instance.</returns>
public static MxGatewayClient Create(MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
HttpMessageHandler handler = CreateHttpHandler(options);
var channel = GrpcChannel.ForAddress(
options.Endpoint,
new GrpcChannelOptions
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
MaxSendMessageSize = options.MaxGrpcMessageBytes,
});
return new MxGatewayClient(
channel,
new GrpcMxGatewayClientTransport(
options,
new MxAccessGateway.MxAccessGatewayClient(channel)));
}
/// <summary>
/// Opens a new gateway session.
/// </summary>
/// <param name="request">Session open request; defaults to empty request if null.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A wrapped gateway session.</returns>
public async Task<MxGatewaySession> OpenSessionAsync(
OpenSessionRequest? request = null,
CancellationToken cancellationToken = default)
{
OpenSessionReply reply = await OpenSessionRawAsync(
request ?? new OpenSessionRequest(),
cancellationToken)
.ConfigureAwait(false);
return new MxGatewaySession(this, reply);
}
/// <summary>
/// Opens a new gateway session and returns the raw protobuf reply.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The raw gateway session open reply.</returns>
public Task<OpenSessionReply> OpenSessionRawAsync(
OpenSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return _transport.OpenSessionAsync(request, CreateCallOptions(cancellationToken));
}
/// <summary>
/// Closes an open gateway session.
/// </summary>
/// <param name="request">Session close request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The session close reply.</returns>
public Task<CloseSessionReply> CloseSessionRawAsync(
CloseSessionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.CloseSessionAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Invokes an MXAccess command on the open session.
/// </summary>
/// <param name="request">The command request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The command reply.</returns>
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
if (MxGatewayClientRetryPolicy.IsRetryableCommand(request.Command?.Kind ?? MxCommandKind.Unspecified))
{
return ExecuteSafeUnaryAsync(
token => _transport.InvokeAsync(request, CreateCallOptions(token)),
cancellationToken);
}
return _transport.InvokeAsync(request, CreateCallOptions(cancellationToken));
}
/// <summary>
/// Streams events from the gateway session.
/// </summary>
/// <param name="request">The stream events request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of events.</returns>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return _transport.StreamEventsAsync(request, CreateStreamCallOptions(cancellationToken));
}
/// <summary>
/// Acknowledges an active MXAccess alarm condition through the gateway. The
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
/// scope and forwards the acknowledge to the worker's MXAccess session;
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
/// </summary>
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Streams a snapshot of all alarms currently Active or ActiveAcked — the gateway's
/// ConditionRefresh equivalent. Used after reconnect to seed the local Part 9 state
/// machine, or to reconcile alarms that may have been missed during a transport
/// blip. Optionally scoped by alarm-reference prefix
/// (<see cref="QueryActiveAlarmsRequest.AlarmFilterPrefix"/>) so a partial refresh
/// can target an equipment sub-tree.
/// </summary>
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
/// <param name="cancellationToken">Cancellation token for the stream.</param>
/// <returns>An async enumerable of active-alarm snapshots.</returns>
public IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return _transport.QueryActiveAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
}
/// <summary>
/// Attaches to the gateway's central alarm feed. The stream opens with one
/// <see cref="AlarmFeedMessage"/> per currently-active alarm (the
/// ConditionRefresh snapshot), then a single <c>snapshot_complete</c>, then a
/// <c>transition</c> for every subsequent raise / acknowledge / clear. Served
/// by the gateway's always-on alarm monitor — no worker session is opened, so
/// any number of clients may attach. Optionally scoped by alarm-reference
/// prefix (<see cref="StreamAlarmsRequest.AlarmFilterPrefix"/>).
/// </summary>
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
/// <param name="cancellationToken">Cancellation token for the stream.</param>
/// <returns>An async enumerable of alarm feed messages.</returns>
public IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return _transport.StreamAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
}
/// <summary>
/// Disposes the client and releases all resources.
/// </summary>
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel?.Dispose();
return ValueTask.CompletedTask;
}
/// <summary>
/// Creates gRPC call options with default timeout and authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the call.</param>
/// <returns>Configured call options.</returns>
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
}
/// <summary>
/// Creates gRPC call options for streaming with stream timeout and authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the call.</param>
/// <returns>Configured call options.</returns>
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.StreamTimeout);
}
/// <summary>
/// Creates gRPC call options with specified timeout and authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the call.</param>
/// <param name="timeout">Optional timeout duration; null means no timeout.</param>
/// <returns>Configured call options.</returns>
internal CallOptions CreateCallOptions(
CancellationToken cancellationToken,
TimeSpan? timeout)
{
Metadata headers = new()
{
{ "authorization", $"Bearer {Options.ApiKey}" },
};
return new CallOptions(
headers,
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
cancellationToken);
}
private async Task<T> ExecuteSafeUnaryAsync<T>(
Func<CancellationToken, Task<T>> call,
CancellationToken cancellationToken)
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(Options.DefaultCallTimeout);
return await _safeUnaryRetryPipeline.ExecuteAsync(
async token => await call(token).ConfigureAwait(false),
timeout.Token)
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options);
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
ConnectTimeout = options.ConnectTimeout,
};
if (options.UseTls)
{
handler.SslOptions = new SslClientAuthenticationOptions();
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
{
handler.SslOptions.TargetHost = options.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
{
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
{
return false;
}
if (certificate is null)
{
return false;
}
using X509Chain customChain = new();
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
X509Certificate2 certificateToValidate = certificate as X509Certificate2
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
return customChain.Build(certificateToValidate);
};
}
else if (!options.RequireCertificateValidation)
{
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
}
}
return handler;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}
@@ -1,15 +1,17 @@
using MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts;
namespace MxGateway.Client;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Exposes the protocol versions compiled into this client package.
/// </summary>
public static class MxGatewayClientContractInfo
{
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
public const uint GatewayProtocolVersion =
GatewayContractInfo.GatewayProtocolVersion;
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
public const uint WorkerProtocolVersion =
GatewayContractInfo.WorkerProtocolVersion;
}
@@ -0,0 +1,141 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
/// </summary>
public sealed class MxGatewayClientOptions
{
/// <summary>
/// Gets the gateway endpoint URI (required).
/// </summary>
public required Uri Endpoint { get; init; }
/// <summary>
/// Gets the API key for gateway authentication (required).
/// </summary>
public required string ApiKey { get; init; }
/// <summary>
/// Gets a value indicating whether to use TLS for the gateway connection.
/// </summary>
public bool UseTls { get; init; }
/// <summary>
/// Gets the path to a CA certificate file for custom certificate validation.
/// </summary>
public string? CaCertificatePath { get; init; }
/// <summary>
/// When true, TLS connections without a pinned <see cref="CaCertificatePath"/>
/// use the OS trust store. When false (default), the gateway certificate is
/// accepted without verification — appropriate for this internal tool's
/// auto-generated self-signed certificate. Pinning a CA always verifies.
/// </summary>
public bool RequireCertificateValidation { get; init; }
/// <summary>
/// Gets the server name override for SNI during TLS handshake.
/// </summary>
public string? ServerNameOverride { get; init; }
/// <summary>
/// Gets the timeout for establishing connection to the gateway.
/// </summary>
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets the default timeout for unary gRPC calls.
/// </summary>
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets the optional timeout for streaming gRPC calls.
/// </summary>
public TimeSpan? StreamTimeout { get; init; }
/// <summary>
/// Gets the maximum size in bytes for gRPC messages.
/// </summary>
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
/// <summary>
/// Gets the retry configuration for safe unary calls.
/// </summary>
public MxGatewayClientRetryOptions Retry { get; init; } = new();
/// <summary>
/// Gets the logger factory for diagnostic logging.
/// </summary>
public ILoggerFactory? LoggerFactory { get; init; }
/// <summary>
/// Validates the client options for consistency and correctness.
/// </summary>
/// <exception cref="ArgumentNullException">Endpoint is null.</exception>
/// <exception cref="ArgumentException">Options are invalid or inconsistent.</exception>
/// <exception cref="ArgumentOutOfRangeException">Timeout values are not greater than zero.</exception>
public void Validate()
{
ArgumentNullException.ThrowIfNull(Endpoint);
if (!Endpoint.IsAbsoluteUri)
{
throw new ArgumentException(
"The gateway endpoint must be an absolute URI.",
nameof(Endpoint));
}
if (string.IsNullOrWhiteSpace(ApiKey))
{
throw new ArgumentException(
"The gateway API key must not be empty.",
nameof(ApiKey));
}
if (ConnectTimeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(ConnectTimeout),
"The connect timeout must be greater than zero.");
}
if (DefaultCallTimeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(DefaultCallTimeout),
"The default call timeout must be greater than zero.");
}
if (StreamTimeout is not null && StreamTimeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(StreamTimeout),
"The stream timeout must be greater than zero when configured.");
}
if (MaxGrpcMessageBytes <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(MaxGrpcMessageBytes),
"The maximum gRPC message size must be greater than zero.");
}
if (UseTls && Endpoint.Scheme != Uri.UriSchemeHttps)
{
throw new ArgumentException(
"UseTls requires an https gateway endpoint.",
nameof(Endpoint));
}
if (!UseTls && Endpoint.Scheme == Uri.UriSchemeHttps)
{
throw new ArgumentException(
"An https gateway endpoint requires UseTls.",
nameof(Endpoint));
}
Retry.Validate();
}
}
@@ -0,0 +1,49 @@
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary>
public sealed class MxGatewayClientRetryOptions
{
/// <summary>Gets the maximum number of attempts (initial + retries); default is 2.</summary>
public int MaxAttempts { get; init; } = 2;
/// <summary>Gets the initial delay between retry attempts; default is 200 milliseconds.</summary>
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(200);
/// <summary>Gets the maximum delay between retry attempts; default is 2 seconds.</summary>
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Gets a value indicating whether to add randomness to retry delays; default is true.</summary>
public bool UseJitter { get; init; } = true;
/// <summary>Validates the retry options and throws if any constraint is violated.</summary>
public void Validate()
{
if (MaxAttempts <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(MaxAttempts),
"The retry max attempts value must be greater than zero.");
}
if (Delay <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(Delay),
"The retry delay must be greater than zero.");
}
if (MaxDelay <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(MaxDelay),
"The retry max delay must be greater than zero.");
}
if (MaxDelay < Delay)
{
throw new ArgumentOutOfRangeException(
nameof(MaxDelay),
"The retry max delay must be greater than or equal to the retry delay.");
}
}
}
@@ -0,0 +1,68 @@
using Grpc.Core;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Polly;
using Polly.Retry;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
internal static class MxGatewayClientRetryPolicy
{
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
/// <param name="logger">Optional logger for retry diagnostics.</param>
public static ResiliencePipeline Create(
MxGatewayClientRetryOptions options,
ILogger? logger)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
return new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = Math.Max(0, options.MaxAttempts - 1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = options.UseJitter,
Delay = options.Delay,
MaxDelay = options.MaxDelay,
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsTransientGrpcFailure),
OnRetry = args =>
{
logger?.LogDebug(
args.Outcome.Exception,
"Retrying MXAccess Gateway client call after transient gRPC failure. Attempt {Attempt}.",
args.AttemptNumber + 1);
return default;
},
})
.Build();
}
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
/// <param name="kind">The command kind to check.</param>
public static bool IsRetryableCommand(MxCommandKind kind)
{
return kind is MxCommandKind.Ping
or MxCommandKind.GetSessionState
or MxCommandKind.GetWorkerInfo;
}
private static bool IsTransientGrpcFailure(Exception exception)
{
return exception switch
{
RpcException rpcException => IsTransientStatus(rpcException.StatusCode),
MxGatewayException { InnerException: RpcException rpcException } => IsTransientStatus(rpcException.StatusCode),
_ => false,
};
}
private static bool IsTransientStatus(StatusCode statusCode)
{
return statusCode is StatusCode.Unavailable
or StatusCode.DeadlineExceeded
or StatusCode.ResourceExhausted;
}
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary>
public class MxGatewayCommandException : MxGatewayException
{
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the command failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewayCommandException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -0,0 +1,82 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Exception thrown when a gateway RPC call fails or returns an error status.
/// </summary>
public class MxGatewayException : Exception
{
/// <summary>
/// Initializes a new instance of the MxGatewayException class with the specified message.
/// </summary>
/// <param name="message">Diagnostic message describing the failure.</param>
public MxGatewayException(string message)
: base(message)
{
Statuses = [];
}
/// <summary>
/// Initializes a new instance of the MxGatewayException class with the specified message and inner exception.
/// </summary>
/// <param name="message">Diagnostic message describing the failure.</param>
/// <param name="innerException">Underlying exception that caused this failure.</param>
public MxGatewayException(string message, Exception? innerException)
: base(message, innerException)
{
Statuses = [];
}
/// <summary>
/// Initializes a new instance of the MxGatewayException class with full diagnostic information.
/// </summary>
/// <param name="message">Diagnostic message describing the failure.</param>
/// <param name="sessionId">Session ID associated with the exception, if available.</param>
/// <param name="correlationId">Correlation ID associated with the exception, if available.</param>
/// <param name="protocolStatus">Protocol-level status returned by the gateway, if available.</param>
/// <param name="hResult">HRESULT code returned by the worker or MXAccess, if available.</param>
/// <param name="statuses">List of MXAccess status codes returned by the operation.</param>
/// <param name="innerException">Underlying exception that caused this failure.</param>
public MxGatewayException(
string message,
string? sessionId,
string? correlationId,
ProtocolStatus? protocolStatus,
int? hResult,
IReadOnlyList<MxStatusProxy> statuses,
Exception? innerException = null)
: base(message, innerException)
{
SessionId = sessionId;
CorrelationId = correlationId;
ProtocolStatus = protocolStatus;
HResultCode = hResult;
Statuses = statuses;
}
/// <summary>
/// Gets the session ID associated with the exception, if available.
/// </summary>
public string? SessionId { get; }
/// <summary>
/// Gets the correlation ID associated with the exception, if available.
/// </summary>
public string? CorrelationId { get; }
/// <summary>
/// Gets the protocol-level status returned by the gateway, if available.
/// </summary>
public ProtocolStatus? ProtocolStatus { get; }
/// <summary>
/// Gets the HRESULT code returned by the worker or MXAccess, if available.
/// </summary>
public int? HResultCode { get; }
/// <summary>
/// Gets the list of MXAccess status codes returned by the operation.
/// </summary>
public IReadOnlyList<MxStatusProxy> Statuses { get; }
}
@@ -0,0 +1,844 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Represents one gateway-backed MXAccess session.
/// </summary>
public sealed class MxGatewaySession : IAsyncDisposable
{
private readonly MxGatewayClient _client;
private readonly SemaphoreSlim _closeLock = new(1, 1);
private CloseSessionReply? _closeReply;
/// <summary>
/// Initializes a new session backed by the given MXAccess gateway client.
/// </summary>
/// <param name="client">The gateway client used for commands and events.</param>
/// <param name="openSessionReply">The server's session creation response.</param>
internal MxGatewaySession(
MxGatewayClient client,
OpenSessionReply openSessionReply)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
OpenSessionReply = openSessionReply ?? throw new ArgumentNullException(nameof(openSessionReply));
}
/// <summary>
/// The session ID assigned by the gateway.
/// </summary>
public string SessionId => OpenSessionReply.SessionId;
/// <summary>
/// The server's session creation response containing metadata.
/// </summary>
public OpenSessionReply OpenSessionReply { get; }
/// <summary>
/// Closes the session on the gateway. Idempotent.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The server's close-session reply.</returns>
public async Task<CloseSessionReply> CloseAsync(CancellationToken cancellationToken = default)
{
if (_closeReply is not null)
{
return _closeReply;
}
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_closeReply is not null)
{
return _closeReply;
}
_closeReply = await _client.CloseSessionRawAsync(
new CloseSessionRequest { SessionId = SessionId },
cancellationToken)
.ConfigureAwait(false);
return _closeReply;
}
finally
{
_closeLock.Release();
}
}
/// <summary>
/// Registers a client with the MXAccess session, returning a ServerHandle.
/// </summary>
/// <param name="clientName">Name to register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The server handle assigned to the registered client.</returns>
public async Task<int> RegisterAsync(
string clientName,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
}
/// <summary>
/// Registers a client with the MXAccess session without error checking.
/// </summary>
/// <param name="clientName">Name to register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> RegisterRawAsync(
string clientName,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientName);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand { ClientName = clientName },
},
cancellationToken);
}
/// <summary>
/// Adds an item to the MXAccess session, returning an ItemHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item handle assigned to the new item.</returns>
public async Task<int> AddItemAsync(
int serverHandle,
string itemDefinition,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AddItemRawAsync(
serverHandle,
itemDefinition,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
}
/// <summary>
/// Adds an item to the MXAccess session without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AddItemRawAsync(
int serverHandle,
string itemDefinition,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = serverHandle,
ItemDefinition = itemDefinition,
},
},
cancellationToken);
}
/// <summary>
/// Adds an item with context to the MXAccess session, returning an ItemHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item handle assigned to the new item.</returns>
public async Task<int> AddItem2Async(
int serverHandle,
string itemDefinition,
string itemContext,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AddItem2RawAsync(
serverHandle,
itemDefinition,
itemContext,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
}
/// <summary>
/// Adds an item with context to the MXAccess session without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AddItem2RawAsync(
int serverHandle,
string itemDefinition,
string itemContext,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AddItem2,
AddItem2 = new AddItem2Command
{
ServerHandle = serverHandle,
ItemDefinition = itemDefinition,
ItemContext = itemContext ?? string.Empty,
},
},
cancellationToken);
}
/// <summary>
/// Subscribes to events for an item (advises in MXAccess terminology).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task AdviseAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AdviseRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Subscribes to events for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AdviseRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Advise,
Advise = new AdviseCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Unsubscribes from events for an item (unadvises in MXAccess terminology).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task UnAdviseAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await UnAdviseRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Unsubscribes from events for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> UnAdviseRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.UnAdvise,
UnAdvise = new UnAdviseCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Removes an item from the MXAccess session.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task RemoveItemAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await RemoveItemRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Removes an item from the MXAccess session without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> RemoveItemRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.RemoveItem,
RemoveItem = new RemoveItemCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Adds multiple items to the MXAccess session in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="tagAddresses">The item tag addresses to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tagAddresses);
AddItemBulkCommand command = new() { ServerHandle = serverHandle };
command.TagAddresses.Add(tagAddresses);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AddItemBulk,
AddItemBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AddItemBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Advises multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandles">The ItemHandles to advise.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(itemHandles);
AdviseItemBulkCommand command = new() { ServerHandle = serverHandle };
command.ItemHandles.Add(itemHandles);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AdviseItemBulk,
AdviseItemBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.AdviseItemBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Removes multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandles">The ItemHandles to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(itemHandles);
RemoveItemBulkCommand command = new() { ServerHandle = serverHandle };
command.ItemHandles.Add(itemHandles);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.RemoveItemBulk,
RemoveItemBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.RemoveItemBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Unadvises multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandles">The ItemHandles to unadvise.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(itemHandles);
UnAdviseItemBulkCommand command = new() { ServerHandle = serverHandle };
command.ItemHandles.Add(itemHandles);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.UnAdviseItemBulk,
UnAdviseItemBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.UnAdviseItemBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Adds and advises multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="tagAddresses">The item tag addresses to add and advise.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tagAddresses);
SubscribeBulkCommand command = new() { ServerHandle = serverHandle };
command.TagAddresses.Add(tagAddresses);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.SubscribeBulk,
SubscribeBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.SubscribeBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Unadvises and removes multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandles">The ItemHandles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(itemHandles);
UnsubscribeBulkCommand command = new() { ServerHandle = serverHandle };
command.ItemHandles.Add(itemHandles);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.UnsubscribeBulk,
UnsubscribeBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.UnsubscribeBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
/// Protocol-level failures still throw via EnsureProtocolSuccess.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, and user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
int serverHandle,
IReadOnlyList<WriteBulkEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
WriteBulkCommand command = new() { ServerHandle = serverHandle };
command.Entries.Add(entries);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.WriteBulk,
WriteBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.WriteBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.
/// Per-item failures appear as <see cref="BulkWriteResult"/> entries with
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, and user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle,
IReadOnlyList<Write2BulkEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
Write2BulkCommand command = new() { ServerHandle = serverHandle };
command.Entries.Add(entries);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Write2Bulk,
Write2Bulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Write2Bulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk WriteSecured — sequential MXAccess WriteSecured per entry.
/// Credential-sensitive values must never reach logs; the client mirrors
/// the single-item WriteSecured redaction contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, current user id, and verifier user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
WriteSecuredBulkCommand command = new() { ServerHandle = serverHandle };
command.Entries.Add(entries);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.WriteSecuredBulk,
WriteSecuredBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.
/// Same redaction rules as <see cref="WriteSecuredBulkAsync"/>.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="entries">Per-item write entries; each carries the item handle, value, timestamp, current user id, and verifier user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkWriteResult"/> per requested entry, in request order.</returns>
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
WriteSecured2BulkCommand command = new() { ServerHandle = serverHandle };
command.Entries.Add(entries);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.WriteSecured2Bulk,
WriteSecured2Bulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.WriteSecured2Bulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Bulk Read — snapshot the current value for each requested tag.
/// Returns the cached OnDataChange value when the tag is already advised
/// (<c>WasCached = true</c>), otherwise the worker takes the full AddItem +
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
/// failures (timeout, invalid tag) appear as <see cref="BulkReadResult"/>
/// entries with <c>WasSuccessful = false</c>; the call never throws on
/// per-tag errors.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="tagAddresses">Tag addresses to read (one per result).</param>
/// <param name="timeout">Per-call timeout for the snapshot lifecycle path; <see cref="TimeSpan.Zero"/> uses the gateway default.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>One <see cref="BulkReadResult"/> per requested tag, in request order.</returns>
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(tagAddresses);
ReadBulkCommand command = new()
{
ServerHandle = serverHandle,
TimeoutMs = timeout <= TimeSpan.Zero ? 0u : (uint)Math.Min(timeout.TotalMilliseconds, uint.MaxValue),
};
command.TagAddresses.Add(tagAddresses);
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.ReadBulk,
ReadBulk = command,
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.ReadBulk?.Results.ToArray() ?? [];
}
/// <summary>
/// Writes a value to an item on the MXAccess server.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The value to write.</param>
/// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteAsync(
int serverHandle,
int itemHandle,
MxValue value,
int userId,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await WriteRawAsync(serverHandle, itemHandle, value, userId, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Writes a value to an item on the MXAccess server without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The value to write.</param>
/// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> WriteRawAsync(
int serverHandle,
int itemHandle,
MxValue value,
int userId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(value);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Value = value,
UserId = userId,
},
},
cancellationToken);
}
/// <summary>
/// Writes a value and timestamp to an item on the MXAccess server.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task Write2Async(
int serverHandle,
int itemHandle,
MxValue value,
MxValue timestampValue,
int userId,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await Write2RawAsync(
serverHandle,
itemHandle,
value,
timestampValue,
userId,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Writes a value and timestamp to an item on the MXAccess server without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> Write2RawAsync(
int serverHandle,
int itemHandle,
MxValue value,
MxValue timestampValue,
int userId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(value);
ArgumentNullException.ThrowIfNull(timestampValue);
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Write2,
Write2 = new Write2Command
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Value = value,
TimestampValue = timestampValue,
UserId = userId,
},
},
cancellationToken);
}
/// <summary>
/// Invokes an MXAccess command on this session.
/// </summary>
/// <param name="request">The command request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return _client.InvokeAsync(request, cancellationToken);
}
/// <summary>
/// Streams events from the worker for this session, optionally starting after a given sequence number.
/// </summary>
/// <param name="afterWorkerSequence">The sequence number to stream from. Defaults to 0.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of events.</returns>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
ulong afterWorkerSequence = 0,
CancellationToken cancellationToken = default)
{
return _client.StreamEventsAsync(
new StreamEventsRequest
{
SessionId = SessionId,
AfterWorkerSequence = afterWorkerSequence,
},
cancellationToken);
}
/// <summary>
/// Closes the session and releases resources.
/// </summary>
public async ValueTask DisposeAsync()
{
await CloseAsync().ConfigureAwait(false);
_closeLock.Dispose();
}
private Task<MxCommandReply> InvokeCommandAsync(
MxCommand command,
CancellationToken cancellationToken)
{
return _client.InvokeAsync(
new MxCommandRequest
{
SessionId = SessionId,
ClientCorrelationId = Guid.NewGuid().ToString("N"),
Command = command,
},
cancellationToken);
}
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary>
public sealed class MxGatewaySessionException : MxGatewayException
{
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the session failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewaySessionException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -0,0 +1,34 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary>
public sealed class MxGatewayWorkerException : MxGatewayException
{
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the worker failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewayWorkerException(
string message,
string? sessionId = null,
string? correlationId = null,
ProtocolStatus? protocolStatus = null,
int? hResult = null,
IReadOnlyList<MxStatusProxy>? statuses = null,
Exception? innerException = null)
: base(
message,
sessionId,
correlationId,
protocolStatus,
hResult,
statuses ?? [],
innerException)
{
}
}
@@ -0,0 +1,30 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>Extension methods for MxStatusProxy values.</summary>
public static class MxStatusProxyExtensions
{
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
/// <param name="status">The status to check.</param>
public static bool IsSuccess(this MxStatusProxy status)
{
ArgumentNullException.ThrowIfNull(status);
return status.Success != 0
&& status.Category is MxStatusCategory.Ok;
}
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
/// <param name="status">The status to summarize.</param>
public static string ToDiagnosticSummary(this MxStatusProxy status)
{
ArgumentNullException.ThrowIfNull(status);
string diagnosticText = string.IsNullOrWhiteSpace(status.DiagnosticText)
? "no diagnostic text"
: status.DiagnosticText;
return $"{status.Category} by {status.DetectedBy}; detail={status.Detail}; {diagnosticText}";
}
}
@@ -0,0 +1,363 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Creates and projects gateway MXAccess values without hiding the raw
/// protobuf value carried by command replies and events.
/// </summary>
public static class MxValueExtensions
{
/// <summary>
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
/// </summary>
/// <param name="value">Scalar boolean value to wrap.</param>
public static MxValue ToMxValue(this bool value)
{
return new MxValue
{
DataType = MxDataType.Boolean,
VariantType = "VT_BOOL",
BoolValue = value,
};
}
/// <summary>
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
/// </summary>
/// <param name="value">32-bit integer value to wrap.</param>
public static MxValue ToMxValue(this int value)
{
return new MxValue
{
DataType = MxDataType.Integer,
VariantType = "VT_I4",
Int32Value = value,
};
}
/// <summary>
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
/// </summary>
/// <param name="value">64-bit integer value to wrap.</param>
public static MxValue ToMxValue(this long value)
{
return new MxValue
{
DataType = MxDataType.Integer,
VariantType = "VT_I8",
Int64Value = value,
};
}
/// <summary>
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
/// </summary>
/// <param name="value">Single-precision floating-point value to wrap.</param>
public static MxValue ToMxValue(this float value)
{
return new MxValue
{
DataType = MxDataType.Float,
VariantType = "VT_R4",
FloatValue = value,
};
}
/// <summary>
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
/// </summary>
/// <param name="value">Double-precision floating-point value to wrap.</param>
public static MxValue ToMxValue(this double value)
{
return new MxValue
{
DataType = MxDataType.Double,
VariantType = "VT_R8",
DoubleValue = value,
};
}
/// <summary>
/// Converts a string value to an MxValue with MxDataType.String.
/// </summary>
/// <param name="value">String value to wrap.</param>
public static MxValue ToMxValue(this string value)
{
ArgumentNullException.ThrowIfNull(value);
return new MxValue
{
DataType = MxDataType.String,
VariantType = "VT_BSTR",
StringValue = value,
};
}
/// <summary>
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
/// </summary>
/// <param name="value">DateTimeOffset value to wrap.</param>
public static MxValue ToMxValue(this DateTimeOffset value)
{
return new MxValue
{
DataType = MxDataType.Time,
VariantType = "VT_DATE",
TimestampValue = Timestamp.FromDateTimeOffset(value),
};
}
/// <summary>
/// Converts a DateTime value to an MxValue with MxDataType.Time.
/// </summary>
/// <param name="value">DateTime value to wrap.</param>
public static MxValue ToMxValue(this DateTime value)
{
return new DateTimeOffset(
value.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
: value.ToUniversalTime())
.ToMxValue();
}
/// <summary>
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
/// </summary>
/// <param name="values">Array of boolean values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new BoolArray();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Boolean, "VT_ARRAY|VT_BOOL", values.Count, new MxArray
{
ElementDataType = MxDataType.Boolean,
VariantType = "VT_ARRAY|VT_BOOL",
BoolValues = array,
});
}
/// <summary>
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
/// </summary>
/// <param name="values">Array of 32-bit integer values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<int> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new Int32Array();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I4", values.Count, new MxArray
{
ElementDataType = MxDataType.Integer,
VariantType = "VT_ARRAY|VT_I4",
Int32Values = array,
});
}
/// <summary>
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
/// </summary>
/// <param name="values">Array of 64-bit integer values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<long> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new Int64Array();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I8", values.Count, new MxArray
{
ElementDataType = MxDataType.Integer,
VariantType = "VT_ARRAY|VT_I8",
Int64Values = array,
});
}
/// <summary>
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
/// </summary>
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<float> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new FloatArray();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Float, "VT_ARRAY|VT_R4", values.Count, new MxArray
{
ElementDataType = MxDataType.Float,
VariantType = "VT_ARRAY|VT_R4",
FloatValues = array,
});
}
/// <summary>
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
/// </summary>
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<double> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new DoubleArray();
array.Values.Add(values);
return CreateArrayValue(MxDataType.Double, "VT_ARRAY|VT_R8", values.Count, new MxArray
{
ElementDataType = MxDataType.Double,
VariantType = "VT_ARRAY|VT_R8",
DoubleValues = array,
});
}
/// <summary>
/// Converts a string array to an MxValue with MxDataType.String.
/// </summary>
/// <param name="values">Array of string values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<string> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new StringArray();
array.Values.Add(values);
return CreateArrayValue(MxDataType.String, "VT_ARRAY|VT_BSTR", values.Count, new MxArray
{
ElementDataType = MxDataType.String,
VariantType = "VT_ARRAY|VT_BSTR",
StringValues = array,
});
}
/// <summary>
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
/// </summary>
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
{
ArgumentNullException.ThrowIfNull(values);
var array = new TimestampArray();
array.Values.Add(values.Select(Timestamp.FromDateTimeOffset));
return CreateArrayValue(MxDataType.Time, "VT_ARRAY|VT_DATE", values.Count, new MxArray
{
ElementDataType = MxDataType.Time,
VariantType = "VT_ARRAY|VT_DATE",
TimestampValues = array,
});
}
/// <summary>
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
/// </summary>
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
public static string GetProjectionKind(this MxValue value)
{
ArgumentNullException.ThrowIfNull(value);
return value.KindCase switch
{
MxValue.KindOneofCase.BoolValue => "boolValue",
MxValue.KindOneofCase.Int32Value => "int32Value",
MxValue.KindOneofCase.Int64Value => "int64Value",
MxValue.KindOneofCase.FloatValue => "floatValue",
MxValue.KindOneofCase.DoubleValue => "doubleValue",
MxValue.KindOneofCase.StringValue => "stringValue",
MxValue.KindOneofCase.TimestampValue => "timestampValue",
MxValue.KindOneofCase.ArrayValue => "arrayValue",
MxValue.KindOneofCase.RawValue => "rawValue",
_ => value.IsNull ? "nullValue" : "unspecified",
};
}
/// <summary>
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
/// </summary>
/// <param name="value">The MxValue to convert.</param>
public static object? ToClrValue(this MxValue value)
{
ArgumentNullException.ThrowIfNull(value);
return value.KindCase switch
{
MxValue.KindOneofCase.BoolValue => value.BoolValue,
MxValue.KindOneofCase.Int32Value => value.Int32Value,
MxValue.KindOneofCase.Int64Value => value.Int64Value,
MxValue.KindOneofCase.FloatValue => value.FloatValue,
MxValue.KindOneofCase.DoubleValue => value.DoubleValue,
MxValue.KindOneofCase.StringValue => value.StringValue,
MxValue.KindOneofCase.TimestampValue => value.TimestampValue.ToDateTimeOffset(),
MxValue.KindOneofCase.ArrayValue => value.ArrayValue.ToClrArrayValue(),
MxValue.KindOneofCase.RawValue => value.RawValue.ToByteArray(),
_ => value.IsNull ? null : value,
};
}
/// <summary>
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
/// </summary>
/// <param name="array">The MxArray to convert.</param>
public static object? ToClrArrayValue(this MxArray array)
{
ArgumentNullException.ThrowIfNull(array);
return array.ValuesCase switch
{
MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(),
MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(),
MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(),
MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(),
MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(),
MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(),
MxArray.ValuesOneofCase.TimestampValues => array.TimestampValues.Values
.Select(timestamp => timestamp.ToDateTimeOffset())
.ToArray(),
MxArray.ValuesOneofCase.RawValues => array.RawValues.Values
.Select(value => value.ToByteArray())
.ToArray(),
_ => null,
};
}
/// <summary>
/// Creates an MxValue with MxDataType.Unknown from raw byte data, variant type, and diagnostic info.
/// </summary>
/// <param name="value">Raw byte data representing the value.</param>
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
/// <param name="rawDataType">Optional MXAccess data type override.</param>
public static MxValue ToRawMxValue(
byte[] value,
string variantType,
string rawDiagnostic,
int rawDataType = 0)
{
ArgumentNullException.ThrowIfNull(value);
return new MxValue
{
DataType = MxDataType.Unknown,
VariantType = variantType,
RawDiagnostic = rawDiagnostic,
RawDataType = rawDataType,
RawValue = ByteString.CopyFrom(value),
};
}
private static MxValue CreateArrayValue(
MxDataType dataType,
string variantType,
int length,
MxArray array)
{
array.Dimensions.Add((uint)length);
return new MxValue
{
DataType = dataType,
VariantType = variantType,
ArrayValue = array,
};
}
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.Client.Tests")]
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\ZB.MOM.WW.MxGateway.Contracts\ZB.MOM.WW.MxGateway.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
@@ -6,8 +6,8 @@ Provide an idiomatic Go client module for MXAccess Gateway, plus a test CLI and
unit tests. The Go client should be suitable for services and command-line
automation.
Follow the [Go Style Guide](./style-guides/GoStyleGuide.md) for handwritten
code and the [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md) for
Follow the [Go Style Guide](../../docs/style-guides/GoStyleGuide.md) for handwritten
code and the [Protobuf Style Guide](../../docs/style-guides/ProtobufStyleGuide.md) for
generated contract inputs.
## Module Layout
@@ -74,6 +74,12 @@ func (s *Session) Unregister(ctx context.Context, serverHandle int32) error
func (s *Session) AddItem(ctx context.Context, serverHandle int32, item string) (int32, error)
func (s *Session) AddItem2(ctx context.Context, serverHandle int32, item, context string) (int32, error)
func (s *Session) Advise(ctx context.Context, serverHandle, itemHandle int32) error
func (s *Session) AddItemBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*pb.SubscribeResult, error)
func (s *Session) AdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*pb.SubscribeResult, error)
func (s *Session) RemoveItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*pb.SubscribeResult, error)
func (s *Session) UnAdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*pb.SubscribeResult, error)
func (s *Session) SubscribeBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*pb.SubscribeResult, error)
func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*pb.SubscribeResult, error)
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value Value, userID int32) error
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error)
func (s *Session) Close(ctx context.Context) error
@@ -98,6 +104,23 @@ Support:
- `credentials.NewClientTLSFromFile`,
- custom `tls.Config` for advanced callers.
### Trust posture
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). To make that usable, TLS is **lenient by default**: when `Plaintext` is
`false` and no `CACertFile`/`TLSConfig`/`TransportCredentials` is supplied,
`buildCredentials` dials with `tls.Config{InsecureSkipVerify: true}` (carrying
`ServerNameOverride` as the SNI when set), so the gateway's self-signed
certificate is accepted without verification.
To verify the gateway instead:
- set `CACertFile` to pin a CA (full verification against that root), or
- set `RequireCertificateValidation: true` to verify against the OS/system trust
roots without pinning.
Pinning a CA always wins over the lenient default.
## Streaming
`Events(ctx)` should return a receive channel of:
@@ -170,3 +193,10 @@ MXGATEWAY_INTEGRATION=1
Integration test should run `OpenSession`, `Register`, `AddItem`, `Advise`,
bounded `StreamEvents`, and `CloseSession`.
## Related Documentation
- [Client Libraries Detailed Design](../../docs/ClientLibrariesDesign.md)
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
- [Client Packaging](../../docs/ClientPackaging.md)
- [Go Style Guide](../../docs/style-guides/GoStyleGuide.md)
+322
View File
@@ -0,0 +1,322 @@
# Go Client
The Go client module contains the generated MXAccess Gateway protobuf bindings,
a small handwritten `mxgateway` package, and the `mxgw-go` test CLI scaffold.
The module uses the shared proto inputs documented in
`../../docs/ClientProtoGeneration.md` so gateway and client contracts stay in
sync.
## Layout
```text
clients/go/
go.mod
generate-proto.ps1
internal/generated/
mxgateway/
cmd/mxgw-go/
```
`internal/generated` contains code produced by `protoc`, `protoc-gen-go`, and
`protoc-gen-go-grpc`. Do not edit generated files by hand.
## Regenerating Protobuf Bindings
Run generation after the shared `.proto` files or the Go output path changes:
```powershell
./generate-proto.ps1
```
The script uses the tool paths recorded in `../../docs/ToolchainLinks.md`.
## Build And Test
Run the Go module checks from `clients/go`:
```powershell
go test ./...
go build ./...
go vet ./...
```
The tests parse the shared JSON fixtures, exercise value and status conversion,
use `bufconn` for fake gateway auth and streaming behavior, and cover CLI JSON
redaction.
## Packaging
Build a local CLI executable from `clients/go`:
```powershell
New-Item -ItemType Directory -Force ../../artifacts/clients/go | Out-Null
go build -o ../../artifacts/clients/go/mxgw-go.exe ./cmd/mxgw-go
```
Install the CLI into the active `GOBIN` or `GOPATH/bin`:
```powershell
go install ./cmd/mxgw-go
```
Other Go modules can consume the library package with the module path
`gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway`.
## Client API
Use `mxgateway.Dial` with `mxgateway.Options` to configure plaintext or TLS
transport, API-key metadata, dial timeout, and per-call timeout:
```go
client, err := mxgateway.Dial(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
```
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`Plaintext: false`) with
no `CACertFile`/`TLSConfig` accepts whatever certificate the gateway presents
(`InsecureSkipVerify`, with `ServerNameOverride` as the SNI when set). To verify
instead, set `CACertFile` to pin a CA, or set `RequireCertificateValidation:
true` to verify against the OS/system trust roots without pinning. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
`Client.OpenSession` returns a `Session` with helpers for `Register`,
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. Raw protobuf messages remain available through the
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply.
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
call returns a `StreamAlarmsClient`; cancel its context to terminate the
stream. All three pass straight through to the gateway's central alarm
monitor.
## Galaxy Repository browse
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
read-only metadata-only browse over the AVEVA System Platform Galaxy
Repository. It uses the same API-key authentication as the MXAccess Gateway
and requires the `metadata:read` scope. Use `mxgateway.DialGalaxy` to obtain a
`*GalaxyClient` that mirrors the connection-management conventions of
`Client`:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
return err
}
defer galaxy.Close()
ok, err := galaxy.TestConnection(ctx)
deployTime, present, err := galaxy.GetLastDeployTime(ctx)
objects, err := galaxy.DiscoverHierarchy(ctx)
```
`GetLastDeployTime` returns `(time.Time{}, false, nil)` when the server
reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
the generated `*GalaxyObject` slice with each object's dynamic attributes
populated for direct contract access.
### Browsing lazily
For UI trees or OPC UA bridges, use `BrowseChildren` to walk one level at a
time instead of loading the full hierarchy. Pass an empty request for root
objects; subsequent calls set `ParentGobjectId`, `ParentTagName`, or
`ParentContainedPath`. Filter fields match `DiscoverHierarchy`. Each response
pairs `Children` with `ChildHasChildren` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```go
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated/galaxy_repository/v1"
reply, err := galaxy.BrowseChildren(ctx, &pb.BrowseChildrenRequest{})
if err != nil {
return err
}
for i, child := range reply.GetChildren() {
fmt.Printf("%s expand=%v\n", child.GetTagName(), reply.GetChildHasChildren()[i])
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
log.Fatal(err)
}
defer galaxy.Close()
roots, err := galaxy.Browse(ctx, nil)
if err != nil {
log.Fatal(err)
}
for _, root := range roots {
if root.HasChildrenHint() {
if err := root.Expand(ctx); err != nil {
log.Fatal(err)
}
}
for _, child := range root.Children() {
kind := "leaf"
if child.HasChildrenHint() {
kind = "has children"
}
fmt.Printf("%s (%s)\n", child.Object().GetTagName(), kind)
}
}
```
`Expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`Browse` again from the root.
### Watching deploy events
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
bootstrap event with the current Galaxy state immediately on subscribe, then
one `DeployEvent` per new deploy. `Sequence` is monotonic per server start;
gaps signal dropped events. Pass a non-nil `lastSeenDeployTime` to suppress the
bootstrap event when resuming from a known checkpoint:
```go
streamCtx, cancel := context.WithCancel(ctx)
defer cancel()
events, errs, err := galaxy.WatchDeployEvents(streamCtx, nil)
if err != nil {
return err
}
for {
select {
case ev, ok := <-events:
if !ok {
return nil // stream completed (server EOF or ctx cancelled)
}
log.Printf("seq=%d objects=%d attrs=%d",
ev.GetSequence(), ev.GetObjectCount(), ev.GetAttributeCount())
case streamErr := <-errs:
if streamErr != nil {
return streamErr // *GatewayError
}
case <-ctx.Done():
return ctx.Err()
}
}
```
Cancel the supplied context to tear down the stream cleanly. Both channels
close after EOF, cancellation, or a terminal error; surfaced errors are wrapped
in `*GatewayError`.
The CLI exposes the same RPC via `galaxy-watch`:
```powershell
go run ./cmd/mxgw-go galaxy-watch -plaintext
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5
```
The command runs until Ctrl+C (or the optional `-limit` is reached) and prints
one line per event in text mode or one JSON object per event with `-json`.
## CLI
The `mxgw-go` CLI emits JSON with redacted API keys for commands that connect to
the gateway:
```powershell
go run ./cmd/mxgw-go version -json
go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -plaintext -json
go run ./cmd/mxgw-go register -session-id <id> -client-name mxgw-go -plaintext -json
go run ./cmd/mxgw-go add-item -session-id <id> -server-handle 1 -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -plaintext -json
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
go run ./cmd/mxgw-go galaxy-discover -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
```
Use `-api-key-env MXGATEWAY_API_KEY` or `-api-key <key>` when authentication is
enabled. CLI output redacts the key value and never writes the raw secret.
Use TLS options for a secured gateway:
```powershell
go run ./cmd/mxgw-go smoke -endpoint mxgateway.example.local:5001 -ca-cert C:\certs\mxgateway-ca.pem -server-name-override mxgateway.example.local -api-key-env MXGATEWAY_API_KEY -item Area001.Tag.Value -json
```
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
```powershell
$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
```
## Installing the Go client
The module is resolved directly from the git repo — no package registry:
````bash
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.0
````
Then import:
````go
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
````
If your build environment cannot reach `gitea.dohertylan.com` directly,
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
repo, or use `GONOSUMCHECK` + `GOPRIVATE` to bypass the checksum database
for the internal module path.
## Releasing a new version
Go modules in monorepo subdirectories use prefixed tags. To tag a release
from this repo:
````bash
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
````
The script validates semver, refuses to tag with uncommitted tracked
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
pushes it to origin.
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
- [Go Client Detailed Design](./GoClientDesign.md)
File diff suppressed because it is too large Load Diff
+296
View File
@@ -0,0 +1,296 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net"
"strings"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
)
func TestRunVersionJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := runWithIO(t.Context(), []string{"version", "-json"}, &stdout, &stderr); err != nil {
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
}
var output versionOutput
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("parse JSON: %v", err)
}
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
t.Fatalf("protocol versions were not populated: %+v", output)
}
}
func TestCommonOptionsRedactsAPIKey(t *testing.T) {
options, err := (&commonOptions{
Endpoint: "localhost:5000",
APIKey: "mxgw_super_secret",
Plaintext: true,
CallTimeout: "2s",
}).resolved()
if err != nil {
t.Fatalf("resolved() error = %v", err)
}
data, err := json.Marshal(options)
if err != nil {
t.Fatalf("marshal options: %v", err)
}
if strings.Contains(string(data), "super_secret") {
t.Fatalf("redacted JSON leaked API key: %s", data)
}
if !strings.Contains(string(data), "mxgw") {
t.Fatalf("redacted JSON did not preserve key shape: %s", data)
}
}
func TestRunBatchEmitsEORAfterVersion(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
in := strings.NewReader("version --json\n")
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "\n"+batchEOR+"\n") && !strings.HasSuffix(out, batchEOR+"\n") {
t.Fatalf("expected EOR marker %q in stdout; got: %q", batchEOR, out)
}
idx := strings.Index(out, batchEOR)
if idx <= 0 {
t.Fatalf("EOR marker not found or appeared before any output: %q", out)
}
payload := out[:idx]
var output versionOutput
if err := json.Unmarshal([]byte(payload), &output); err != nil {
t.Fatalf("parse JSON block before EOR: %v (payload=%q)", err, payload)
}
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
t.Fatalf("protocol versions were not populated: %+v", output)
}
}
func TestParseValueBuildsTypedValue(t *testing.T) {
value, err := parseValue("int32", "123")
if err != nil {
t.Fatalf("parseValue() error = %v", err)
}
if got := value.GetInt32Value(); got != 123 {
t.Fatalf("int32 value = %d, want 123", got)
}
}
// TestRunWriteBulkVariantGatesSecuredFlags pins the Client.Go-022 fix:
// secured-only flags must be unavailable on non-secured variants, and
// vice-versa, so a wrong-variant flag fails with a clean "flag provided
// but not defined" error instead of silently no-op'ing.
func TestRunWriteBulkVariantGatesSecuredFlags(t *testing.T) {
cases := []struct {
name string
args []string
}{
{
name: "write-bulk-rejects-current-user-id",
args: []string{"write-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write-bulk-rejects-verifier-user-id",
args: []string{"write-bulk", "-verifier-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write2-bulk-rejects-current-user-id",
args: []string{"write2-bulk", "-current-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write-secured-bulk-rejects-user-id",
args: []string{"write-secured-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
},
{
name: "write-secured2-bulk-rejects-user-id",
args: []string{"write-secured2-bulk", "-user-id", "5", "-item-handles", "1", "-values", "1"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), tc.args, &stdout, &stderr)
if err == nil {
t.Fatalf("runWithIO(%v) returned no error", tc.args)
}
if !strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("runWithIO(%v) error = %v; want 'flag provided but not defined'", tc.args, err)
}
})
}
}
// TestRunBenchReadBulkRespectsContextCancellation pins the Client.Go-023
// fix: the warm-up and steady-state wall-clock loops must honour ctx.Err()
// so an external cancel (Ctrl+C, parent-cancel from a cross-language bench
// driver) short-circuits the bench instead of spinning failing ReadBulk
// calls until the wall-clock deadline elapses.
func TestRunBenchReadBulkRespectsContextCancellation(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := grpc.NewServer()
fake := &benchFakeGateway{}
pb.RegisterMxAccessGatewayServer(server, fake)
go func() {
_ = server.Serve(listener)
}()
defer server.Stop()
defer listener.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Long warm-up + duration, so if the ctx.Err() guard were missing the
// loops would run for ~10s. With the guard, the cancel below short-
// circuits both loops within ~one ReadBulk iteration.
args := []string{
"bench-read-bulk",
"-endpoint", listener.Addr().String(),
"-plaintext",
"-api-key", "test",
"-warmup-seconds", "5",
"-duration-seconds", "5",
"-bulk-size", "1",
"-timeout-ms", "100",
}
// Cancel after a brief delay — far less than warmup+duration (10s).
go func() {
time.Sleep(150 * time.Millisecond)
cancel()
}()
var stdout, stderr bytes.Buffer
start := time.Now()
err = runWithIO(ctx, args, &stdout, &stderr)
elapsed := time.Since(start)
// With the ctx.Err() guard, the loops exit well before the wall-clock
// deadlines (warmup=5s + duration=5s = 10s). Allow generous slack for
// CI noise but assert clearly less than the un-guarded worst case.
if elapsed > 4*time.Second {
t.Fatalf("bench-read-bulk took %s after ctx cancel; want <4s (ctx.Err() guard missing?). err=%v stderr=%s", elapsed, err, stderr.String())
}
}
// benchFakeGateway is a minimal MxAccessGatewayServer that satisfies the
// bench-read-bulk session-setup sequence (OpenSession + Invoke for Register
// / SubscribeBulk / ReadBulk / UnsubscribeBulk / CloseSession).
type benchFakeGateway struct {
pb.UnimplementedMxAccessGatewayServer
}
func (g *benchFakeGateway) OpenSession(_ context.Context, _ *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
return &pb.OpenSessionReply{
SessionId: "bench-session",
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}, nil
}
func (g *benchFakeGateway) CloseSession(_ context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
return &pb.CloseSessionReply{
SessionId: req.GetSessionId(),
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}, nil
}
func (g *benchFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
kind := req.GetCommand().GetKind()
reply := &pb.MxCommandReply{
SessionId: req.GetSessionId(),
Kind: kind,
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
}
switch kind {
case pb.MxCommandKind_MX_COMMAND_KIND_REGISTER:
reply.Payload = &pb.MxCommandReply_Register{Register: &pb.RegisterReply{ServerHandle: 1}}
case pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK:
reply.Payload = &pb.MxCommandReply_SubscribeBulk{SubscribeBulk: &pb.BulkSubscribeReply{
Results: []*pb.SubscribeResult{{ServerHandle: 1, ItemHandle: 1, WasSuccessful: true}},
}}
case pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK:
reply.Payload = &pb.MxCommandReply_ReadBulk{ReadBulk: &pb.BulkReadReply{
Results: []*pb.BulkReadResult{{ItemHandle: 1, WasSuccessful: true, WasCached: true}},
}}
case pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK:
reply.Payload = &pb.MxCommandReply_UnsubscribeBulk{UnsubscribeBulk: &pb.BulkSubscribeReply{}}
}
return reply, nil
}
// TestRunBenchReadBulkRejectsNonPositiveBulkSize pins the Client.Go-023-adjacent
// positivity checks so they cannot drift while resolving the cancellation finding.
func TestRunBenchReadBulkRejectsNonPositiveBulkSize(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runWithIO(t.Context(), []string{"bench-read-bulk", "-bulk-size", "0"}, &stdout, &stderr)
if err == nil || !strings.Contains(err.Error(), "bulk-size must be positive") {
t.Fatalf("bench-read-bulk -bulk-size 0 error = %v", err)
}
}
// TestRunBatchSkipsBlankLinesAndContinuesUntilEOF pins the Client.Go-027 fix:
// a blank line in the middle of a batch session must NOT terminate the loop —
// only stdin EOF ends the session.
func TestRunBatchSkipsBlankLinesAndContinuesUntilEOF(t *testing.T) {
var stdout, stderr bytes.Buffer
// version -> blank -> version (a stray blank line in the middle of a
// programmatic session).
in := strings.NewReader("version --json\n\nversion --json\n")
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
}
out := stdout.String()
// Both version commands must have produced a result before the EOR sentinel.
if count := strings.Count(out, batchEOR); count != 2 {
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, blank line skipped); out = %q", count, out)
}
}
// TestRunBatchHandlesLongCommandLine pins the Client.Go-026 fix: a command
// line longer than the default bufio.Scanner token size (64 KiB) must not
// abort the batch session.
func TestRunBatchHandlesLongCommandLine(t *testing.T) {
var stdout, stderr bytes.Buffer
// Build a single command line larger than 64 KiB. The command itself is
// invalid (no real session) but runBatch must still emit an EOR sentinel
// and continue to the next command rather than dropping the line on the
// floor with a bufio.ErrTooLong from the outer return.
huge := strings.Repeat("tag-with-a-reasonably-long-name-and-suffix,", 2000) + "trailing"
line := "subscribe-bulk -session-id none -items " + huge
if len(line) <= 64*1024 {
t.Fatalf("test setup error: long line length = %d, want > 64KiB", len(line))
}
in := strings.NewReader(line + "\nversion --json\n")
if err := runBatch(t.Context(), in, &stdout, &stderr); err != nil {
t.Fatalf("runBatch() error = %v; stderr = %s", err, stderr.String())
}
out := stdout.String()
// Both commands must produce an EOR sentinel — the long line should be a
// per-command error (still emitted with EOR), then the version command
// should run normally.
if count := strings.Count(out, batchEOR); count != 2 {
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, even when first is too long); out length = %d", count, len(out))
}
}
+46
View File
@@ -0,0 +1,46 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
$protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
$goPluginPath = 'C:\Users\dohertj2\go\bin'
if (-not (Test-Path $protoc)) {
throw "protoc was not found at $protoc. See docs/ToolchainLinks.md."
}
foreach ($pluginName in @('protoc-gen-go.exe', 'protoc-gen-go-grpc.exe')) {
$pluginPath = Join-Path $goPluginPath $pluginName
if (-not (Test-Path $pluginPath)) {
throw "$pluginName was not found at $pluginPath. See docs/ToolchainLinks.md."
}
}
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
Get-ChildItem -Path $outputRoot -Filter '*.pb.go' -File | Remove-Item
$env:Path = "$goPluginPath;$env:Path"
& $protoc `
--proto_path=$protoRoot `
--go_out=$outputRoot `
--go_opt=paths=source_relative `
"--go_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
"--go_opt=Mmxaccess_worker.proto=$modulePath;generated" `
"--go_opt=Mgalaxy_repository.proto=$modulePath;generated" `
mxaccess_gateway.proto `
mxaccess_worker.proto `
galaxy_repository.proto
& $protoc `
--proto_path=$protoRoot `
--go-grpc_out=$outputRoot `
--go-grpc_opt=paths=source_relative `
"--go-grpc_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
"--go-grpc_opt=Mgalaxy_repository.proto=$modulePath;generated" `
mxaccess_gateway.proto `
galaxy_repository.proto
+15
View File
@@ -0,0 +1,15 @@
module gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go
go 1.26
require (
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
require (
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
)
+38
View File
@@ -0,0 +1,38 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.34.1
// source: galaxy_repository.proto
package generated
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
GalaxyRepository_TestConnection_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/TestConnection"
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
GalaxyRepository_BrowseChildren_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/BrowseChildren"
)
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
type GalaxyRepositoryClient interface {
TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error)
GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error)
DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error)
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error)
}
type galaxyRepositoryClient struct {
cc grpc.ClientConnInterface
}
func NewGalaxyRepositoryClient(cc grpc.ClientConnInterface) GalaxyRepositoryClient {
return &galaxyRepositoryClient{cc}
}
func (c *galaxyRepositoryClient) TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TestConnectionReply)
err := c.cc.Invoke(ctx, GalaxyRepository_TestConnection_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetLastDeployTimeReply)
err := c.cc.Invoke(ctx, GalaxyRepository_GetLastDeployTime_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiscoverHierarchyReply)
err := c.cc.Invoke(ctx, GalaxyRepository_DiscoverHierarchy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &GalaxyRepository_ServiceDesc.Streams[0], GalaxyRepository_WatchDeployEvents_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[WatchDeployEventsRequest, DeployEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
func (c *galaxyRepositoryClient) BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BrowseChildrenReply)
err := c.cc.Invoke(ctx, GalaxyRepository_BrowseChildren_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
// All implementations must embed UnimplementedGalaxyRepositoryServer
// for forward compatibility.
//
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
type GalaxyRepositoryServer interface {
TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error)
GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error)
DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error)
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error)
mustEmbedUnimplementedGalaxyRepositoryServer()
}
// UnimplementedGalaxyRepositoryServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGalaxyRepositoryServer struct{}
func (UnimplementedGalaxyRepositoryServer) TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error) {
return nil, status.Error(codes.Unimplemented, "method TestConnection not implemented")
}
func (UnimplementedGalaxyRepositoryServer) GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error) {
return nil, status.Error(codes.Unimplemented, "method GetLastDeployTime not implemented")
}
func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error) {
return nil, status.Error(codes.Unimplemented, "method DiscoverHierarchy not implemented")
}
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
}
func (UnimplementedGalaxyRepositoryServer) BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error) {
return nil, status.Error(codes.Unimplemented, "method BrowseChildren not implemented")
}
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
// UnsafeGalaxyRepositoryServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GalaxyRepositoryServer will
// result in compilation errors.
type UnsafeGalaxyRepositoryServer interface {
mustEmbedUnimplementedGalaxyRepositoryServer()
}
func RegisterGalaxyRepositoryServer(s grpc.ServiceRegistrar, srv GalaxyRepositoryServer) {
// If the following call panics, it indicates UnimplementedGalaxyRepositoryServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GalaxyRepository_ServiceDesc, srv)
}
func _GalaxyRepository_TestConnection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TestConnectionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).TestConnection(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_TestConnection_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).TestConnection(ctx, req.(*TestConnectionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_GetLastDeployTime_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLastDeployTimeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_GetLastDeployTime_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, req.(*GetLastDeployTimeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_DiscoverHierarchy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DiscoverHierarchyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_DiscoverHierarchy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, req.(*DiscoverHierarchyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(WatchDeployEventsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(GalaxyRepositoryServer).WatchDeployEvents(m, &grpc.GenericServerStream[WatchDeployEventsRequest, DeployEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
func _GalaxyRepository_BrowseChildren_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BrowseChildrenRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_BrowseChildren_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, req.(*BrowseChildrenRequest))
}
return interceptor(ctx, in, info, handler)
}
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
ServiceName: "galaxy_repository.v1.GalaxyRepository",
HandlerType: (*GalaxyRepositoryServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "TestConnection",
Handler: _GalaxyRepository_TestConnection_Handler,
},
{
MethodName: "GetLastDeployTime",
Handler: _GalaxyRepository_GetLastDeployTime_Handler,
},
{
MethodName: "DiscoverHierarchy",
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
},
{
MethodName: "BrowseChildren",
Handler: _GalaxyRepository_BrowseChildren_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "WatchDeployEvents",
Handler: _GalaxyRepository_WatchDeployEvents_Handler,
ServerStreams: true,
},
},
Metadata: "galaxy_repository.proto",
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,389 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.34.1
// source: mxaccess_gateway.proto
package generated
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
MxAccessGateway_OpenSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/OpenSession"
MxAccessGateway_CloseSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/CloseSession"
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
MxAccessGateway_AcknowledgeAlarm_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm"
MxAccessGateway_StreamAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamAlarms"
MxAccessGateway_QueryActiveAlarms_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/QueryActiveAlarms"
)
// MxAccessGatewayClient is the client API for MxAccessGateway service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// Public client API for MXAccess sessions hosted by the gateway.
type MxAccessGatewayClient interface {
OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error)
CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error)
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
AcknowledgeAlarm(ctx context.Context, in *AcknowledgeAlarmRequest, opts ...grpc.CallOption) (*AcknowledgeAlarmReply, error)
// Session-less central alarm feed. The stream opens with the current
// active-alarm snapshot (one `active_alarm` per alarm), then a single
// `snapshot_complete`, then a `transition` for every subsequent change.
// Served by the gateway's always-on alarm monitor; any number of clients
// fan out from the single monitor without opening a worker session.
StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error)
// Point-in-time snapshot of the currently-active alarm set served from the
// gateway's always-on alarm monitor cache (session-less). Used after a
// reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set.
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
// snapshot to alarms whose `alarm_full_reference` starts with the given
// prefix; an empty prefix returns the full set.
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
}
type mxAccessGatewayClient struct {
cc grpc.ClientConnInterface
}
func NewMxAccessGatewayClient(cc grpc.ClientConnInterface) MxAccessGatewayClient {
return &mxAccessGatewayClient{cc}
}
func (c *mxAccessGatewayClient) OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OpenSessionReply)
err := c.cc.Invoke(ctx, MxAccessGateway_OpenSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mxAccessGatewayClient) CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CloseSessionReply)
err := c.cc.Invoke(ctx, MxAccessGateway_CloseSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mxAccessGatewayClient) Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MxCommandReply)
err := c.cc.Invoke(ctx, MxAccessGateway_Invoke_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mxAccessGatewayClient) StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[0], MxAccessGateway_StreamEvents_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[StreamEventsRequest, MxEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_StreamEventsClient = grpc.ServerStreamingClient[MxEvent]
func (c *mxAccessGatewayClient) AcknowledgeAlarm(ctx context.Context, in *AcknowledgeAlarmRequest, opts ...grpc.CallOption) (*AcknowledgeAlarmReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AcknowledgeAlarmReply)
err := c.cc.Invoke(ctx, MxAccessGateway_AcknowledgeAlarm_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *mxAccessGatewayClient) StreamAlarms(ctx context.Context, in *StreamAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AlarmFeedMessage], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[1], MxAccessGateway_StreamAlarms_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[StreamAlarmsRequest, AlarmFeedMessage]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_StreamAlarmsClient = grpc.ServerStreamingClient[AlarmFeedMessage]
func (c *mxAccessGatewayClient) QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[2], MxAccessGateway_QueryActiveAlarms_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_QueryActiveAlarmsClient = grpc.ServerStreamingClient[ActiveAlarmSnapshot]
// MxAccessGatewayServer is the server API for MxAccessGateway service.
// All implementations must embed UnimplementedMxAccessGatewayServer
// for forward compatibility.
//
// Public client API for MXAccess sessions hosted by the gateway.
type MxAccessGatewayServer interface {
OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error)
CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error)
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error)
// Session-less central alarm feed. The stream opens with the current
// active-alarm snapshot (one `active_alarm` per alarm), then a single
// `snapshot_complete`, then a `transition` for every subsequent change.
// Served by the gateway's always-on alarm monitor; any number of clients
// fan out from the single monitor without opening a worker session.
StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error
// Point-in-time snapshot of the currently-active alarm set served from the
// gateway's always-on alarm monitor cache (session-less). Used after a
// reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set.
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
// snapshot to alarms whose `alarm_full_reference` starts with the given
// prefix; an empty prefix returns the full set.
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
mustEmbedUnimplementedMxAccessGatewayServer()
}
// UnimplementedMxAccessGatewayServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedMxAccessGatewayServer struct{}
func (UnimplementedMxAccessGatewayServer) OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error) {
return nil, status.Error(codes.Unimplemented, "method OpenSession not implemented")
}
func (UnimplementedMxAccessGatewayServer) CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error) {
return nil, status.Error(codes.Unimplemented, "method CloseSession not implemented")
}
func (UnimplementedMxAccessGatewayServer) Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error) {
return nil, status.Error(codes.Unimplemented, "method Invoke not implemented")
}
func (UnimplementedMxAccessGatewayServer) StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error {
return status.Error(codes.Unimplemented, "method StreamEvents not implemented")
}
func (UnimplementedMxAccessGatewayServer) AcknowledgeAlarm(context.Context, *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
return nil, status.Error(codes.Unimplemented, "method AcknowledgeAlarm not implemented")
}
func (UnimplementedMxAccessGatewayServer) StreamAlarms(*StreamAlarmsRequest, grpc.ServerStreamingServer[AlarmFeedMessage]) error {
return status.Error(codes.Unimplemented, "method StreamAlarms not implemented")
}
func (UnimplementedMxAccessGatewayServer) QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error {
return status.Error(codes.Unimplemented, "method QueryActiveAlarms not implemented")
}
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
// UnsafeMxAccessGatewayServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to MxAccessGatewayServer will
// result in compilation errors.
type UnsafeMxAccessGatewayServer interface {
mustEmbedUnimplementedMxAccessGatewayServer()
}
func RegisterMxAccessGatewayServer(s grpc.ServiceRegistrar, srv MxAccessGatewayServer) {
// If the following call panics, it indicates UnimplementedMxAccessGatewayServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&MxAccessGateway_ServiceDesc, srv)
}
func _MxAccessGateway_OpenSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(OpenSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MxAccessGatewayServer).OpenSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MxAccessGateway_OpenSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MxAccessGatewayServer).OpenSession(ctx, req.(*OpenSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MxAccessGateway_CloseSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CloseSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MxAccessGatewayServer).CloseSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MxAccessGateway_CloseSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MxAccessGatewayServer).CloseSession(ctx, req.(*CloseSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MxAccessGateway_Invoke_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MxCommandRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MxAccessGatewayServer).Invoke(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MxAccessGateway_Invoke_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MxAccessGatewayServer).Invoke(ctx, req.(*MxCommandRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MxAccessGateway_StreamEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(StreamEventsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(MxAccessGatewayServer).StreamEvents(m, &grpc.GenericServerStream[StreamEventsRequest, MxEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_StreamEventsServer = grpc.ServerStreamingServer[MxEvent]
func _MxAccessGateway_AcknowledgeAlarm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AcknowledgeAlarmRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MxAccessGatewayServer).AcknowledgeAlarm(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MxAccessGateway_AcknowledgeAlarm_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MxAccessGatewayServer).AcknowledgeAlarm(ctx, req.(*AcknowledgeAlarmRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MxAccessGateway_StreamAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(StreamAlarmsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(MxAccessGatewayServer).StreamAlarms(m, &grpc.GenericServerStream[StreamAlarmsRequest, AlarmFeedMessage]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_StreamAlarmsServer = grpc.ServerStreamingServer[AlarmFeedMessage]
func _MxAccessGateway_QueryActiveAlarms_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(QueryActiveAlarmsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(MxAccessGatewayServer).QueryActiveAlarms(m, &grpc.GenericServerStream[QueryActiveAlarmsRequest, ActiveAlarmSnapshot]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MxAccessGateway_QueryActiveAlarmsServer = grpc.ServerStreamingServer[ActiveAlarmSnapshot]
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mxaccess_gateway.v1.MxAccessGateway",
HandlerType: (*MxAccessGatewayServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "OpenSession",
Handler: _MxAccessGateway_OpenSession_Handler,
},
{
MethodName: "CloseSession",
Handler: _MxAccessGateway_CloseSession_Handler,
},
{
MethodName: "Invoke",
Handler: _MxAccessGateway_Invoke_Handler,
},
{
MethodName: "AcknowledgeAlarm",
Handler: _MxAccessGateway_AcknowledgeAlarm_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "StreamEvents",
Handler: _MxAccessGateway_StreamEvents_Handler,
ServerStreams: true,
},
{
StreamName: "StreamAlarms",
Handler: _MxAccessGateway_StreamAlarms_Handler,
ServerStreams: true,
},
{
StreamName: "QueryActiveAlarms",
Handler: _MxAccessGateway_QueryActiveAlarms_Handler,
ServerStreams: true,
},
},
Metadata: "mxaccess_gateway.proto",
}
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
package mxgateway
import (
"context"
"errors"
)
// AcknowledgeAlarm acknowledges an active MXAccess alarm condition through the
// gateway. The gateway authenticates the request against the API key's
// invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess
// session; the resulting native MxStatus is returned in the reply.
//
// Acks are idempotent — re-acking an already-acked condition is a no-op at
// the MxAccess layer.
func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
if req == nil {
return nil, errors.New("mxgateway: acknowledge alarm request is required")
}
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "acknowledge alarm", Err: err}
}
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
return reply, err
}
return reply, nil
}
// QueryActiveAlarms streams a snapshot of all alarms currently Active or
// ActiveAcked — the gateway's ConditionRefresh equivalent. Used after reconnect
// to seed local Part 9 state, or to reconcile alarms that may have been missed
// during a transport blip.
//
// The returned stream is owned by the caller; cancel ctx to release it.
// Optional alarm-reference prefix scoping (req.AlarmFilterPrefix) limits the
// stream to a sub-tree.
func (c *Client) QueryActiveAlarms(ctx context.Context, req *QueryActiveAlarmsRequest) (QueryActiveAlarmsClient, error) {
if req == nil {
return nil, errors.New("mxgateway: query active alarms request is required")
}
stream, err := c.raw.QueryActiveAlarms(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "query active alarms", Err: err}
}
return stream, nil
}
// StreamAlarms attaches to the gateway's central alarm feed. The stream opens
// with one AlarmFeedMessage per currently-active alarm (the ConditionRefresh
// snapshot), then a single snapshot-complete sentinel, then a transition for
// every subsequent raise / acknowledge / clear. It is served by the gateway's
// always-on alarm monitor — no worker session is opened — so any number of
// clients may attach.
//
// The returned stream is owned by the caller; cancel ctx to release it.
// Optional alarm-reference prefix scoping (req.AlarmFilterPrefix) limits the
// stream to a sub-tree.
func (c *Client) StreamAlarms(ctx context.Context, req *StreamAlarmsRequest) (StreamAlarmsClient, error) {
if req == nil {
return nil, errors.New("mxgateway: stream alarms request is required")
}
stream, err := c.raw.StreamAlarms(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "stream alarms", Err: err}
}
return stream, nil
}
+310
View File
@@ -0,0 +1,310 @@
package mxgateway
import (
"context"
"errors"
"io"
"net"
"testing"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
)
// PR E.4 — pins the Go SDK surface for the new alarm RPCs:
// AcknowledgeAlarm + QueryActiveAlarms.
func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
fake := &fakeGatewayWithAlarms{
acknowledgeReply: &pb.AcknowledgeAlarmReply{
CorrelationId: "corr-1",
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Status: &pb.MxStatusProxy{
Success: 1,
Category: pb.MxStatusCategory_MX_STATUS_CATEGORY_OK,
},
},
}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
reply, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
ClientCorrelationId: "corr-1",
AlarmFullReference: "Tank01.Level.HiHi",
Comment: "investigating",
OperatorUser: "alice",
})
if err != nil {
t.Fatalf("AcknowledgeAlarm() error = %v", err)
}
if reply.GetProtocolStatus().GetCode() != pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
t.Fatalf("protocol status = %v", reply.GetProtocolStatus().GetCode())
}
if got := fake.acknowledgeRequest.GetAlarmFullReference(); got != "Tank01.Level.HiHi" {
t.Fatalf("captured alarm reference = %q", got)
}
if got := fake.acknowledgeRequest.GetComment(); got != "investigating" {
t.Fatalf("captured comment = %q", got)
}
if got := fake.acknowledgeAuth; got != "Bearer test-api-key" {
t.Fatalf("authorization metadata = %q", got)
}
}
func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
fake := &fakeGatewayWithAlarms{}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
_, err := client.AcknowledgeAlarm(context.Background(), nil)
if err == nil || !errors.Is(err, errors.Unwrap(err)) && err.Error() != "mxgateway: acknowledge alarm request is required" {
// Accept either: the helper returned the literal sentinel, or the
// generic transport error — both prove nil was rejected.
}
if err == nil {
t.Fatalf("AcknowledgeAlarm(nil) returned no error")
}
}
func TestAcknowledgeAlarmMapsUnauthenticated(t *testing.T) {
fake := &fakeGatewayWithAlarms{
acknowledgeError: status.Error(codes.Unauthenticated, "expired key"),
}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
AlarmFullReference: "Tank01.Level.HiHi",
OperatorUser: "alice",
})
if err == nil {
t.Fatalf("AcknowledgeAlarm() returned no error on Unauthenticated")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) {
t.Fatalf("error %T does not unwrap to *GatewayError", err)
}
if got, _ := status.FromError(gwErr.Err); got.Code() != codes.Unauthenticated {
t.Fatalf("inner status code = %v", got.Code())
}
}
func TestQueryActiveAlarmsStreamsSnapshots(t *testing.T) {
fake := &fakeGatewayWithAlarms{
activeSnapshots: []*pb.ActiveAlarmSnapshot{
{
AlarmFullReference: "Tank01.Level.HiHi",
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE,
Severity: 750,
},
{
AlarmFullReference: "Tank02.Level.HiHi",
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED,
Severity: 750,
},
},
}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
SessionId: "session-1",
})
if err != nil {
t.Fatalf("QueryActiveAlarms() error = %v", err)
}
var received []*pb.ActiveAlarmSnapshot
for {
snap, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("stream.Recv() error = %v", err)
}
received = append(received, snap)
}
if len(received) != 2 {
t.Fatalf("snapshot count = %d, want 2", len(received))
}
if received[0].GetAlarmFullReference() != "Tank01.Level.HiHi" {
t.Fatalf("snapshot[0] ref = %q", received[0].GetAlarmFullReference())
}
if received[1].GetCurrentState() != pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE_ACKED {
t.Fatalf("snapshot[1] state = %v", received[1].GetCurrentState())
}
}
func TestQueryActiveAlarmsPassesFilterPrefix(t *testing.T) {
fake := &fakeGatewayWithAlarms{}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
stream, err := client.QueryActiveAlarms(context.Background(), &pb.QueryActiveAlarmsRequest{
SessionId: "session-1",
AlarmFilterPrefix: "Tank01.",
})
if err != nil {
t.Fatalf("QueryActiveAlarms() error = %v", err)
}
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("stream.Recv() error = %v", err)
}
}
if got := fake.queryRequest.GetAlarmFilterPrefix(); got != "Tank01." {
t.Fatalf("captured filter prefix = %q", got)
}
}
func TestStreamAlarmsPassesFilterPrefixAndReceivesFeedMessages(t *testing.T) {
fake := &fakeGatewayWithAlarms{
feedMessages: []*pb.AlarmFeedMessage{
{
Payload: &pb.AlarmFeedMessage_ActiveAlarm{
ActiveAlarm: &pb.ActiveAlarmSnapshot{
AlarmFullReference: "Tank01.Level.HiHi",
CurrentState: pb.AlarmConditionState_ALARM_CONDITION_STATE_ACTIVE,
},
},
},
{
Payload: &pb.AlarmFeedMessage_SnapshotComplete{
SnapshotComplete: true,
},
},
},
}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
stream, err := client.StreamAlarms(context.Background(), &pb.StreamAlarmsRequest{
AlarmFilterPrefix: "Tank01.",
})
if err != nil {
t.Fatalf("StreamAlarms() error = %v", err)
}
var received []*pb.AlarmFeedMessage
for {
msg, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("stream.Recv() error = %v", err)
}
received = append(received, msg)
}
if len(received) != 2 {
t.Fatalf("received count = %d, want 2", len(received))
}
if got := fake.streamRequest.GetAlarmFilterPrefix(); got != "Tank01." {
t.Fatalf("captured filter prefix = %q", got)
}
if got := fake.streamAuth; got != "Bearer test-api-key" {
t.Fatalf("stream authorization metadata = %q", got)
}
}
func TestStreamAlarmsRejectsNilRequest(t *testing.T) {
fake := &fakeGatewayWithAlarms{}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
if _, err := client.StreamAlarms(context.Background(), nil); err == nil {
t.Fatal("StreamAlarms(nil) returned no error")
}
}
type fakeGatewayWithAlarms struct {
pb.UnimplementedMxAccessGatewayServer
acknowledgeRequest *pb.AcknowledgeAlarmRequest
acknowledgeReply *pb.AcknowledgeAlarmReply
acknowledgeError error
acknowledgeAuth string
queryRequest *pb.QueryActiveAlarmsRequest
activeSnapshots []*pb.ActiveAlarmSnapshot
streamRequest *pb.StreamAlarmsRequest
feedMessages []*pb.AlarmFeedMessage
streamAuth string
}
func (s *fakeGatewayWithAlarms) AcknowledgeAlarm(ctx context.Context, req *pb.AcknowledgeAlarmRequest) (*pb.AcknowledgeAlarmReply, error) {
s.acknowledgeRequest = req
s.acknowledgeAuth = authorizationFromContext(ctx)
if s.acknowledgeError != nil {
return nil, s.acknowledgeError
}
if s.acknowledgeReply != nil {
return s.acknowledgeReply, nil
}
return &pb.AcknowledgeAlarmReply{
CorrelationId: req.GetClientCorrelationId(),
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
}, nil
}
func (s *fakeGatewayWithAlarms) QueryActiveAlarms(req *pb.QueryActiveAlarmsRequest, stream grpc.ServerStreamingServer[pb.ActiveAlarmSnapshot]) error {
s.queryRequest = req
for _, snap := range s.activeSnapshots {
if err := stream.Send(snap); err != nil {
return err
}
}
return nil
}
func (s *fakeGatewayWithAlarms) StreamAlarms(req *pb.StreamAlarmsRequest, stream grpc.ServerStreamingServer[pb.AlarmFeedMessage]) error {
s.streamRequest = req
s.streamAuth = authorizationFromContext(stream.Context())
for _, msg := range s.feedMessages {
if err := stream.Send(msg); err != nil {
return err
}
}
return nil
}
func newBufconnClientWithAlarms(t *testing.T, fake *fakeGatewayWithAlarms) (*Client, func()) {
t.Helper()
listener := bufconn.Listen(bufSize)
server := grpc.NewServer()
pb.RegisterMxAccessGatewayServer(server, fake)
go func() {
_ = server.Serve(listener)
}()
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx)
}
client, err := Dial(context.Background(), Options{
Endpoint: "bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{grpc.WithContextDialer(dialer)},
})
if err != nil {
t.Fatalf("Dial() error = %v", err)
}
return client, func() {
client.Close()
server.Stop()
listener.Close()
}
}
+30
View File
@@ -0,0 +1,30 @@
package mxgateway
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
const authorizationHeader = "authorization"
func unaryAuthInterceptor(apiKey string) grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
return invoker(authContext(ctx, apiKey), method, req, reply, cc, opts...)
}
}
func streamAuthInterceptor(apiKey string) grpc.StreamClientInterceptor {
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
return streamer(authContext(ctx, apiKey), desc, cc, method, opts...)
}
}
func authContext(ctx context.Context, apiKey string) context.Context {
if apiKey == "" {
return ctx
}
return metadata.AppendToOutgoingContext(ctx, authorizationHeader, "Bearer "+apiKey)
}
+267
View File
@@ -0,0 +1,267 @@
// Package mxgateway is the Go client for the MXAccess Gateway gRPC service.
//
// The package wraps the generated gRPC contract with session-oriented helpers
// for invoking MXAccess commands, streaming events, and browsing the Galaxy
// Repository. Authentication uses an API-key bearer token attached as gRPC
// metadata on every call.
//
// Typical use opens a Client with Dial, opens a Session, invokes commands such
// as Register, AddItem, Advise, and Write, and consumes events via
// SubscribeEvents. Galaxy Repository browse RPCs are exposed through
// GalaxyClient.
package mxgateway
import (
"context"
"crypto/tls"
"errors"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/durationpb"
)
const (
defaultDialTimeout = 10 * time.Second
defaultCallTimeout = 30 * time.Second
)
// Client owns a gateway gRPC connection and exposes session-oriented helpers.
type Client struct {
conn *grpc.ClientConn
raw pb.MxAccessGatewayClient
opts Options
}
// Dial opens a gRPC connection to the gateway and configures auth metadata,
// transport security, and blocking dial cancellation from ctx.
func Dial(ctx context.Context, opts Options) (*Client, error) {
if opts.Endpoint == "" {
return nil, errors.New("mxgateway: endpoint is required")
}
dialCtx := ctx
cancel := func() {}
if opts.DialTimeout > 0 {
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
} else if _, ok := ctx.Deadline(); !ok {
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
}
defer cancel()
transportCredentials, err := resolveTransportCredentials(opts)
if err != nil {
return nil, err
}
dialOptions := []grpc.DialOption{
grpc.WithTransportCredentials(transportCredentials),
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
grpc.WithBlock(),
}
dialOptions = append(dialOptions, opts.DialOptions...)
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
if err != nil {
return nil, &GatewayError{Op: "dial", Err: err}
}
return NewClient(conn, opts), nil
}
// NewClient wraps an existing gRPC connection. The caller owns closing conn
// unless it calls Close on the returned Client.
func NewClient(conn *grpc.ClientConn, opts Options) *Client {
return &Client{
conn: conn,
raw: pb.NewMxAccessGatewayClient(conn),
opts: opts,
}
}
// RawClient returns the generated gRPC client for command-specific parity tests.
func (c *Client) RawClient() RawGatewayClient {
return c.raw
}
// OpenSession creates a gateway-backed MXAccess session.
func (c *Client) OpenSession(ctx context.Context, opts OpenSessionOptions) (*Session, error) {
reply, err := c.OpenSessionRaw(ctx, opts.Request())
if err != nil {
return nil, err
}
return newSession(c, reply), nil
}
// OpenSessionRaw sends a raw OpenSession request and validates protocol status.
func (c *Client) OpenSessionRaw(ctx context.Context, req *OpenSessionRequest) (*OpenSessionReply, error) {
if req == nil {
return nil, errors.New("mxgateway: open session request is required")
}
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.OpenSession(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "open session", Err: err}
}
if err := EnsureProtocolSuccess("open session", reply.GetProtocolStatus(), nil); err != nil {
return reply, err
}
return reply, nil
}
// Invoke sends a raw MXAccess command request and validates protocol and
// MXAccess status fields while preserving the raw reply on typed errors.
func (c *Client) Invoke(ctx context.Context, req *MxCommandRequest) (*MxCommandReply, error) {
if req == nil {
return nil, errors.New("mxgateway: command request is required")
}
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.Invoke(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "invoke", Err: err}
}
if err := EnsureProtocolSuccess("invoke", reply.GetProtocolStatus(), reply); err != nil {
return reply, err
}
if err := EnsureMxAccessSuccess("invoke", reply); err != nil {
return reply, err
}
return reply, nil
}
// CloseSessionRaw sends a raw CloseSession request and validates protocol
// status.
func (c *Client) CloseSessionRaw(ctx context.Context, req *CloseSessionRequest) (*CloseSessionReply, error) {
if req == nil {
return nil, errors.New("mxgateway: close session request is required")
}
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.CloseSession(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "close session", Err: err}
}
if err := EnsureProtocolSuccess("close session", reply.GetProtocolStatus(), nil); err != nil {
return reply, err
}
return reply, nil
}
// StreamEventsRaw starts the generated event stream for callers that need direct
// control over Recv.
func (c *Client) StreamEventsRaw(ctx context.Context, req *StreamEventsRequest) (RawEventStream, error) {
if req == nil {
return nil, errors.New("mxgateway: stream events request is required")
}
stream, err := c.raw.StreamEvents(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "stream events", Err: err}
}
return stream, nil
}
// Close closes the underlying gRPC connection.
func (c *Client) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *Client) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.opts.CallTimeout
if timeout == 0 {
timeout = defaultCallTimeout
}
if timeout < 0 {
return ctx, func() {}
}
if deadline, ok := ctx.Deadline(); ok {
timeoutDeadline := time.Now().Add(timeout)
if deadline.Before(timeoutDeadline) {
return ctx, func() {}
}
}
return context.WithTimeout(ctx, timeout)
}
func resolveTransportCredentials(opts Options) (credentials.TransportCredentials, error) {
if opts.TransportCredentials != nil {
return opts.TransportCredentials, nil
}
if opts.Plaintext {
return insecure.NewCredentials(), nil
}
if opts.CACertFile != "" {
return credentials.NewClientTLSFromFile(opts.CACertFile, opts.ServerNameOverride)
}
if opts.TLSConfig != nil {
cfg := opts.TLSConfig.Clone()
if opts.ServerNameOverride != "" {
cfg.ServerName = opts.ServerNameOverride
}
return credentials.NewTLS(cfg), nil
}
return credentials.NewTLS(tlsConfigForOptions(opts)), nil
}
// tlsConfigForOptions returns the *tls.Config for the no-CA, no-custom-config TLS path.
// It returns nil when the caller should use a different credentials path (CA file or custom TLSConfig).
// Exposed as an internal helper so unit tests can assert the InsecureSkipVerify posture.
func tlsConfigForOptions(opts Options) *tls.Config {
// CA file and custom TLSConfig take their own paths in resolveTransportCredentials.
if opts.CACertFile != "" || opts.TLSConfig != nil {
return nil
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: opts.ServerNameOverride,
InsecureSkipVerify: !opts.RequireCertificateValidation, //nolint:gosec // internal tool; self-signed gateway cert expected; opt-in strict via RequireCertificateValidation
}
}
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
type OpenSessionOptions struct {
// RequestedBackend selects the gateway worker backend (empty for default).
RequestedBackend string
// ClientSessionName is a human-readable name recorded on the session.
ClientSessionName string
// ClientCorrelationID echoes through gateway logs and replies for tracing.
ClientCorrelationID string
// CommandTimeout sets the per-command timeout the gateway forwards to the
// worker; zero leaves the gateway default in place.
CommandTimeout time.Duration
}
// Request returns the raw protobuf OpenSessionRequest for these options.
func (o OpenSessionOptions) Request() *OpenSessionRequest {
req := &OpenSessionRequest{
RequestedBackend: o.RequestedBackend,
ClientSessionName: o.ClientSessionName,
ClientCorrelationId: o.ClientCorrelationID,
}
if o.CommandTimeout > 0 {
req.CommandTimeout = durationpb.New(o.CommandTimeout)
}
return req
}
+585
View File
@@ -0,0 +1,585 @@
package mxgateway
import (
"context"
"errors"
"io"
"net"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/test/bufconn"
)
const bufSize = 1024 * 1024
func TestDialAttachesAuthMetadataToUnaryCalls(t *testing.T) {
fake := &fakeGatewayServer{
openReply: &pb.OpenSessionReply{
SessionId: "session-1",
GatewayProtocolVersion: GatewayProtocolVersion,
WorkerProtocolVersion: WorkerProtocolVersion,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
_, err := client.OpenSession(context.Background(), OpenSessionOptions{ClientSessionName: "fixture"})
if err != nil {
t.Fatalf("OpenSession() error = %v", err)
}
if got := fake.openAuth; got != "Bearer test-api-key" {
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
}
}
func TestStreamEventsAttachesAuthMetadataAndClosesOnCancellation(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
ctx, cancel := context.WithCancel(context.Background())
events, err := session.Events(ctx)
if err != nil {
t.Fatalf("Events() error = %v", err)
}
<-fake.streamStarted
first := <-events
if first.Err != nil {
t.Fatalf("first event error = %v", first.Err)
}
if first.Event.GetWorkerSequence() != 1 {
t.Fatalf("worker sequence = %d, want 1", first.Event.GetWorkerSequence())
}
if got := fake.streamAuth; got != "Bearer test-api-key" {
t.Fatalf("stream authorization metadata = %q, want %q", got, "Bearer test-api-key")
}
cancel()
select {
case _, ok := <-events:
if ok {
t.Fatal("events channel produced an extra item after cancellation")
}
case <-time.After(2 * time.Second):
t.Fatal("events channel did not close after cancellation")
}
}
func TestEventSubscriptionCloseStopsStream(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamDone: make(chan struct{}),
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
subscription, err := session.SubscribeEvents(context.Background())
if err != nil {
t.Fatalf("SubscribeEvents() error = %v", err)
}
<-fake.streamStarted
first := <-subscription.Events()
if first.Err != nil {
t.Fatalf("first event error = %v", first.Err)
}
subscription.Close()
select {
case <-fake.streamDone:
case <-time.After(2 * time.Second):
t.Fatal("event stream did not stop after subscription close")
}
select {
case _, ok := <-subscription.Events():
if ok {
t.Fatal("subscription channel remained open after close")
}
case <-time.After(2 * time.Second):
t.Fatal("subscription channel did not close")
}
}
func TestEventsAfterCancelsStreamWhenCompatibilityChannelIsAbandoned(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamDone: make(chan struct{}),
streamEventCount: 64,
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
events, err := session.EventsAfter(context.Background(), 0)
if err != nil {
t.Fatalf("EventsAfter() error = %v", err)
}
<-fake.streamStarted
select {
case <-fake.streamDone:
case <-time.After(2 * time.Second):
t.Fatal("compatibility event stream did not stop after result channel filled")
}
for {
select {
case _, ok := <-events:
if !ok {
return
}
case <-time.After(2 * time.Second):
t.Fatal("compatibility event channel did not close")
}
}
}
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_AddItem2{
AddItem2: &pb.AddItem2Reply{ItemHandle: 42},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
itemHandle, err := session.AddItem2(context.Background(), 12, "Area001.Pump001.Speed", "runtime")
if err != nil {
t.Fatalf("AddItem2() error = %v", err)
}
if itemHandle != 42 {
t.Fatalf("item handle = %d, want 42", itemHandle)
}
req := fake.invokeRequest
if req.GetSessionId() != "session-1" {
t.Fatalf("session id = %q, want session-1", req.GetSessionId())
}
if req.GetClientCorrelationId() == "" {
t.Fatal("client correlation id is empty")
}
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2 {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if req.GetCommand().GetAddItem2().GetItemContext() != "runtime" {
t.Fatalf("item context = %q, want runtime", req.GetCommand().GetAddItem2().GetItemContext())
}
}
func TestSubscribeBulkBuildsOneBulkCommandAndReturnsResults(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_SubscribeBulk{
SubscribeBulk: &pb.BulkSubscribeReply{
Results: []*pb.SubscribeResult{
{
ServerHandle: 12,
TagAddress: "Area001.Pump001.Speed",
ItemHandle: 34,
WasSuccessful: true,
},
},
},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.SubscribeBulk(context.Background(), 12, []string{"Area001.Pump001.Speed"})
if err != nil {
t.Fatalf("SubscribeBulk() error = %v", err)
}
if len(results) != 1 || results[0].GetItemHandle() != 34 {
t.Fatalf("results = %#v, want item handle 34", results)
}
req := fake.invokeRequest
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if got := req.GetCommand().GetSubscribeBulk().GetTagAddresses(); len(got) != 1 || got[0] != "Area001.Pump001.Speed" {
t.Fatalf("tag addresses = %#v", got)
}
}
func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_WriteBulk{
WriteBulk: &pb.BulkWriteReply{
Results: []*pb.BulkWriteResult{
{ItemHandle: 10, WasSuccessful: true},
{ItemHandle: 11, WasSuccessful: true},
},
},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
entries := []*WriteBulkEntry{
{ItemHandle: 10, Value: Int32Value(7), UserId: 100},
{ItemHandle: 11, Value: Int32Value(8), UserId: 100},
}
results, err := session.WriteBulk(context.Background(), 12, entries)
if err != nil {
t.Fatalf("WriteBulk() error = %v", err)
}
if len(results) != 2 {
t.Fatalf("results len = %d, want 2", len(results))
}
req := fake.invokeRequest
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if got := req.GetCommand().GetWriteBulk().GetEntries(); len(got) != 2 {
t.Fatalf("entry count = %d, want 2", len(got))
}
}
func TestWriteBulkRejectsNilEntries(t *testing.T) {
fake := &fakeGatewayServer{}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
if _, err := session.WriteBulk(context.Background(), 12, nil); err == nil {
t.Fatal("WriteBulk(nil) returned no error")
}
if _, err := session.Write2Bulk(context.Background(), 12, nil); err == nil {
t.Fatal("Write2Bulk(nil) returned no error")
}
if _, err := session.WriteSecuredBulk(context.Background(), 12, nil); err == nil {
t.Fatal("WriteSecuredBulk(nil) returned no error")
}
if _, err := session.WriteSecured2Bulk(context.Background(), 12, nil); err == nil {
t.Fatal("WriteSecured2Bulk(nil) returned no error")
}
if _, err := session.ReadBulk(context.Background(), 12, nil, 0); err == nil {
t.Fatal("ReadBulk(nil) returned no error")
}
}
func TestBulkMethodsShortCircuitOnEmptySliceWithoutRoundTrip(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.WriteBulk(context.Background(), 12, []*WriteBulkEntry{})
if err != nil {
t.Fatalf("WriteBulk(empty) error = %v", err)
}
if len(results) != 0 {
t.Fatalf("WriteBulk(empty) results len = %d, want 0", len(results))
}
if fake.invokeRequest != nil {
t.Fatal("WriteBulk(empty) sent a round trip; expected short-circuit")
}
results2, err := session.Write2Bulk(context.Background(), 12, []*Write2BulkEntry{})
if err != nil {
t.Fatalf("Write2Bulk(empty) error = %v", err)
}
if len(results2) != 0 {
t.Fatalf("Write2Bulk(empty) results len = %d, want 0", len(results2))
}
if fake.invokeRequest != nil {
t.Fatal("Write2Bulk(empty) sent a round trip; expected short-circuit")
}
results3, err := session.WriteSecuredBulk(context.Background(), 12, []*WriteSecuredBulkEntry{})
if err != nil {
t.Fatalf("WriteSecuredBulk(empty) error = %v", err)
}
if len(results3) != 0 {
t.Fatalf("WriteSecuredBulk(empty) results len = %d, want 0", len(results3))
}
if fake.invokeRequest != nil {
t.Fatal("WriteSecuredBulk(empty) sent a round trip; expected short-circuit")
}
results4, err := session.WriteSecured2Bulk(context.Background(), 12, []*WriteSecured2BulkEntry{})
if err != nil {
t.Fatalf("WriteSecured2Bulk(empty) error = %v", err)
}
if len(results4) != 0 {
t.Fatalf("WriteSecured2Bulk(empty) results len = %d, want 0", len(results4))
}
if fake.invokeRequest != nil {
t.Fatal("WriteSecured2Bulk(empty) sent a round trip; expected short-circuit")
}
readResults, err := session.ReadBulk(context.Background(), 12, []string{}, 0)
if err != nil {
t.Fatalf("ReadBulk(empty) error = %v", err)
}
if len(readResults) != 0 {
t.Fatalf("ReadBulk(empty) results len = %d, want 0", len(readResults))
}
if fake.invokeRequest != nil {
t.Fatal("ReadBulk(empty) sent a round trip; expected short-circuit")
}
}
func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
Payload: &pb.MxCommandReply_ReadBulk{
ReadBulk: &pb.BulkReadReply{
Results: []*pb.BulkReadResult{
{TagAddress: "Tank01.Level", WasSuccessful: true, WasCached: true},
{TagAddress: "Tank02.Level", WasSuccessful: true, WasCached: false},
},
},
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
results, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level", "Tank02.Level"}, 250*time.Millisecond)
if err != nil {
t.Fatalf("ReadBulk() error = %v", err)
}
if len(results) != 2 {
t.Fatalf("results len = %d, want 2", len(results))
}
if !results[0].GetWasCached() || results[1].GetWasCached() {
t.Fatalf("WasCached flags = [%v %v], want [true false]", results[0].GetWasCached(), results[1].GetWasCached())
}
req := fake.invokeRequest
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK {
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
}
if got := req.GetCommand().GetReadBulk().GetTimeoutMs(); got != 250 {
t.Fatalf("timeout ms = %d, want 250", got)
}
}
func TestReadBulkSaturatesTimeoutAboveMaxUint32(t *testing.T) {
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
// 100 days in milliseconds exceeds MaxUint32 (~49.7 days).
hugeTimeout := 100 * 24 * time.Hour
_, err := session.ReadBulk(context.Background(), 12, []string{"Tank01.Level"}, hugeTimeout)
if err != nil {
t.Fatalf("ReadBulk() error = %v", err)
}
got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs()
if got != ^uint32(0) {
t.Fatalf("timeout ms = %d, want %d (MaxUint32)", got, ^uint32(0))
}
}
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
hresult := int32(-2147467259)
fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{
SessionId: "session-1",
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
Hresult: &hresult,
DiagnosticMessage: "native failure",
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, Message: "MXAccess failed"},
Statuses: []*pb.MxStatusProxy{{Success: 0, DiagnosticText: "failed"}},
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
err := session.Advise(context.Background(), 12, 34)
var mxErr *MxAccessError
if !errors.As(err, &mxErr) {
t.Fatalf("error %T does not support errors.As(*MxAccessError)", err)
}
if mxErr.Reply.GetHresult() != hresult {
t.Fatalf("raw reply HRESULT = %d, want %d", mxErr.Reply.GetHresult(), hresult)
}
var commandErr *CommandError
if !errors.As(err, &commandErr) {
t.Fatalf("error %T does not support errors.As(*CommandError)", err)
}
if commandErr.Reply.GetDiagnosticMessage() != "native failure" {
t.Fatalf("raw diagnostic = %q", commandErr.Reply.GetDiagnosticMessage())
}
}
func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
t.Helper()
listener := bufconn.Listen(bufSize)
server := grpc.NewServer()
pb.RegisterMxAccessGatewayServer(server, fake)
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
t.Errorf("bufconn server failed: %v", err)
}
}()
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx)
}
client, err := Dial(context.Background(), Options{
Endpoint: "bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{
grpc.WithContextDialer(dialer),
},
})
if err != nil {
t.Fatalf("Dial() error = %v", err)
}
return client, func() {
client.Close()
server.Stop()
listener.Close()
}
}
type fakeGatewayServer struct {
pb.UnimplementedMxAccessGatewayServer
openReply *pb.OpenSessionReply
openAuth string
streamAuth string
streamStarted chan struct{}
streamDone chan struct{}
streamEventCount int
invokeReply *pb.MxCommandReply
invokeRequest *pb.MxCommandRequest
}
func (s *fakeGatewayServer) OpenSession(ctx context.Context, req *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
s.openAuth = authorizationFromContext(ctx)
if s.openReply != nil {
return s.openReply, nil
}
return &pb.OpenSessionReply{
SessionId: "session-1",
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
}, nil
}
func (s *fakeGatewayServer) CloseSession(ctx context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
return &pb.CloseSessionReply{
SessionId: req.GetSessionId(),
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
}, nil
}
func (s *fakeGatewayServer) Invoke(ctx context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
s.invokeRequest = req
if s.invokeReply != nil {
return s.invokeReply, nil
}
return &pb.MxCommandReply{
SessionId: req.GetSessionId(),
Kind: req.GetCommand().GetKind(),
ProtocolStatus: &pb.ProtocolStatus{
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
},
}, nil
}
func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grpc.ServerStreamingServer[pb.MxEvent]) error {
s.streamAuth = authorizationFromContext(stream.Context())
if s.streamDone != nil {
defer close(s.streamDone)
}
if s.streamStarted != nil {
close(s.streamStarted)
}
eventCount := s.streamEventCount
if eventCount == 0 {
eventCount = 1
}
for sequence := 1; sequence <= eventCount; sequence++ {
if err := stream.Send(&pb.MxEvent{
SessionId: req.GetSessionId(),
Family: pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE,
WorkerSequence: uint64(sequence),
}); err != nil {
return err
}
}
<-stream.Context().Done()
return io.EOF
}
func authorizationFromContext(ctx context.Context) string {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return ""
}
values := md.Get(authorizationHeader)
if len(values) == 0 {
return ""
}
return values[0]
}
+59
View File
@@ -0,0 +1,59 @@
package mxgateway
import (
"crypto/tls"
"testing"
)
// tlsConfigFromOptions is the internal helper under test.
// It extracts the *tls.Config from the no-CA TLS path of resolveTransportCredentials.
// We exercise it directly to avoid needing a real dial target.
func TestTLSInsecureSkipVerify_DefaultTrue(t *testing.T) {
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
})
if cfg == nil {
t.Fatal("expected non-nil tls.Config")
}
if !cfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be true by default when no CA is pinned")
}
}
func TestTLSInsecureSkipVerify_FalseWhenRequireCertificateValidation(t *testing.T) {
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
RequireCertificateValidation: true,
})
if cfg == nil {
t.Fatal("expected non-nil tls.Config")
}
if cfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be false when RequireCertificateValidation is true")
}
}
func TestTLSInsecureSkipVerify_FalseWhenCACertFileSet(t *testing.T) {
// When a CA file is pinned, the CA-verification path is taken instead.
// tlsConfigForOptions should return nil (the CA path does not use our helper).
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
CACertFile: "/some/ca.pem",
})
if cfg != nil {
t.Error("expected nil tls.Config when CACertFile is set (CA path taken)")
}
}
func TestTLSInsecureSkipVerify_FalseWhenCustomTLSConfig(t *testing.T) {
// When TLSConfig is supplied explicitly, our default skip-verify must not overwrite it.
custom := &tls.Config{MinVersion: tls.VersionTLS13}
cfg := tlsConfigForOptions(Options{
Endpoint: "localhost:5120",
TLSConfig: custom,
})
if cfg != nil {
t.Error("expected nil tls.Config when TLSConfig is already set (custom config path taken)")
}
}
+73
View File
@@ -0,0 +1,73 @@
package mxgateway
import (
"encoding/json"
"os"
"path/filepath"
"testing"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/protobuf/encoding/protojson"
)
func TestValueConversionFixtures(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "values", "value-conversion-cases.json"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var fixture struct {
Cases []struct {
ID string `json:"id"`
ExpectedKind string `json:"expectedKind"`
Value json.RawMessage `json:"value"`
} `json:"cases"`
}
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("parse fixture manifest: %v", err)
}
for _, tc := range fixture.Cases {
t.Run(tc.ID, func(t *testing.T) {
var value pb.MxValue
if err := protojson.Unmarshal(tc.Value, &value); err != nil {
t.Fatalf("parse value: %v", err)
}
if _, err := NativeValue(&value); err != nil {
t.Fatalf("NativeValue() error = %v", err)
}
if got := value.ProtoReflect().WhichOneof(value.ProtoReflect().Descriptor().Oneofs().ByName("kind")).JSONName(); got != tc.ExpectedKind {
t.Fatalf("kind = %q, want %q", got, tc.ExpectedKind)
}
})
}
}
func TestStatusConversionFixtures(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "statuses", "status-conversion-cases.json"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var fixture struct {
Cases []struct {
ID string `json:"id"`
Status json.RawMessage `json:"status"`
} `json:"cases"`
}
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatalf("parse fixture manifest: %v", err)
}
for _, tc := range fixture.Cases {
t.Run(tc.ID, func(t *testing.T) {
var status pb.MxStatusProxy
if err := protojson.Unmarshal(tc.Status, &status); err != nil {
t.Fatalf("parse status: %v", err)
}
if got, want := StatusSucceeded(&status), status.GetSuccess() != 0; got != want {
t.Fatalf("StatusSucceeded() = %v, want %v", got, want)
}
})
}
}
+130
View File
@@ -0,0 +1,130 @@
package mxgateway
import (
"fmt"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
)
// GatewayError wraps transport-level gRPC failures.
type GatewayError struct {
// Op names the operation that failed (for example "dial" or "invoke").
Op string
// Err is the underlying gRPC or transport error.
Err error
}
// Error returns the formatted gateway error message.
func (e *GatewayError) Error() string {
if e == nil {
return ""
}
if e.Op == "" {
return fmt.Sprintf("mxgateway: %v", e.Err)
}
return fmt.Sprintf("mxgateway: %s failed: %v", e.Op, e.Err)
}
// Unwrap returns the wrapped transport error.
func (e *GatewayError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// CommandError reports a non-OK gateway protocol status and keeps the raw
// command reply when one exists.
type CommandError struct {
// Op names the gateway operation that produced the non-OK status.
Op string
// Status carries the gateway-reported protocol status.
Status *ProtocolStatus
// Reply is the raw command reply, when one was returned alongside the status.
Reply *MxCommandReply
}
// Error returns the formatted command error message.
func (e *CommandError) Error() string {
if e == nil {
return ""
}
status := e.Status
if status == nil {
return fmt.Sprintf("mxgateway: %s failed with missing protocol status", e.Op)
}
if status.GetMessage() == "" {
return fmt.Sprintf("mxgateway: %s failed with protocol status %s", e.Op, status.GetCode())
}
return fmt.Sprintf("mxgateway: %s failed with protocol status %s: %s", e.Op, status.GetCode(), status.GetMessage())
}
// MxAccessError reports HRESULT or MXSTATUS_PROXY failures returned by MXAccess.
type MxAccessError struct {
// Command is the wrapped CommandError when the protocol status carried one.
Command *CommandError
// Reply is the raw MXAccess command reply that surfaced the failure.
Reply *MxCommandReply
}
// Error returns the formatted MXAccess error message.
func (e *MxAccessError) Error() string {
if e == nil {
return ""
}
if e.Command != nil && e.Command.Status != nil && e.Command.Status.GetMessage() != "" {
return e.Command.Error()
}
if e.Reply != nil && e.Reply.GetDiagnosticMessage() != "" {
return fmt.Sprintf("mxgateway: MXAccess command %s failed: %s", e.Reply.GetKind(), e.Reply.GetDiagnosticMessage())
}
if e.Reply != nil && e.Reply.Hresult != nil {
return fmt.Sprintf("mxgateway: MXAccess command %s failed with HRESULT 0x%08X", e.Reply.GetKind(), uint32(e.Reply.GetHresult()))
}
return "mxgateway: MXAccess command failed"
}
// Unwrap returns the wrapped CommandError, when one is present.
func (e *MxAccessError) Unwrap() error {
if e == nil {
return nil
}
return e.Command
}
// EnsureProtocolSuccess returns a typed CommandError when status is non-OK.
func EnsureProtocolSuccess(op string, status *ProtocolStatus, reply *MxCommandReply) error {
if status == nil || status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
return nil
}
commandError := &CommandError{
Op: op,
Status: status,
Reply: reply,
}
if status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE {
return &MxAccessError{
Command: commandError,
Reply: reply,
}
}
return commandError
}
// EnsureMxAccessSuccess returns a typed MxAccessError for failing HRESULTs or
// MXSTATUS_PROXY entries.
func EnsureMxAccessSuccess(op string, reply *MxCommandReply) error {
if reply == nil {
return nil
}
if reply.Hresult != nil && reply.GetHresult() != 0 {
return &MxAccessError{Reply: reply}
}
for _, status := range reply.GetStatuses() {
if !StatusSucceeded(status) {
return &MxAccessError{Reply: reply}
}
}
return nil
}
+489
View File
@@ -0,0 +1,489 @@
package mxgateway
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
// browseChildrenPageSize is the per-request page size used by the lazy walker.
const browseChildrenPageSize = 500
// discoverHierarchyPageSize is the per-request page size used by DiscoverHierarchy.
// Mirrors the .NET client constant so large galaxies are not silently truncated
// by the server's default page cap.
const discoverHierarchyPageSize = 5000
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
// Galaxy Repository service exposed for callers that need direct contract
// access.
type RawGalaxyRepositoryClient = pb.GalaxyRepositoryClient
// Generated protobuf aliases for Galaxy Repository messages.
type (
// TestConnectionRequest is the request for Galaxy Repository TestConnection.
TestConnectionRequest = pb.TestConnectionRequest
// TestConnectionReply is the reply for Galaxy Repository TestConnection.
TestConnectionReply = pb.TestConnectionReply
// GetLastDeployTimeRequest is the request for GetLastDeployTime.
GetLastDeployTimeRequest = pb.GetLastDeployTimeRequest
// GetLastDeployTimeReply is the reply for GetLastDeployTime.
GetLastDeployTimeReply = pb.GetLastDeployTimeReply
// DiscoverHierarchyRequest is the request for DiscoverHierarchy.
DiscoverHierarchyRequest = pb.DiscoverHierarchyRequest
// DiscoverHierarchyReply is the reply for DiscoverHierarchy.
DiscoverHierarchyReply = pb.DiscoverHierarchyReply
// GalaxyObject describes one Galaxy object with its dynamic attributes.
GalaxyObject = pb.GalaxyObject
// GalaxyAttribute describes one dynamic attribute on a GalaxyObject.
GalaxyAttribute = pb.GalaxyAttribute
// WatchDeployEventsRequest is the request for WatchDeployEvents.
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
// DeployEvent is one Galaxy Repository deploy event.
DeployEvent = pb.DeployEvent
// BrowseChildrenRequest is the request for BrowseChildren.
BrowseChildrenRequest = pb.BrowseChildrenRequest
// BrowseChildrenReply is the reply for BrowseChildren.
BrowseChildrenReply = pb.BrowseChildrenReply
)
// RawDeployEventStream is the generated WatchDeployEvents client stream.
type RawDeployEventStream = grpc.ServerStreamingClient[pb.DeployEvent]
// GalaxyClient owns a gateway gRPC connection and exposes Galaxy Repository
// browse helpers. It mirrors the structure of Client and uses the same
// connection-management conventions.
type GalaxyClient struct {
conn *grpc.ClientConn
raw pb.GalaxyRepositoryClient
opts Options
}
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
// service. It applies the same authentication metadata, transport security,
// and dial-timeout behavior as Dial.
func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
if opts.Endpoint == "" {
return nil, errors.New("mxgateway: endpoint is required")
}
dialCtx := ctx
cancel := func() {}
if opts.DialTimeout > 0 {
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
} else if _, ok := ctx.Deadline(); !ok {
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
}
defer cancel()
transportCredentials, err := resolveTransportCredentials(opts)
if err != nil {
return nil, err
}
dialOptions := []grpc.DialOption{
grpc.WithTransportCredentials(transportCredentials),
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
grpc.WithBlock(),
}
dialOptions = append(dialOptions, opts.DialOptions...)
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
if err != nil {
return nil, &GatewayError{Op: "dial", Err: err}
}
return NewGalaxyClient(conn, opts), nil
}
// NewGalaxyClient wraps an existing gRPC connection for Galaxy Repository
// access. The caller owns closing conn unless it calls Close on the returned
// GalaxyClient.
func NewGalaxyClient(conn *grpc.ClientConn, opts Options) *GalaxyClient {
return &GalaxyClient{
conn: conn,
raw: pb.NewGalaxyRepositoryClient(conn),
opts: opts,
}
}
// RawClient returns the generated gRPC client for command-specific parity
// tests.
func (c *GalaxyClient) RawClient() RawGalaxyRepositoryClient {
return c.raw
}
// TestConnection probes the Galaxy Repository service. It returns the server's
// reported ok flag and a non-nil error only when the RPC itself fails.
func (c *GalaxyClient) TestConnection(ctx context.Context) (bool, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.TestConnection(callCtx, &pb.TestConnectionRequest{})
if err != nil {
return false, &GatewayError{Op: "galaxy test connection", Err: err}
}
return reply.GetOk(), nil
}
// GetLastDeployTime returns the Galaxy's last deploy timestamp. When the server
// reports present=false (no deploy recorded yet) the call returns
// (time.Time{}, false, nil). When present=true the timestamp is returned in
// UTC with present=true.
func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.GetLastDeployTime(callCtx, &pb.GetLastDeployTimeRequest{})
if err != nil {
return time.Time{}, false, &GatewayError{Op: "galaxy get last deploy time", Err: err}
}
if !reply.GetPresent() {
return time.Time{}, false, nil
}
ts := reply.GetTimeOfLastDeploy()
if ts == nil {
return time.Time{}, false, nil
}
return ts.AsTime(), true, nil
}
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
// object's dynamic attributes. The objects are returned in the order supplied
// by the server. The call pages over the server's NextPageToken until the
// server signals it has no more results, matching the .NET client.
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
var objects []*GalaxyObject
pageToken := ""
seen := map[string]struct{}{}
for {
callCtx, cancel := c.callContext(ctx)
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{
PageSize: discoverHierarchyPageSize,
PageToken: pageToken,
})
cancel()
if err != nil {
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
}
objects = append(objects, reply.GetObjects()...)
pageToken = reply.GetNextPageToken()
if pageToken == "" {
return objects, nil
}
if _, dup := seen[pageToken]; dup {
return nil, &GatewayError{
Op: "galaxy discover hierarchy",
Err: fmt.Errorf("repeated page token %q", pageToken),
}
}
seen[pageToken] = struct{}{}
}
}
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
// that want direct control over Recv. The caller owns the returned stream's
// lifetime via ctx cancellation.
func (c *GalaxyClient) WatchDeployEventsRaw(ctx context.Context, req *WatchDeployEventsRequest) (RawDeployEventStream, error) {
if req == nil {
req = &pb.WatchDeployEventsRequest{}
}
stream, err := c.raw.WatchDeployEvents(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "galaxy watch deploy events", Err: err}
}
return stream, nil
}
// WatchDeployEvents subscribes to Galaxy deploy events. The server emits a
// bootstrap event with the current state immediately on subscribe, then one
// event per new deploy. When lastSeenDeployTime is non-nil it is forwarded to
// the server to suppress the bootstrap event.
//
// The returned event channel is closed when the server completes the stream
// (io.EOF), when ctx is cancelled, or after a terminal error has been
// delivered on the error channel. The error channel is also closed once the
// stream tears down. Surfaced errors are wrapped in *GatewayError.
//
// Cancel ctx to tear the stream down cleanly.
func (c *GalaxyClient) WatchDeployEvents(
ctx context.Context,
lastSeenDeployTime *time.Time,
) (<-chan *DeployEvent, <-chan error, error) {
req := &pb.WatchDeployEventsRequest{}
if lastSeenDeployTime != nil {
req.LastSeenDeployTime = timestamppb.New(*lastSeenDeployTime)
}
stream, err := c.WatchDeployEventsRaw(ctx, req)
if err != nil {
return nil, nil, err
}
events := make(chan *DeployEvent, 16)
errs := make(chan error, 1)
go func() {
defer close(events)
defer close(errs)
for {
event, recvErr := stream.Recv()
if recvErr == nil {
select {
case events <- event:
case <-ctx.Done():
return
}
continue
}
if recvErr == io.EOF {
return
}
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
return
}
select {
case errs <- &GatewayError{Op: "galaxy watch deploy events", Err: recvErr}:
case <-ctx.Done():
}
return
}
}()
return events, errs, nil
}
// Close closes the underlying gRPC connection.
func (c *GalaxyClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by
// (*GalaxyClient).Browse. Children are not fetched until Expand is called.
// The node is safe for concurrent use; concurrent Expand calls coalesce onto
// a single in-flight RPC and do not block snapshot accessors.
type LazyBrowseNode struct {
client *GalaxyClient
object *pb.GalaxyObject
hasChildrenHint bool
options BrowseChildrenOptions
// expandLock gates inspection and mutation of expand-coordination state
// (expanding, expandDone, expandErr). It is held only briefly; the BrowseChildren
// RPC itself runs outside this lock so concurrent readers and waiters are not blocked.
expandLock sync.Mutex
expanding bool
expandDone chan struct{}
expandErr error
// mu protects the children snapshot and isExpanded flag for concurrent
// Children() / IsExpanded() readers.
mu sync.RWMutex
children []*LazyBrowseNode
isExpanded bool
}
// Object returns the underlying GalaxyObject describing this node.
func (n *LazyBrowseNode) Object() *pb.GalaxyObject { return n.object }
// HasChildrenHint reports the server-supplied hint on whether this node has
// matching descendants under the current filter set.
func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint }
// Children returns a snapshot copy of the currently-loaded child nodes. Returns
// an empty slice when Expand has not yet been called.
func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
n.mu.RLock()
defer n.mu.RUnlock()
out := make([]*LazyBrowseNode, len(n.children))
copy(out, n.children)
return out
}
// IsExpanded reports whether Expand has completed successfully on this node.
func (n *LazyBrowseNode) IsExpanded() bool {
n.mu.RLock()
defer n.mu.RUnlock()
return n.isExpanded
}
// Expand fetches this node's direct children via BrowseChildren when they have
// not yet been loaded. Subsequent calls after a successful Expand are a no-op
// and do not issue another RPC.
//
// Expand is safe to call concurrently from multiple goroutines: callers that
// arrive while an expansion is in flight wait on the active RPC and share its
// result instead of issuing a second RPC. The RPC itself runs without holding
// the snapshot mutex, so concurrent Children() and IsExpanded() callers are
// not blocked for the duration of the network round trip.
//
// Failure semantics: a failed expansion surfaces the same error to every
// in-flight waiter, but the node is left in its pre-call state (isExpanded =
// false, no in-flight expansion). The next Expand call therefore retries with
// a fresh RPC; failures are not sticky.
func (n *LazyBrowseNode) Expand(ctx context.Context) error {
// Fast path: already expanded.
n.mu.RLock()
if n.isExpanded {
n.mu.RUnlock()
return nil
}
n.mu.RUnlock()
// Either start a new expansion or wait on an existing one.
n.expandLock.Lock()
n.mu.RLock()
alreadyExpanded := n.isExpanded
n.mu.RUnlock()
if alreadyExpanded {
n.expandLock.Unlock()
return nil
}
if n.expanding {
done := n.expandDone
n.expandLock.Unlock()
select {
case <-done:
n.expandLock.Lock()
err := n.expandErr
n.expandLock.Unlock()
return err
case <-ctx.Done():
return ctx.Err()
}
}
n.expanding = true
n.expandDone = make(chan struct{})
done := n.expandDone
n.expandLock.Unlock()
// Issue the RPC outside any lock so concurrent readers/waiters are not blocked.
parentID := n.object.GetGobjectId()
children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
if err == nil {
n.mu.Lock()
n.children = children
n.isExpanded = true
n.mu.Unlock()
}
// Publish result to waiters and clear the in-flight marker so a failed
// expansion can be retried by the next Expand call.
n.expandLock.Lock()
n.expandErr = err
n.expanding = false
close(done)
n.expandLock.Unlock()
return err
}
// Browse returns the root nodes of the Galaxy hierarchy. The returned nodes
// have only their server-supplied hints populated; call Expand on each node to
// fetch its direct children. When opts is nil the server defaults apply.
func (c *GalaxyClient) Browse(ctx context.Context, opts *BrowseChildrenOptions) ([]*LazyBrowseNode, error) {
effective := BrowseChildrenOptions{}
if opts != nil {
effective = *opts
}
return c.browseChildrenInner(ctx, nil, effective)
}
// BrowseChildrenRaw issues a single BrowseChildren RPC and returns the raw
// reply for callers that need direct page-token control. Transport-level
// failures are wrapped in *GatewayError to match the rest of the client.
func (c *GalaxyClient) BrowseChildrenRaw(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.BrowseChildren(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "galaxy browse children", Err: err}
}
return reply, nil
}
func (c *GalaxyClient) browseChildrenInner(
ctx context.Context,
parentGobjectID *int32,
opts BrowseChildrenOptions,
) ([]*LazyBrowseNode, error) {
var nodes []*LazyBrowseNode
pageToken := ""
seen := map[string]struct{}{}
for {
req := &pb.BrowseChildrenRequest{
PageSize: browseChildrenPageSize,
PageToken: pageToken,
CategoryIds: opts.CategoryIds,
TemplateChainContains: opts.TemplateChainContains,
TagNameGlob: opts.TagNameGlob,
AlarmBearingOnly: opts.AlarmBearingOnly,
HistorizedOnly: opts.HistorizedOnly,
}
if parentGobjectID != nil {
req.Parent = &pb.BrowseChildrenRequest_ParentGobjectId{ParentGobjectId: *parentGobjectID}
}
if opts.IncludeAttributes != nil {
req.IncludeAttributes = opts.IncludeAttributes
}
reply, err := c.BrowseChildrenRaw(ctx, req)
if err != nil {
return nil, err
}
for i, child := range reply.GetChildren() {
hasChildren := reply.GetChildHasChildren()
hint := i < len(hasChildren) && hasChildren[i]
nodes = append(nodes, &LazyBrowseNode{
client: c,
object: child,
hasChildrenHint: hint,
options: opts,
})
}
pageToken = reply.GetNextPageToken()
if pageToken == "" {
return nodes, nil
}
if _, dup := seen[pageToken]; dup {
return nil, &GatewayError{
Op: "galaxy browse children",
Err: fmt.Errorf("repeated page token %q", pageToken),
}
}
seen[pageToken] = struct{}{}
}
}
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.opts.CallTimeout
if timeout == 0 {
timeout = defaultCallTimeout
}
if timeout < 0 {
return ctx, func() {}
}
if deadline, ok := ctx.Deadline(); ok {
timeoutDeadline := time.Now().Add(timeout)
if deadline.Before(timeoutDeadline) {
return ctx, func() {}
}
}
return context.WithTimeout(ctx, timeout)
}
+864
View File
@@ -0,0 +1,864 @@
package mxgateway
import (
"context"
"errors"
"net"
"sync"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestGalaxyTestConnectionAttachesAuthAndReturnsOk(t *testing.T) {
fake := &fakeGalaxyServer{
testReply: &pb.TestConnectionReply{Ok: true},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ok, err := client.TestConnection(context.Background())
if err != nil {
t.Fatalf("TestConnection() error = %v", err)
}
if !ok {
t.Fatalf("TestConnection() ok = false, want true")
}
if got := fake.testAuth; got != "Bearer test-api-key" {
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
}
}
func TestGalaxyGetLastDeployTimeReturnsAbsentForPresentFalse(t *testing.T) {
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{Present: false},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if present {
t.Fatalf("present = true, want false")
}
if !got.IsZero() {
t.Fatalf("time = %v, want zero", got)
}
}
func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{
Present: true,
TimeOfLastDeploy: timestamppb.New(want),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if !present {
t.Fatalf("present = false, want true")
}
if !got.Equal(want) {
t.Fatalf("time = %v, want %v", got, want)
}
}
func TestGalaxyGetLastDeployTimeReturnsAbsentWhenTimestampNil(t *testing.T) {
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{Present: true, TimeOfLastDeploy: nil},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if present {
t.Fatalf("present = true, want false (nil timestamp)")
}
if !got.IsZero() {
t.Fatalf("time = %v, want zero", got)
}
}
func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
fake := &fakeGalaxyServer{
discoverReply: &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{
GobjectId: 1,
TagName: "TestMachine_001",
ContainedName: "TestMachine_001",
BrowseName: "TestMachine_001",
IsArea: false,
CategoryId: 7,
TemplateChain: []string{"$Object", "$AppObject"},
Attributes: []*pb.GalaxyAttribute{
{
AttributeName: "DownloadPath",
FullTagReference: "TestMachine_001.DownloadPath",
MxDataType: 8,
DataTypeName: "String",
},
},
},
{
GobjectId: 2,
TagName: "TestMachine_002",
ContainedName: "TestMachine_002",
ParentGobjectId: 1,
},
},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
objects, err := client.DiscoverHierarchy(context.Background())
if err != nil {
t.Fatalf("DiscoverHierarchy() error = %v", err)
}
if len(objects) != 2 {
t.Fatalf("len(objects) = %d, want 2", len(objects))
}
if objects[0].GetTagName() != "TestMachine_001" {
t.Fatalf("objects[0].TagName = %q", objects[0].GetTagName())
}
if len(objects[0].GetAttributes()) != 1 {
t.Fatalf("len(attributes) = %d, want 1", len(objects[0].GetAttributes()))
}
if objects[0].GetAttributes()[0].GetFullTagReference() != "TestMachine_001.DownloadPath" {
t.Fatalf("FullTagReference = %q", objects[0].GetAttributes()[0].GetFullTagReference())
}
}
func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) {
page1 := &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{GobjectId: 1, TagName: "A"},
{GobjectId: 2, TagName: "B"},
},
NextPageToken: "page-2",
TotalObjectCount: 3,
}
page2 := &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{GobjectId: 3, TagName: "C"},
},
TotalObjectCount: 3,
}
fake := &fakeGalaxyServer{
discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
objs, err := client.DiscoverHierarchy(context.Background())
if err != nil {
t.Fatalf("DiscoverHierarchy: %v", err)
}
if got, want := len(objs), 3; got != want {
t.Fatalf("len(objs) = %d, want %d", got, want)
}
if len(fake.discoverHierarchyCalls) != 2 {
t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls))
}
if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize {
t.Fatalf("first call PageSize = %d, want %d",
fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize)
}
if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" {
t.Fatalf("second call page token = %q, want %q",
fake.discoverHierarchyCalls[1].GetPageToken(), "page-2")
}
}
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
fake := &fakeGalaxyServer{failTest: true}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
_, err := client.TestConnection(context.Background())
if err == nil {
t.Fatal("TestConnection() error = nil, want error")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) {
t.Fatalf("error %T does not support errors.As(*GatewayError)", err)
}
if gwErr.Op != "galaxy test connection" {
t.Fatalf("Op = %q, want %q", gwErr.Op, "galaxy test connection")
}
}
func TestGalaxyWatchDeployEventsReceivesEventsInOrder(t *testing.T) {
bootstrap := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
deploy1 := time.Date(2026, 4, 28, 10, 5, 0, 0, time.UTC)
deploy2 := time.Date(2026, 4, 28, 10, 6, 0, 0, time.UTC)
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{
Sequence: 1,
ObservedAt: timestamppb.New(bootstrap),
TimeOfLastDeploy: timestamppb.New(deploy1),
TimeOfLastDeployPresent: true,
ObjectCount: 10,
AttributeCount: 42,
},
{
Sequence: 2,
ObservedAt: timestamppb.New(deploy2),
TimeOfLastDeploy: timestamppb.New(deploy2),
TimeOfLastDeployPresent: true,
ObjectCount: 11,
AttributeCount: 44,
},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
events, errs, err := client.WatchDeployEvents(ctx, nil)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
got := make([]*DeployEvent, 0, 2)
loop:
for {
select {
case ev, ok := <-events:
if !ok {
break loop
}
got = append(got, ev)
case errVal := <-errs:
if errVal != nil {
t.Fatalf("error channel: %v", errVal)
}
case <-ctx.Done():
t.Fatalf("timeout waiting for events; got %d", len(got))
}
}
if len(got) != 2 {
t.Fatalf("len(events) = %d, want 2", len(got))
}
if got[0].GetSequence() != 1 || got[1].GetSequence() != 2 {
t.Fatalf("sequences = [%d,%d], want [1,2]", got[0].GetSequence(), got[1].GetSequence())
}
if !got[0].GetTimeOfLastDeployPresent() {
t.Fatalf("event[0] TimeOfLastDeployPresent = false, want true")
}
if got[0].GetObjectCount() != 10 || got[0].GetAttributeCount() != 42 {
t.Fatalf("event[0] counts = (%d,%d), want (10,42)", got[0].GetObjectCount(), got[0].GetAttributeCount())
}
if !got[0].GetTimeOfLastDeploy().AsTime().Equal(deploy1) {
t.Fatalf("event[0] TimeOfLastDeploy = %v, want %v", got[0].GetTimeOfLastDeploy().AsTime(), deploy1)
}
}
func TestGalaxyWatchDeployEventsForwardsLastSeenDeployTime(t *testing.T) {
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{Sequence: 7},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
lastSeen := time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC)
events, errs, err := client.WatchDeployEvents(ctx, &lastSeen)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
// Drain everything.
loop:
for {
select {
case _, ok := <-events:
if !ok {
break loop
}
case errVal := <-errs:
if errVal != nil {
t.Fatalf("error channel: %v", errVal)
}
case <-ctx.Done():
t.Fatalf("timeout draining events")
}
}
if fake.watchRequest == nil {
t.Fatalf("server did not receive a request")
}
gotTs := fake.watchRequest.GetLastSeenDeployTime()
if gotTs == nil {
t.Fatalf("LastSeenDeployTime = nil, want %v", lastSeen)
}
if !gotTs.AsTime().Equal(lastSeen) {
t.Fatalf("LastSeenDeployTime = %v, want %v", gotTs.AsTime(), lastSeen)
}
}
func TestGalaxyWatchDeployEventsCancelTearsDownStream(t *testing.T) {
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{Sequence: 1},
},
watchHoldOpen: true,
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
streamCtx, cancelStream := context.WithCancel(context.Background())
events, errs, err := client.WatchDeployEvents(streamCtx, nil)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
// Wait for the bootstrap event to arrive.
select {
case ev, ok := <-events:
if !ok {
t.Fatalf("events channel closed before delivering bootstrap")
}
if ev.GetSequence() != 1 {
t.Fatalf("got seq=%d, want 1", ev.GetSequence())
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for bootstrap event")
}
// Cancel the stream; both channels must close cleanly without delivering an error.
cancelStream()
deadline := time.After(2 * time.Second)
for events != nil || errs != nil {
select {
case _, ok := <-events:
if !ok {
events = nil
}
case errVal, ok := <-errs:
if !ok {
errs = nil
continue
}
if errVal != nil {
t.Fatalf("error after cancel: %v", errVal)
}
case <-deadline:
t.Fatalf("channels did not close after cancel; events nil=%v errs nil=%v", events == nil, errs == nil)
}
}
}
func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient, func()) {
t.Helper()
listener := bufconn.Listen(bufSize)
server := grpc.NewServer()
pb.RegisterGalaxyRepositoryServer(server, fake)
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
t.Errorf("bufconn server failed: %v", err)
}
}()
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx)
}
client, err := DialGalaxy(context.Background(), Options{
Endpoint: "bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{
grpc.WithContextDialer(dialer),
},
})
if err != nil {
t.Fatalf("DialGalaxy() error = %v", err)
}
return client, func() {
client.Close()
server.Stop()
listener.Close()
}
}
type fakeGalaxyServer struct {
pb.UnimplementedGalaxyRepositoryServer
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
discoverHierarchyReplies []*pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool
browseChildrenCalls []*pb.BrowseChildrenRequest
browseChildrenReplies []*pb.BrowseChildrenReply
browseChildrenError error
}
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
s.testAuth = authorizationFromContext(ctx)
if s.failTest {
return nil, errors.New("simulated failure")
}
if s.testReply != nil {
return s.testReply, nil
}
return &pb.TestConnectionReply{Ok: true}, nil
}
func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLastDeployTimeRequest) (*pb.GetLastDeployTimeReply, error) {
if s.deployReply != nil {
return s.deployReply, nil
}
return &pb.GetLastDeployTimeReply{Present: false}, nil
}
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req)
if len(s.discoverHierarchyReplies) > 0 {
reply := s.discoverHierarchyReplies[0]
s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:]
return reply, nil
}
if s.discoverReply != nil {
return s.discoverReply, nil
}
return &pb.DiscoverHierarchyReply{}, nil
}
func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, stream grpc.ServerStreamingServer[pb.DeployEvent]) error {
s.watchRequest = req
for _, event := range s.watchEvents {
if err := stream.Send(event); err != nil {
return err
}
if s.watchSendInterval > 0 {
select {
case <-time.After(s.watchSendInterval):
case <-stream.Context().Done():
return stream.Context().Err()
}
}
}
if s.watchHoldOpen {
<-stream.Context().Done()
}
return nil
}
func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
s.browseChildrenCalls = append(s.browseChildrenCalls, req)
if s.browseChildrenError != nil {
err := s.browseChildrenError
s.browseChildrenError = nil
return nil, err
}
if len(s.browseChildrenReplies) == 0 {
return &pb.BrowseChildrenReply{}, nil
}
reply := s.browseChildrenReplies[0]
s.browseChildrenReplies = s.browseChildrenReplies[1:]
return reply, nil
}
func obj(id int32, tag string, isArea bool) *pb.GalaxyObject {
return &pb.GalaxyObject{
GobjectId: id,
TagName: tag,
BrowseName: tag,
IsArea: isArea,
}
}
func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply {
return &pb.BrowseChildrenReply{
TotalChildCount: int32(len(children)),
CacheSequence: seq,
Children: children,
ChildHasChildren: hasChildren,
}
}
func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)},
[]bool{true, false},
7,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if got, want := len(roots), 2; got != want {
t.Fatalf("len(roots) = %d, want %d", got, want)
}
if roots[0].Object().GetTagName() != "Plant" {
t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName())
}
if !roots[0].HasChildrenHint() {
t.Fatal("roots[0].HasChildrenHint = false, want true")
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true, want false")
}
if roots[1].HasChildrenHint() {
t.Fatal("roots[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
if fake.browseChildrenCalls[0].GetParent() != nil {
t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent())
}
}
func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)},
[]bool{true, false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
plant := roots[0]
if plant.IsExpanded() {
t.Fatal("plant.IsExpanded = true before Expand, want false")
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
if !plant.IsExpanded() {
t.Fatal("plant.IsExpanded = false after Expand, want true")
}
children := plant.Children()
if len(children) != 2 {
t.Fatalf("len(children) = %d, want 2", len(children))
}
if children[0].Object().GetTagName() != "Area1" {
t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName())
}
if !children[0].HasChildrenHint() {
t.Fatal("children[0].HasChildrenHint = false, want true")
}
if children[1].HasChildrenHint() {
t.Fatal("children[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 2 {
t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls))
}
parent := fake.browseChildrenCalls[1].GetParent()
parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId)
if !ok {
t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent)
}
if parentGobj.ParentGobjectId != 1 {
t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId)
}
}
func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true)},
[]bool{false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
plant := roots[0]
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #1: %v", err)
}
callsAfterFirst := len(fake.browseChildrenCalls)
if callsAfterFirst != 2 {
t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst)
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #2: %v", err)
}
if got := len(fake.browseChildrenCalls); got != callsAfterFirst {
t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst)
}
}
func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
},
browseChildrenError: status.Error(codes.NotFound, "parent not found"),
}
// The first Browse() consumes the first reply; the next call (Expand) will
// then hit browseChildrenError. We need the error to fire only on the second
// call, so seed the reply first and let the call sequence consume them in
// order. Because BrowseChildren in the fake consumes browseChildrenError
// before falling through to replies, swap the strategy: keep the root reply
// but have BrowseChildren return the error on the second call. We do this by
// emptying the reply list after the first Browse.
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
// First call returns the error (because browseChildrenError takes precedence).
// To avoid that, clear it for the root call by performing a manual setup: we
// pre-stage replies first, then set the error after the first call. Easiest:
// pre-Browse() with error=nil, then set error before Expand.
fake.browseChildrenError = nil
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
fake.browseChildrenError = status.Error(codes.NotFound, "parent not found")
err = roots[0].Expand(context.Background())
if err == nil {
t.Fatal("Expand: error = nil, want NotFound")
}
if status.Code(err) != codes.NotFound {
t.Fatalf("status.Code = %s, want NotFound", status.Code(err))
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true after failed Expand, want false")
}
}
func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) {
firstPage := buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
7,
)
pageA := buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)},
[]bool{false, false},
7,
)
pageA.NextPageToken = "7:abc:2"
pageB := buildBrowseReply(
[]*pb.GalaxyObject{obj(12, "Child3", false)},
[]bool{false},
7,
)
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if err := roots[0].Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
children := roots[0].Children()
if len(children) != 3 {
t.Fatalf("len(children) = %d, want 3", len(children))
}
if len(fake.browseChildrenCalls) != 3 {
t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls))
}
if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" {
t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2")
}
}
func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(nil, nil, 1),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
include := true
opts := &BrowseChildrenOptions{
CategoryIds: []int32{7, 9},
TemplateChainContains: []string{"$AppObject"},
TagNameGlob: "Tank*",
IncludeAttributes: &include,
AlarmBearingOnly: true,
HistorizedOnly: true,
}
if _, err := client.Browse(context.Background(), opts); err != nil {
t.Fatalf("Browse: %v", err)
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
got := fake.browseChildrenCalls[0]
if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] {
t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want)
}
if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] {
t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want)
}
if got.GetTagNameGlob() != "Tank*" {
t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*")
}
if !got.GetIncludeAttributes() {
t.Fatal("IncludeAttributes = false, want true")
}
if !got.GetAlarmBearingOnly() {
t.Fatal("AlarmBearingOnly = false, want true")
}
if !got.GetHistorizedOnly() {
t.Fatal("HistorizedOnly = false, want true")
}
}
func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
// roots
buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7),
// one expand: one child
buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx := context.Background()
roots, err := client.Browse(ctx, nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
var wg sync.WaitGroup
errs := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
errs <- roots[0].Expand(ctx)
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent Expand: %v", err)
}
}
if !roots[0].IsExpanded() {
t.Fatal("IsExpanded() = false after 10 concurrent expands")
}
if got, want := len(roots[0].Children()), 1; got != want {
t.Fatalf("len(children) = %d, want %d", got, want)
}
// 1 roots fetch + exactly 1 expand fetch.
if got, want := len(fake.browseChildrenCalls), 2; got != want {
t.Fatalf("RPC count = %d, want %d", got, want)
}
}
func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) {
// Build a reply that carries a non-empty NextPageToken so browseChildrenInner
// will request a second page. Queue the same reply twice so the second response
// returns the same page token, triggering the duplicate-token guard.
page := buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
)
page.NextPageToken = "1:abc:1"
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{page, page},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
_, err := client.Browse(context.Background(), nil)
if err == nil {
t.Fatal("Browse: error = nil, want repeated-page-token error")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) {
t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err)
}
}
+84
View File
@@ -0,0 +1,84 @@
package mxgateway
import (
"crypto/tls"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// Options configures gateway connections.
type Options struct {
// Endpoint is the gateway host:port address to dial.
Endpoint string
// APIKey is the bearer token attached to outgoing gRPC metadata.
APIKey string
// Plaintext disables TLS and uses insecure credentials when true.
Plaintext bool
// CACertFile points to a PEM file used to verify the gateway certificate.
CACertFile string
// ServerNameOverride overrides the TLS SNI/SAN name presented to the gateway.
ServerNameOverride string
// DialTimeout bounds the blocking Dial; zero applies a built-in default.
DialTimeout time.Duration
// CallTimeout bounds each unary RPC; zero applies a built-in default and
// negative disables the bound entirely.
CallTimeout time.Duration
// TLSConfig supplies a custom TLS configuration; takes precedence over
// CACertFile when TransportCredentials is unset.
TLSConfig *tls.Config
// TransportCredentials, when non-nil, overrides every other transport-level
// option and is used as-is.
TransportCredentials credentials.TransportCredentials
// DialOptions are appended to the gRPC dial options after the defaults.
DialOptions []grpc.DialOption
// RequireCertificateValidation forces TLS certificate verification even when
// no CACertFile is pinned. Default false: the gateway's self-signed cert is
// accepted without verification (internal-tool posture).
RequireCertificateValidation bool
}
// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by
// (*GalaxyClient).Browse and (*LazyBrowseNode).Expand. All fields are optional;
// the zero value matches the dashboard default (no filters, all attributes per
// the server default).
type BrowseChildrenOptions struct {
// CategoryIds restricts results to the listed Galaxy category ids when set.
CategoryIds []int32
// TemplateChainContains restricts results to objects whose template chain
// contains any of the listed template tag names.
TemplateChainContains []string
// TagNameGlob restricts results to objects whose tag name matches the glob
// pattern when non-empty.
TagNameGlob string
// IncludeAttributes overrides the server default for attribute inclusion when
// non-nil. The pointer form mirrors the proto's optional field.
IncludeAttributes *bool
// AlarmBearingOnly limits results to alarm-bearing objects when true.
AlarmBearingOnly bool
// HistorizedOnly limits results to historized objects when true.
HistorizedOnly bool
}
// RedactedAPIKey returns a display-safe representation of the configured API
// key for diagnostics and CLI output.
func (o Options) RedactedAPIKey() string {
return RedactAPIKey(o.APIKey)
}
// RedactAPIKey hides credential material while keeping enough shape for
// troubleshooting whether a key was supplied.
func RedactAPIKey(apiKey string) string {
if apiKey == "" {
return ""
}
if len(apiKey) <= 8 {
return "<redacted>"
}
prefix, suffix := apiKey[:4], apiKey[len(apiKey)-4:]
return prefix + strings.Repeat("*", len(apiKey)-8) + suffix
}
+23
View File
@@ -0,0 +1,23 @@
package mxgateway
import "testing"
func TestRedactAPIKey(t *testing.T) {
tests := []struct {
name string
apiKey string
want string
}{
{name: "empty", apiKey: "", want: ""},
{name: "short", apiKey: "mxgw_1", want: "<redacted>"},
{name: "long", apiKey: "mxgw_key_secret", want: "mxgw*******cret"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := RedactAPIKey(tt.apiKey); got != tt.want {
t.Fatalf("RedactAPIKey() = %q, want %q", got, tt.want)
}
})
}
}
@@ -0,0 +1,69 @@
package mxgateway
import (
"os"
"path/filepath"
"testing"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
func TestGeneratedGoldenFixturesParse(t *testing.T) {
tests := []struct {
name string
path string
msg proto.Message
}{
{
name: "open session reply",
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"),
msg: &pb.OpenSessionReply{},
},
{
name: "register command request",
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "register-command-request.json"),
msg: &pb.MxCommandRequest{},
},
{
name: "on data change event",
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "on-data-change-event.json"),
msg: &pb.MxEvent{},
},
}
unmarshal := protojson.UnmarshalOptions{DiscardUnknown: false}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := os.ReadFile(tt.path)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
if err := unmarshal.Unmarshal(data, tt.msg); err != nil {
t.Fatalf("parse fixture: %v", err)
}
})
}
}
func TestOpenSessionFixtureProtocolVersions(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var reply pb.OpenSessionReply
if err := protojson.Unmarshal(data, &reply); err != nil {
t.Fatalf("parse fixture: %v", err)
}
if reply.GetGatewayProtocolVersion() != GatewayProtocolVersion {
t.Fatalf("gateway protocol = %d, want %d", reply.GetGatewayProtocolVersion(), GatewayProtocolVersion)
}
if reply.GetWorkerProtocolVersion() != WorkerProtocolVersion {
t.Fatalf("worker protocol = %d, want %d", reply.GetWorkerProtocolVersion(), WorkerProtocolVersion)
}
}
+700
View File
@@ -0,0 +1,700 @@
package mxgateway
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"sync"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const maxBulkItems = 1000
// EventResult carries either the next ordered event or a terminal stream error.
type EventResult struct {
// Event is the next event from the stream when Err is nil.
Event *MxEvent
// Err is the terminal stream error; when non-nil no further results follow.
Err error
}
// EventSubscription owns a running gateway event stream.
type EventSubscription struct {
results <-chan EventResult
cancel context.CancelFunc
done <-chan struct{}
once sync.Once
}
// Events returns the stream results channel.
func (s *EventSubscription) Events() <-chan EventResult {
return s.results
}
// Close cancels the stream and waits for the receive goroutine to stop.
func (s *EventSubscription) Close() {
if s == nil {
return
}
s.once.Do(func() {
s.cancel()
<-s.done
})
}
// Session represents one gateway-backed MXAccess session.
type Session struct {
client *Client
openReply *OpenSessionReply
closeMu sync.Mutex
closeReply *CloseSessionReply
}
func newSession(client *Client, openReply *OpenSessionReply) *Session {
return &Session{
client: client,
openReply: openReply,
}
}
// NewSessionForID creates a session wrapper for commands against an existing
// gateway session id.
func NewSessionForID(client *Client, sessionID string) *Session {
return newSession(client, &pb.OpenSessionReply{SessionId: sessionID})
}
// ID returns the gateway session identifier.
func (s *Session) ID() string {
return s.openReply.GetSessionId()
}
// OpenReply returns the raw OpenSession reply.
func (s *Session) OpenReply() *OpenSessionReply {
return s.openReply
}
// Close closes the gateway session once and returns the raw close reply.
func (s *Session) Close(ctx context.Context) (*CloseSessionReply, error) {
s.closeMu.Lock()
defer s.closeMu.Unlock()
if s.closeReply != nil {
return s.closeReply, nil
}
reply, err := s.client.CloseSessionRaw(ctx, &pb.CloseSessionRequest{SessionId: s.ID()})
if err != nil {
return reply, err
}
s.closeReply = reply
return reply, nil
}
// Register invokes MXAccess Register and returns the server handle.
func (s *Session) Register(ctx context.Context, clientName string) (int32, error) {
reply, err := s.RegisterRaw(ctx, clientName)
if err != nil {
return 0, err
}
if reply.GetRegister() != nil {
return reply.GetRegister().GetServerHandle(), nil
}
return reply.GetReturnValue().GetInt32Value(), nil
}
// RegisterRaw invokes MXAccess Register and returns the raw reply.
func (s *Session) RegisterRaw(ctx context.Context, clientName string) (*MxCommandReply, error) {
if clientName == "" {
return nil, errors.New("mxgateway: client name is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REGISTER,
Payload: &pb.MxCommand_Register{
Register: &pb.RegisterCommand{ClientName: clientName},
},
})
}
// Unregister invokes MXAccess Unregister.
func (s *Session) Unregister(ctx context.Context, serverHandle int32) error {
_, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER,
Payload: &pb.MxCommand_Unregister{
Unregister: &pb.UnregisterCommand{ServerHandle: serverHandle},
},
})
return err
}
// RemoveItem invokes MXAccess RemoveItem.
func (s *Session) RemoveItem(ctx context.Context, serverHandle, itemHandle int32) error {
_, err := s.RemoveItemRaw(ctx, serverHandle, itemHandle)
return err
}
// RemoveItemRaw invokes MXAccess RemoveItem and returns the raw reply.
func (s *Session) RemoveItemRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM,
Payload: &pb.MxCommand_RemoveItem{
RemoveItem: &pb.RemoveItemCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
})
}
// AddItem invokes MXAccess AddItem and returns the item handle.
func (s *Session) AddItem(ctx context.Context, serverHandle int32, itemDefinition string) (int32, error) {
reply, err := s.AddItemRaw(ctx, serverHandle, itemDefinition)
if err != nil {
return 0, err
}
if reply.GetAddItem() != nil {
return reply.GetAddItem().GetItemHandle(), nil
}
return reply.GetReturnValue().GetInt32Value(), nil
}
// AddItemRaw invokes MXAccess AddItem and returns the raw reply.
func (s *Session) AddItemRaw(ctx context.Context, serverHandle int32, itemDefinition string) (*MxCommandReply, error) {
if itemDefinition == "" {
return nil, errors.New("mxgateway: item definition is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM,
Payload: &pb.MxCommand_AddItem{
AddItem: &pb.AddItemCommand{
ServerHandle: serverHandle,
ItemDefinition: itemDefinition,
},
},
})
}
// AddItem2 invokes MXAccess AddItem2 and returns the item handle.
func (s *Session) AddItem2(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) {
reply, err := s.AddItem2Raw(ctx, serverHandle, itemDefinition, itemContext)
if err != nil {
return 0, err
}
if reply.GetAddItem2() != nil {
return reply.GetAddItem2().GetItemHandle(), nil
}
return reply.GetReturnValue().GetInt32Value(), nil
}
// AddItem2Raw invokes MXAccess AddItem2 and returns the raw reply.
func (s *Session) AddItem2Raw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) {
if itemDefinition == "" {
return nil, errors.New("mxgateway: item definition is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
Payload: &pb.MxCommand_AddItem2{
AddItem2: &pb.AddItem2Command{
ServerHandle: serverHandle,
ItemDefinition: itemDefinition,
ItemContext: itemContext,
},
},
})
}
// Advise invokes MXAccess Advise.
func (s *Session) Advise(ctx context.Context, serverHandle, itemHandle int32) error {
_, err := s.AdviseRaw(ctx, serverHandle, itemHandle)
return err
}
// AdviseRaw invokes MXAccess Advise and returns the raw reply.
func (s *Session) AdviseRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
Payload: &pb.MxCommand_Advise{
Advise: &pb.AdviseCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
})
}
// UnAdvise invokes MXAccess UnAdvise.
func (s *Session) UnAdvise(ctx context.Context, serverHandle, itemHandle int32) error {
_, err := s.UnAdviseRaw(ctx, serverHandle, itemHandle)
return err
}
// UnAdviseRaw invokes MXAccess UnAdvise and returns the raw reply.
func (s *Session) UnAdviseRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE,
Payload: &pb.MxCommand_UnAdvise{
UnAdvise: &pb.UnAdviseCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
},
},
})
}
// AddItemBulk invokes MXAccess AddItem for each tag inside one gateway command.
func (s *Session) AddItemBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*SubscribeResult, error) {
if tagAddresses == nil {
return nil, errors.New("mxgateway: tag addresses are required")
}
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM_BULK,
Payload: &pb.MxCommand_AddItemBulk{
AddItemBulk: &pb.AddItemBulkCommand{
ServerHandle: serverHandle,
TagAddresses: tagAddresses,
},
},
})
if err != nil {
return nil, err
}
return reply.GetAddItemBulk().GetResults(), nil
}
// AdviseItemBulk invokes MXAccess Advise for each item handle inside one gateway command.
func (s *Session) AdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
if itemHandles == nil {
return nil, errors.New("mxgateway: item handles are required")
}
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_ITEM_BULK,
Payload: &pb.MxCommand_AdviseItemBulk{
AdviseItemBulk: &pb.AdviseItemBulkCommand{
ServerHandle: serverHandle,
ItemHandles: itemHandles,
},
},
})
if err != nil {
return nil, err
}
return reply.GetAdviseItemBulk().GetResults(), nil
}
// RemoveItemBulk invokes MXAccess RemoveItem for each item handle inside one gateway command.
func (s *Session) RemoveItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
if itemHandles == nil {
return nil, errors.New("mxgateway: item handles are required")
}
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM_BULK,
Payload: &pb.MxCommand_RemoveItemBulk{
RemoveItemBulk: &pb.RemoveItemBulkCommand{
ServerHandle: serverHandle,
ItemHandles: itemHandles,
},
},
})
if err != nil {
return nil, err
}
return reply.GetRemoveItemBulk().GetResults(), nil
}
// UnAdviseItemBulk invokes MXAccess UnAdvise for each item handle inside one gateway command.
func (s *Session) UnAdviseItemBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
if itemHandles == nil {
return nil, errors.New("mxgateway: item handles are required")
}
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK,
Payload: &pb.MxCommand_UnAdviseItemBulk{
UnAdviseItemBulk: &pb.UnAdviseItemBulkCommand{
ServerHandle: serverHandle,
ItemHandles: itemHandles,
},
},
})
if err != nil {
return nil, err
}
return reply.GetUnAdviseItemBulk().GetResults(), nil
}
// SubscribeBulk invokes AddItem and Advise for each tag inside one gateway command.
func (s *Session) SubscribeBulk(ctx context.Context, serverHandle int32, tagAddresses []string) ([]*SubscribeResult, error) {
if tagAddresses == nil {
return nil, errors.New("mxgateway: tag addresses are required")
}
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK,
Payload: &pb.MxCommand_SubscribeBulk{
SubscribeBulk: &pb.SubscribeBulkCommand{
ServerHandle: serverHandle,
TagAddresses: tagAddresses,
},
},
})
if err != nil {
return nil, err
}
return reply.GetSubscribeBulk().GetResults(), nil
}
// UnsubscribeBulk invokes UnAdvise and RemoveItem for each item handle inside one gateway command.
func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemHandles []int32) ([]*SubscribeResult, error) {
if itemHandles == nil {
return nil, errors.New("mxgateway: item handles are required")
}
if err := ensureBulkSize("item handles", len(itemHandles)); err != nil {
return nil, err
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK,
Payload: &pb.MxCommand_UnsubscribeBulk{
UnsubscribeBulk: &pb.UnsubscribeBulkCommand{
ServerHandle: serverHandle,
ItemHandles: itemHandles,
},
},
})
if err != nil {
return nil, err
}
return reply.GetUnsubscribeBulk().GetResults(), nil
}
// WriteBulk invokes MXAccess Write sequentially for each entry inside one gateway command.
// Per-entry failures appear as BulkWriteResult entries with WasSuccessful=false; the call
// never returns an error for per-entry MXAccess failures (it returns an error only for
// protocol-level failures or transport errors).
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*WriteBulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write bulk entries are required")
}
if err := ensureBulkSize("write bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
Payload: &pb.MxCommand_WriteBulk{
WriteBulk: &pb.WriteBulkCommand{
ServerHandle: serverHandle,
Entries: entries,
},
},
})
if err != nil {
return nil, err
}
return reply.GetWriteBulk().GetResults(), nil
}
// Write2Bulk invokes MXAccess Write2 (timestamped) for each entry inside one gateway command.
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []*Write2BulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write2 bulk entries are required")
}
if err := ensureBulkSize("write2 bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK,
Payload: &pb.MxCommand_Write2Bulk{
Write2Bulk: &pb.Write2BulkCommand{
ServerHandle: serverHandle,
Entries: entries,
},
},
})
if err != nil {
return nil, err
}
return reply.GetWrite2Bulk().GetResults(), nil
}
// WriteSecuredBulk invokes MXAccess WriteSecured for each entry. Credential-sensitive
// values must not be logged by callers; mirrors the single-item WriteSecured contract.
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entries []*WriteSecuredBulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write-secured bulk entries are required")
}
if err := ensureBulkSize("write-secured bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK,
Payload: &pb.MxCommand_WriteSecuredBulk{
WriteSecuredBulk: &pb.WriteSecuredBulkCommand{
ServerHandle: serverHandle,
Entries: entries,
},
},
})
if err != nil {
return nil, err
}
return reply.GetWriteSecuredBulk().GetResults(), nil
}
// WriteSecured2Bulk invokes MXAccess WriteSecured2 (timestamped) for each entry.
//
// A non-nil but empty entries slice is treated as a no-op and returns an empty result
// without a wire round-trip; pass nil to surface a clear "entries are required" error.
func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, entries []*WriteSecured2BulkEntry) ([]*BulkWriteResult, error) {
if entries == nil {
return nil, errors.New("mxgateway: write-secured2 bulk entries are required")
}
if err := ensureBulkSize("write-secured2 bulk entries", len(entries)); err != nil {
return nil, err
}
if len(entries) == 0 {
return []*BulkWriteResult{}, nil
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK,
Payload: &pb.MxCommand_WriteSecured2Bulk{
WriteSecured2Bulk: &pb.WriteSecured2BulkCommand{
ServerHandle: serverHandle,
Entries: entries,
},
},
})
if err != nil {
return nil, err
}
return reply.GetWriteSecured2Bulk().GetResults(), nil
}
// ReadBulk snapshots the current value of each requested tag.
//
// MXAccess COM has no synchronous Read; the worker satisfies this by returning the
// most recent cached OnDataChange value when the tag is already advised (WasCached=true),
// or by taking a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
// otherwise. timeout bounds the wait per tag in the snapshot case; pass zero to use the
// worker default. Per-tag failures (timeout, invalid tag) appear as BulkReadResult entries
// with WasSuccessful=false; the call never returns an error for per-tag MXAccess failures.
//
// A non-nil but empty tagAddresses slice is treated as a no-op and returns an empty
// result without a wire round-trip; pass nil to surface a clear "tag addresses are
// required" error.
func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses []string, timeout time.Duration) ([]*BulkReadResult, error) {
if tagAddresses == nil {
return nil, errors.New("mxgateway: tag addresses are required")
}
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
return nil, err
}
if len(tagAddresses) == 0 {
return []*BulkReadResult{}, nil
}
var timeoutMs uint32
if timeout > 0 {
ms := timeout.Milliseconds()
if ms > int64(^uint32(0)) {
timeoutMs = ^uint32(0)
} else {
timeoutMs = uint32(ms)
}
}
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
Payload: &pb.MxCommand_ReadBulk{
ReadBulk: &pb.ReadBulkCommand{
ServerHandle: serverHandle,
TagAddresses: tagAddresses,
TimeoutMs: timeoutMs,
},
},
})
if err != nil {
return nil, err
}
return reply.GetReadBulk().GetResults(), nil
}
// Write invokes MXAccess Write.
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
return err
}
// WriteRaw invokes MXAccess Write and returns the raw reply.
func (s *Session) WriteRaw(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) (*MxCommandReply, error) {
if value == nil {
return nil, errors.New("mxgateway: write value is required")
}
return s.invokeCommand(ctx, &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE,
Payload: &pb.MxCommand_Write{
Write: &pb.WriteCommand{
ServerHandle: serverHandle,
ItemHandle: itemHandle,
Value: value,
UserId: userID,
},
},
})
}
// Events streams ordered session events until the server ends the stream,
// context cancellation stops Recv, or a terminal error is sent.
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error) {
return s.EventsAfter(ctx, 0)
}
// EventsAfter streams ordered session events after the given worker sequence.
func (s *Session) EventsAfter(ctx context.Context, afterWorkerSequence uint64) (<-chan EventResult, error) {
subscription, err := s.subscribeEventsAfter(ctx, afterWorkerSequence, true)
if err != nil {
return nil, err
}
return subscription.Events(), nil
}
// SubscribeEvents starts an owned event subscription.
func (s *Session) SubscribeEvents(ctx context.Context) (*EventSubscription, error) {
return s.SubscribeEventsAfter(ctx, 0)
}
// SubscribeEventsAfter starts an owned event subscription after the given worker sequence.
func (s *Session) SubscribeEventsAfter(ctx context.Context, afterWorkerSequence uint64) (*EventSubscription, error) {
return s.subscribeEventsAfter(ctx, afterWorkerSequence, false)
}
func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence uint64, cancelWhenResultBufferFull bool) (*EventSubscription, error) {
streamCtx, cancel := context.WithCancel(ctx)
stream, err := s.client.StreamEventsRaw(streamCtx, &pb.StreamEventsRequest{
SessionId: s.ID(),
AfterWorkerSequence: afterWorkerSequence,
})
if err != nil {
cancel()
return nil, err
}
results := make(chan EventResult, 16)
done := make(chan struct{})
go func() {
defer close(results)
defer close(done)
for {
event, err := stream.Recv()
if err == nil {
if !sendEventResult(streamCtx, results, EventResult{Event: event}, cancelWhenResultBufferFull, cancel) {
return
}
continue
}
if err == io.EOF || status.Code(err) == codes.Canceled || streamCtx.Err() != nil {
return
}
sendEventResult(
streamCtx,
results,
EventResult{Err: &GatewayError{Op: "stream events", Err: err}},
cancelWhenResultBufferFull,
cancel)
return
}
}()
return &EventSubscription{
results: results,
cancel: cancel,
done: done,
}, nil
}
func ensureBulkSize(name string, length int) error {
if length > maxBulkItems {
return fmt.Errorf("mxgateway: %s bulk commands are limited to %d item(s)", name, maxBulkItems)
}
return nil
}
func sendEventResult(
ctx context.Context,
results chan<- EventResult,
result EventResult,
cancelWhenBufferFull bool,
cancel context.CancelFunc,
) bool {
if cancelWhenBufferFull {
select {
case results <- result:
return true
case <-ctx.Done():
return false
default:
cancel()
return false
}
}
select {
case results <- result:
return true
case <-ctx.Done():
return false
}
}
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
return s.client.Invoke(ctx, &pb.MxCommandRequest{
SessionId: s.ID(),
ClientCorrelationId: newCorrelationID(),
Command: command,
})
}
func newCorrelationID() string {
var buffer [16]byte
if _, err := rand.Read(buffer[:]); err != nil {
return ""
}
return hex.EncodeToString(buffer[:])
}
+6
View File
@@ -0,0 +1,6 @@
package mxgateway
// StatusSucceeded reports whether an MXSTATUS_PROXY entry represents success.
func StatusSucceeded(status *MxStatusProxy) bool {
return status == nil || status.GetSuccess() != 0
}
+213
View File
@@ -0,0 +1,213 @@
package mxgateway
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
// RawGatewayClient is the generated gRPC client interface exposed for callers
// that need direct contract access.
type RawGatewayClient = pb.MxAccessGatewayClient
// RawEventStream is the generated StreamEvents client stream.
type RawEventStream = pb.MxAccessGateway_StreamEventsClient
// Generated protobuf aliases keep raw contract access available from the public
// mxgateway package while generated code remains under internal/generated.
type (
// OpenSessionRequest is the gateway OpenSession request message.
OpenSessionRequest = pb.OpenSessionRequest
// OpenSessionReply is the gateway OpenSession reply message.
OpenSessionReply = pb.OpenSessionReply
// CloseSessionRequest is the gateway CloseSession request message.
CloseSessionRequest = pb.CloseSessionRequest
// CloseSessionReply is the gateway CloseSession reply message.
CloseSessionReply = pb.CloseSessionReply
// StreamEventsRequest is the gateway StreamEvents request message.
StreamEventsRequest = pb.StreamEventsRequest
// MxCommandRequest carries one MXAccess command for Invoke.
MxCommandRequest = pb.MxCommandRequest
// MxCommandReply is the reply to an MXAccess command Invoke.
MxCommandReply = pb.MxCommandReply
// MxCommand is the discriminated union of MXAccess command payloads.
MxCommand = pb.MxCommand
// MxEvent is one ordered event delivered on a session event stream.
MxEvent = pb.MxEvent
// MxValue is the protobuf representation of an MXAccess value.
MxValue = pb.MxValue
// Value is an alias for MxValue retained for symmetry with other clients.
Value = pb.MxValue
// MxArray is the protobuf representation of an MXAccess array value.
MxArray = pb.MxArray
// MxStatusProxy mirrors the MXAccess MXSTATUS_PROXY structure.
MxStatusProxy = pb.MxStatusProxy
// ProtocolStatus is the gateway-level status carried on every reply.
ProtocolStatus = pb.ProtocolStatus
// RegisterCommand is the payload of an MXAccess Register command.
RegisterCommand = pb.RegisterCommand
// UnregisterCommand is the payload of an MXAccess Unregister command.
UnregisterCommand = pb.UnregisterCommand
// AddItemCommand is the payload of an MXAccess AddItem command.
AddItemCommand = pb.AddItemCommand
// AddItem2Command is the payload of an MXAccess AddItem2 command.
AddItem2Command = pb.AddItem2Command
// RemoveItemCommand is the payload of an MXAccess RemoveItem command.
RemoveItemCommand = pb.RemoveItemCommand
// AdviseCommand is the payload of an MXAccess Advise command.
AdviseCommand = pb.AdviseCommand
// UnAdviseCommand is the payload of an MXAccess UnAdvise command.
UnAdviseCommand = pb.UnAdviseCommand
// AddItemBulkCommand is the payload of an AddItem bulk command.
AddItemBulkCommand = pb.AddItemBulkCommand
// AdviseItemBulkCommand is the payload of an Advise bulk command.
AdviseItemBulkCommand = pb.AdviseItemBulkCommand
// RemoveItemBulkCommand is the payload of a RemoveItem bulk command.
RemoveItemBulkCommand = pb.RemoveItemBulkCommand
// UnAdviseItemBulkCommand is the payload of an UnAdvise bulk command.
UnAdviseItemBulkCommand = pb.UnAdviseItemBulkCommand
// SubscribeBulkCommand combines AddItem and Advise for a list of tags.
SubscribeBulkCommand = pb.SubscribeBulkCommand
// UnsubscribeBulkCommand combines UnAdvise and RemoveItem for a list of items.
UnsubscribeBulkCommand = pb.UnsubscribeBulkCommand
// WriteCommand is the payload of an MXAccess Write command.
WriteCommand = pb.WriteCommand
// Write2Command is the payload of an MXAccess Write2 command.
Write2Command = pb.Write2Command
// WriteBulkCommand is the payload of a bulk Write command.
WriteBulkCommand = pb.WriteBulkCommand
// WriteBulkEntry is one entry inside a WriteBulkCommand.
WriteBulkEntry = pb.WriteBulkEntry
// Write2BulkCommand is the payload of a bulk Write2 (timestamped) command.
Write2BulkCommand = pb.Write2BulkCommand
// Write2BulkEntry is one entry inside a Write2BulkCommand.
Write2BulkEntry = pb.Write2BulkEntry
// WriteSecuredBulkCommand is the payload of a bulk WriteSecured command.
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
// WriteSecuredBulkEntry is one entry inside a WriteSecuredBulkCommand.
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
// WriteSecured2BulkCommand is the payload of a bulk WriteSecured2 (timestamped) command.
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
// WriteSecured2BulkEntry is one entry inside a WriteSecured2BulkCommand.
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
// ReadBulkCommand is the payload of a bulk Read snapshot command.
ReadBulkCommand = pb.ReadBulkCommand
// BulkWriteReply aggregates BulkWriteResult entries for a bulk write command.
BulkWriteReply = pb.BulkWriteReply
// BulkWriteResult is one entry in a bulk write reply list.
BulkWriteResult = pb.BulkWriteResult
// BulkReadReply aggregates BulkReadResult entries for a bulk read command.
BulkReadReply = pb.BulkReadReply
// BulkReadResult is one entry in a bulk read reply list.
BulkReadResult = pb.BulkReadResult
// RegisterReply carries the ServerHandle returned by Register.
RegisterReply = pb.RegisterReply
// AddItemReply carries the ItemHandle returned by AddItem.
AddItemReply = pb.AddItemReply
// AddItem2Reply carries the ItemHandle returned by AddItem2.
AddItem2Reply = pb.AddItem2Reply
// SubscribeResult is one entry in a bulk command result list.
SubscribeResult = pb.SubscribeResult
// BulkSubscribeReply aggregates SubscribeResult entries for a bulk command.
BulkSubscribeReply = pb.BulkSubscribeReply
// AcknowledgeAlarmRequest is the gateway AcknowledgeAlarm request message.
AcknowledgeAlarmRequest = pb.AcknowledgeAlarmRequest
// AcknowledgeAlarmReply is the gateway AcknowledgeAlarm reply message.
AcknowledgeAlarmReply = pb.AcknowledgeAlarmReply
// QueryActiveAlarmsRequest is the gateway QueryActiveAlarms request message.
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
// StreamAlarmsRequest is the gateway StreamAlarms request message.
StreamAlarmsRequest = pb.StreamAlarmsRequest
// AlarmFeedMessage is one message on the StreamAlarms feed — an
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
AlarmFeedMessage = pb.AlarmFeedMessage
// ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
OnAlarmTransitionEvent = pb.OnAlarmTransitionEvent
)
// AlarmTransitionKind discriminates raise / acknowledge / clear / retrigger
// transitions on an OnAlarmTransitionEvent.
type AlarmTransitionKind = pb.AlarmTransitionKind
// AlarmConditionState reports the current state of an active alarm in a
// ConditionRefresh snapshot.
type AlarmConditionState = pb.AlarmConditionState
// QueryActiveAlarmsClient is the generated server-streaming client for the
// QueryActiveAlarms RPC.
type QueryActiveAlarmsClient = pb.MxAccessGateway_QueryActiveAlarmsClient
// StreamAlarmsClient is the generated server-streaming client for the
// StreamAlarms RPC.
type StreamAlarmsClient = pb.MxAccessGateway_StreamAlarmsClient
// Enumerations from the generated contract re-exported for client callers.
type (
// MxCommandKind discriminates which MXAccess command an MxCommand carries.
MxCommandKind = pb.MxCommandKind
// MxDataType is the MXAccess data type tag on values and arrays.
MxDataType = pb.MxDataType
// MxEventFamily groups MXAccess events by source category.
MxEventFamily = pb.MxEventFamily
// MxStatusCategory classifies MXSTATUS_PROXY entries.
MxStatusCategory = pb.MxStatusCategory
// MxStatusSource identifies the originator of a status entry.
MxStatusSource = pb.MxStatusSource
// ProtocolStatusCode enumerates gateway-level status codes.
ProtocolStatusCode = pb.ProtocolStatusCode
// SessionState enumerates gateway session lifecycle states.
SessionState = pb.SessionState
)
// MXAccess command kind, data type, and protocol status constants surfaced
// from the generated contract.
const (
// CommandKindRegister selects the MXAccess Register command.
CommandKindRegister = pb.MxCommandKind_MX_COMMAND_KIND_REGISTER
// CommandKindUnregister selects the MXAccess Unregister command.
CommandKindUnregister = pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER
// CommandKindAddItem selects the MXAccess AddItem command.
CommandKindAddItem = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM
// CommandKindAddItem2 selects the MXAccess AddItem2 command.
CommandKindAddItem2 = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2
// CommandKindRemoveItem selects the MXAccess RemoveItem command.
CommandKindRemoveItem = pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM
// CommandKindAdvise selects the MXAccess Advise command.
CommandKindAdvise = pb.MxCommandKind_MX_COMMAND_KIND_ADVISE
// CommandKindUnAdvise selects the MXAccess UnAdvise command.
CommandKindUnAdvise = pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE
// CommandKindAddItemBulk selects the AddItem bulk command.
CommandKindAddItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM_BULK
// CommandKindAdviseItemBulk selects the Advise bulk command.
CommandKindAdviseItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_ITEM_BULK
// CommandKindRemoveItemBulk selects the RemoveItem bulk command.
CommandKindRemoveItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_REMOVE_ITEM_BULK
// CommandKindUnAdviseItemBulk selects the UnAdvise bulk command.
CommandKindUnAdviseItemBulk = pb.MxCommandKind_MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK
// CommandKindSubscribeBulk selects the AddItem+Advise combined bulk command.
CommandKindSubscribeBulk = pb.MxCommandKind_MX_COMMAND_KIND_SUBSCRIBE_BULK
// CommandKindUnsubscribeBulk selects the UnAdvise+RemoveItem combined bulk command.
CommandKindUnsubscribeBulk = pb.MxCommandKind_MX_COMMAND_KIND_UNSUBSCRIBE_BULK
// CommandKindWrite selects the MXAccess Write command.
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
// CommandKindWrite2 selects the MXAccess Write2 command.
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
// DataTypeUnknown denotes an unrecognized MXAccess data type.
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
// DataTypeBoolean denotes an MXAccess Boolean value.
DataTypeBoolean = pb.MxDataType_MX_DATA_TYPE_BOOLEAN
// DataTypeInteger denotes an MXAccess Integer value.
DataTypeInteger = pb.MxDataType_MX_DATA_TYPE_INTEGER
// DataTypeFloat denotes an MXAccess Float (single precision) value.
DataTypeFloat = pb.MxDataType_MX_DATA_TYPE_FLOAT
// DataTypeDouble denotes an MXAccess Double (double precision) value.
DataTypeDouble = pb.MxDataType_MX_DATA_TYPE_DOUBLE
// DataTypeString denotes an MXAccess String value.
DataTypeString = pb.MxDataType_MX_DATA_TYPE_STRING
// DataTypeTime denotes an MXAccess timestamp value.
DataTypeTime = pb.MxDataType_MX_DATA_TYPE_TIME
// ProtocolStatusOK indicates the gateway processed the request successfully.
ProtocolStatusOK = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK
// ProtocolStatusMxAccessFailure indicates the worker reported an MXAccess failure.
ProtocolStatusMxAccessFailure = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE
)
+148
View File
@@ -0,0 +1,148 @@
package mxgateway
import (
"fmt"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/protobuf/types/known/timestamppb"
)
// BoolValue builds an MXAccess Boolean value.
func BoolValue(value bool) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_BOOLEAN,
VariantType: "VT_BOOL",
Kind: &pb.MxValue_BoolValue{BoolValue: value},
}
}
// Int32Value builds an MXAccess Int32 value.
func Int32Value(value int32) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
VariantType: "VT_I4",
Kind: &pb.MxValue_Int32Value{Int32Value: value},
}
}
// Int64Value builds an MXAccess Int64 value.
func Int64Value(value int64) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
VariantType: "VT_I8",
Kind: &pb.MxValue_Int64Value{Int64Value: value},
}
}
// FloatValue builds an MXAccess Float value.
func FloatValue(value float32) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_FLOAT,
VariantType: "VT_R4",
Kind: &pb.MxValue_FloatValue{FloatValue: value},
}
}
// DoubleValue builds an MXAccess Double value.
func DoubleValue(value float64) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_DOUBLE,
VariantType: "VT_R8",
Kind: &pb.MxValue_DoubleValue{DoubleValue: value},
}
}
// StringValue builds an MXAccess String value.
func StringValue(value string) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_STRING,
VariantType: "VT_BSTR",
Kind: &pb.MxValue_StringValue{StringValue: value},
}
}
// TimestampValue builds an MXAccess timestamp value from a Go time.
func TimestampValue(value time.Time) *MxValue {
return &pb.MxValue{
DataType: pb.MxDataType_MX_DATA_TYPE_TIME,
VariantType: "VT_DATE",
Kind: &pb.MxValue_TimestampValue{TimestampValue: timestamppb.New(value)},
}
}
// NativeValue converts a protobuf MxValue to the closest Go representation
// without discarding raw fallback data.
func NativeValue(value *MxValue) (any, error) {
if value == nil || value.GetIsNull() {
return nil, nil
}
switch kind := value.GetKind().(type) {
case *pb.MxValue_BoolValue:
return kind.BoolValue, nil
case *pb.MxValue_Int32Value:
return kind.Int32Value, nil
case *pb.MxValue_Int64Value:
return kind.Int64Value, nil
case *pb.MxValue_FloatValue:
return kind.FloatValue, nil
case *pb.MxValue_DoubleValue:
return kind.DoubleValue, nil
case *pb.MxValue_StringValue:
return kind.StringValue, nil
case *pb.MxValue_TimestampValue:
if kind.TimestampValue == nil {
return nil, nil
}
return kind.TimestampValue.AsTime(), nil
case *pb.MxValue_ArrayValue:
return NativeArray(kind.ArrayValue)
case *pb.MxValue_RawValue:
return append([]byte(nil), kind.RawValue...), nil
default:
return nil, fmt.Errorf("mxgateway: unsupported value kind %T", kind)
}
}
// NativeArray converts a protobuf MxArray to the closest Go slice
// representation.
func NativeArray(array *MxArray) (any, error) {
if array == nil {
return nil, nil
}
switch values := array.GetValues().(type) {
case *pb.MxArray_BoolValues:
return append([]bool(nil), values.BoolValues.GetValues()...), nil
case *pb.MxArray_Int32Values:
return append([]int32(nil), values.Int32Values.GetValues()...), nil
case *pb.MxArray_Int64Values:
return append([]int64(nil), values.Int64Values.GetValues()...), nil
case *pb.MxArray_FloatValues:
return append([]float32(nil), values.FloatValues.GetValues()...), nil
case *pb.MxArray_DoubleValues:
return append([]float64(nil), values.DoubleValues.GetValues()...), nil
case *pb.MxArray_StringValues:
return append([]string(nil), values.StringValues.GetValues()...), nil
case *pb.MxArray_TimestampValues:
result := make([]time.Time, 0, len(values.TimestampValues.GetValues()))
for _, value := range values.TimestampValues.GetValues() {
if value == nil {
result = append(result, time.Time{})
continue
}
result = append(result, value.AsTime())
}
return result, nil
case *pb.MxArray_RawValues:
rawValues := values.RawValues.GetValues()
result := make([][]byte, 0, len(rawValues))
for _, value := range rawValues {
result = append(result, append([]byte(nil), value...))
}
return result, nil
default:
return nil, fmt.Errorf("mxgateway: unsupported array value kind %T", values)
}
}
+15
View File
@@ -0,0 +1,15 @@
package mxgateway
const (
// ClientVersion identifies this Go client scaffold before package releases
// assign semantic versions.
ClientVersion = "0.1.0-dev"
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
// in the shared .NET contracts.
GatewayProtocolVersion uint32 = 3
// WorkerProtocolVersion matches GatewayContractInfo.WorkerProtocolVersion
// and is exposed for fake-worker and parity tests.
WorkerProtocolVersion uint32 = 1
)
@@ -5,8 +5,8 @@
Provide a Java client library for MXAccess Gateway, plus a test CLI and unit
tests. The Java client should work for JVM services and operator tooling.
Follow the [Java Style Guide](./style-guides/JavaStyleGuide.md) for handwritten
code and the [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md) for
Follow the [Java Style Guide](../../docs/style-guides/JavaStyleGuide.md) for handwritten
code and the [Protobuf Style Guide](../../docs/style-guides/ProtobufStyleGuide.md) for
generated contract inputs.
## Build Layout
@@ -17,13 +17,14 @@ Recommended Gradle multi-project layout:
clients/java/
settings.gradle
build.gradle
mxgateway-client/
src/main/generated/
zb-mom-ww-mxgateway-client/
build.gradle
src/main/java/com/dohertylan/mxgateway/client/
src/test/java/com/dohertylan/mxgateway/client/
mxgateway-cli/
src/main/java/com/zb/mom/ww/mxgateway/client/
src/test/java/com/zb/mom/ww/mxgateway/client/
zb-mom-ww-mxgateway-cli/
build.gradle
src/main/java/com/dohertylan/mxgateway/cli/
src/main/java/com/zb/mom/ww/mxgateway/cli/
```
Alternative Maven layout is acceptable if the repo standardizes on Maven.
@@ -31,6 +32,7 @@ Alternative Maven layout is acceptable if the repo standardizes on Maven.
Target Java:
- Java 21 recommended.
- The Gradle scaffold uses the Java 21 toolchain for compilation and tests.
Expected dependencies:
@@ -62,6 +64,12 @@ public final class MxGatewaySession implements AutoCloseable {
public int addItem(int serverHandle, String item);
public int addItem2(int serverHandle, String item, String context);
public void advise(int serverHandle, int itemHandle);
public List<SubscribeResult> addItemBulk(int serverHandle, List<String> tagAddresses);
public List<SubscribeResult> adviseItemBulk(int serverHandle, List<Integer> itemHandles);
public List<SubscribeResult> removeItemBulk(int serverHandle, List<Integer> itemHandles);
public List<SubscribeResult> unAdviseItemBulk(int serverHandle, List<Integer> itemHandles);
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> tagAddresses);
public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
public void write(int serverHandle, int itemHandle, MxValue value, int userId);
public Iterator<MxEvent> streamEvents();
public void streamEventsAsync(StreamObserver<MxEvent> observer);
@@ -104,6 +112,23 @@ Support:
- custom CA certificate file,
- server name override for test environments.
### Trust posture
The gateway can serve a self-signed certificate it generates itself (it has no
PKI). To make that usable, TLS is **lenient by default**: when the channel is not
plaintext and no `caCertificatePath` is set, the client builds
`GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)`
(grpc-netty-shaded), so the gateway's self-signed certificate is accepted without
verification.
To verify the gateway instead:
- set `caCertificatePath` to pin a CA (full verification against that root), or
- set `requireCertificateValidation` to `true` to verify against the JVM trust
store without pinning.
Pinning a CA always wins over the lenient default.
## Streaming
Support both:
@@ -184,8 +209,28 @@ stream for bounded time, and close.
Publish library and CLI separately:
- `mxgateway-client` jar,
- `mxgateway-cli` runnable distribution.
- `zb-mom-ww-mxgateway-client` jar,
- `zb-mom-ww-mxgateway-cli` runnable distribution.
Generated protobuf code should be produced during the build from shared proto
files and should not be hand-edited.
## Current Build
Run the Java scaffold checks from `clients/java`:
```powershell
gradle test
```
The `zb-mom-ww-mxgateway-client` project generates the gateway and worker
protobuf/gRPC bindings into `src/main/generated`, compiles the generated
contracts, and runs JUnit 5 tests. The `zb-mom-ww-mxgateway-cli` project
builds a Picocli-based `mxgw-java` entry point for later command implementation.
## Related Documentation
- [Client Libraries Detailed Design](../../docs/ClientLibrariesDesign.md)
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
- [Client Packaging](../../docs/ClientPackaging.md)
- [Java Style Guide](../../docs/style-guides/JavaStyleGuide.md)
+331
View File
@@ -0,0 +1,331 @@
# Java Client
The Java client workspace contains the MXAccess Gateway client library,
generated protobuf/gRPC bindings, a Picocli test CLI project, and JUnit tests.
## Layout
```text
clients/java/
settings.gradle
build.gradle
src/main/generated/
zb-mom-ww-mxgateway-client/
zb-mom-ww-mxgateway-cli/
```
`zb-mom-ww-mxgateway-client` generates Java protobuf and gRPC sources from
`../../src/ZB.MOM.WW.MxGateway.Contracts/Protos`. The Gradle protobuf plugin writes those
generated sources under `src/main/generated`, which matches the client proto
manifest in `../proto/proto-inputs.json`. Do not edit generated files by hand.
`zb-mom-ww-mxgateway-client` exposes `MxGatewayClientOptions`, `MxGatewayClient`,
`MxGatewaySession`, value/status helpers, typed gateway exceptions, raw
generated stubs, and generated protobuf messages for parity tests.
`zb-mom-ww-mxgateway-cli` depends on `zb-mom-ww-mxgateway-client` and provides
the `mxgw-java` application entry point. The CLI supports version, session,
command, event streaming, write, and smoke-test commands with deterministic
JSON output.
## Regenerating Protobuf Bindings
Run generation from `clients/java` after the shared `.proto` files or Java
output path changes:
```powershell
gradle :zb-mom-ww-mxgateway-client:generateProto
```
## Client Usage
Create a client with explicit transport and auth options:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (MxGatewayClient client = MxGatewayClient.connect(options);
MxGatewaySession session = client.openSession("java-client")) {
int serverHandle = session.register("java-client");
int itemHandle = session.addItem(serverHandle, "TestObject.TestInt");
session.advise(serverHandle, itemHandle);
session.write(serverHandle, itemHandle, MxValues.int32Value(123), 0);
}
```
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
the client is **lenient by default**: a TLS connection (`plaintext(false)`) with
no `caCertificatePath` accepts whatever certificate the gateway presents (via
grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set
`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to
verify against the JVM trust store without pinning. Use `serverNameOverride` /
`--server-name-override` when the dialed host differs from the certificate SAN.
See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
underlying protobuf messages. `MxGatewayCommandException` and
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
data-bearing MXAccess failure.
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
cancels the underlying gRPC stream. Canceling or timing out a Java client call
only stops the client from waiting; it does not abort an in-flight MXAccess COM
call on the worker STA.
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
yields alarm-feed messages from the gateway's central monitor), and
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
ack target). Close the subscription to cancel the underlying gRPC stream.
## Galaxy Repository Browse
The Galaxy Repository service is a separate metadata-only gRPC service exposed
by the gateway. It lets clients enumerate the deployed Galaxy object hierarchy
and the dynamic attributes on each object so they know which tag references to
subscribe to via the MXAccess Gateway service. It uses the same API-key auth as
the gateway and requires the `metadata:read` scope.
`GalaxyRepositoryClient` mirrors the `MxGatewayClient` pattern (caller-managed
or owned channel, `MxGatewayClientOptions`, blocking + async variants). Three
RPCs are exposed:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
boolean ok = galaxy.testConnection();
Optional<Instant> lastDeploy = galaxy.getLastDeployTime();
List<GalaxyObject> hierarchy = galaxy.discoverHierarchy();
}
```
`getLastDeployTime` returns `Optional.empty()` when the server reports
`present=false`. `discoverHierarchy` returns the generated `GalaxyObject` proto
messages directly so callers can read all fields (including the nested
`GalaxyAttribute` list) without an extra DTO layer.
The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
`galaxy-discover`, and `galaxy-watch`. They take the same `--endpoint`,
`--api-key-env`, `--plaintext`, `--ca-file`, `--server-name-override`,
`--timeout`, and `--json` options as the gateway commands.
```powershell
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
```
### Browsing lazily
For UI trees or OPC UA bridges, use `browseChildren` to walk one level at a
time instead of loading the full hierarchy with `discoverHierarchy`. Pass a
default request for root objects; subsequent calls set `parentGobjectId`,
`parentTagName`, or `parentContainedPath`. Filter fields match
`DiscoverHierarchy`. Each response pairs `getChildrenList()` with
`getChildHasChildrenList()` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics. This snippet documents the API as it appears once
the Java client is regenerated on the Windows host.
```java
BrowseChildrenReply reply = galaxy.browseChildren(
BrowseChildrenRequest.newBuilder().build());
List<GalaxyObject> children = reply.getChildrenList();
List<Boolean> hasChildren = reply.getChildHasChildrenList();
for (int i = 0; i < children.size(); i++) {
System.out.printf("%s expand=%b%n", children.get(i).getTagName(), hasChildren.get(i));
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
List<LazyBrowseNode> roots = galaxy.browse();
for (LazyBrowseNode root : roots) {
if (root.hasChildrenHint()) {
root.expand();
}
for (LazyBrowseNode child : root.getChildren()) {
String kind = child.hasChildrenHint() ? "has children" : "leaf";
System.out.println(child.getObject().getTagName() + " (" + kind + ")");
}
}
}
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
sends a bootstrap `DeployEvent` immediately on subscribe and then one event
each time it observes a new `galaxy.time_of_last_deploy`. The `sequence` field
is monotonic per server start; gaps mean the per-subscriber buffer dropped
older events because the consumer was too slow.
The client exposes both an iterator-style adaptor over the async stub and an
observer-callback variant. Both honour the channel-level `streamTimeout`.
```java
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options);
DeployEventStream events = galaxy.watchDeployEvents(/* lastSeenDeployTime */ null)) {
while (events.hasNext()) {
DeployEvent event = events.next();
// event.getSequence(), event.getObservedAt(),
// event.getTimeOfLastDeploy() / getTimeOfLastDeployPresent(),
// event.getObjectCount(), event.getAttributeCount()
}
}
```
Pass an `Instant` for `lastSeenDeployTime` to suppress the bootstrap event when
the cached deploy time matches what the caller already has. `DeployEventStream`
implements `Iterator<DeployEvent>` and `AutoCloseable`; closing it cancels the
underlying gRPC call.
For callback delivery (e.g. when the consumer wants to drive a queue or
reactive pipeline), use the async variant:
```java
DeployEventSubscription subscription = galaxy.watchDeployEventsAsync(
lastSeen,
new StreamObserver<>() {
@Override public void onNext(DeployEvent value) { /* ... */ }
@Override public void onError(Throwable t) { /* ... */ }
@Override public void onCompleted() { /* ... */ }
});
// later:
subscription.cancel(); // or subscription.close()
```
The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
one line per event in text mode or one JSON object per event with `--json`:
```powershell
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
```
## CLI Usage
Run the CLI through Gradle:
```powershell
gradle :zb-mom-ww-mxgateway-cli:run --args="version --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --client-name java-cli --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
```
The CLI accepts `--api-key`, `--api-key-env`, `--plaintext`, `--ca-file`,
`--server-name-override`, `--timeout`, and `--json` on gateway commands. JSON
output redacts API keys.
Use TLS options for a secured gateway:
```powershell
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestObject.TestInt --json"
```
## Build And Test
Run the Java checks from `clients/java`:
```powershell
gradle test
```
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC
code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures,
in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
## Packaging
Create local library and CLI artifacts from `clients/java`:
```powershell
gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
```
The library jar is under `zb-mom-ww-mxgateway-client/build/libs`. The installed CLI
distribution is under `zb-mom-ww-mxgateway-cli/build/install/zb-mom-ww-mxgateway-cli`.
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
```powershell
$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'localhost:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
```
## Installing from the Gitea Maven repository
The client publishes to the internal Gitea Maven repository at
`https://gitea.dohertylan.com/api/packages/dohertj2/maven`.
In your consumer project's `build.gradle`:
````groovy
repositories {
maven {
url 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
credentials {
username = System.getenv('GITEA_USERNAME')
password = System.getenv('GITEA_TOKEN')
}
}
}
dependencies {
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.0'
}
````
To publish a new version from this repo:
````bash
export GITEA_USERNAME=dohertj2
export GITEA_TOKEN=<your-gitea-token>
gradle :zb-mom-ww-mxgateway-client:publish
````
## Related Documentation
- [Client Packaging](../../docs/ClientPackaging.md)
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
- [Java Client Detailed Design](./JavaClientDesign.md)
- [Java Style Guide](../../docs/style-guides/JavaStyleGuide.md)
+80
View File
@@ -0,0 +1,80 @@
plugins {
id 'base'
}
ext {
guavaVersion = '33.5.0-jre'
gsonVersion = '2.13.2'
grpcVersion = '1.76.0'
junitVersion = '5.14.1'
picocliVersion = '4.7.7'
protobufVersion = '4.33.1'
}
subprojects {
group = 'com.zb.mom.ww.mxgateway'
version = '0.1.0'
pluginManager.withPlugin('java') {
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 21
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
}
dependencies {
testImplementation platform("org.junit:junit-bom:${junitVersion}")
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
}
pluginManager.withPlugin('maven-publish') {
publishing {
publications {
maven(MavenPublication) {
from components.java
pom {
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
description = 'MxAccessGateway Java client'
scm {
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
connection = 'scm:git:https://gitea.dohertylan.com/dohertj2/mxaccessgw.git'
}
developers {
developer {
id = 'dohertj2'
name = 'Joseph Doherty'
}
}
licenses {
license {
name = 'Proprietary'
distribution = 'repo'
}
}
}
}
}
repositories {
maven {
name = 'GiteaPackages'
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
credentials {
username = System.getenv('GITEA_USERNAME') ?: ''
password = System.getenv('GITEA_TOKEN') ?: ''
}
}
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
plugins {
id 'com.google.protobuf' version '0.9.5'
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}
rootProject.name = 'zb-mom-ww-mxaccessgw-java'
include 'zb-mom-ww-mxgateway-client'
include 'zb-mom-ww-mxgateway-cli'
@@ -0,0 +1,752 @@
package galaxy_repository.v1;
import static io.grpc.MethodDescriptor.generateFullMethodName;
/**
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
@io.grpc.stub.annotations.GrpcGenerated
public final class GalaxyRepositoryGrpc {
private GalaxyRepositoryGrpc() {}
public static final java.lang.String SERVICE_NAME = "galaxy_repository.v1.GalaxyRepository";
// Static method descriptors that strictly reflect the proto.
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "TestConnection",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod;
if ((getTestConnectionMethod = GalaxyRepositoryGrpc.getTestConnectionMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getTestConnectionMethod = GalaxyRepositoryGrpc.getTestConnectionMethod) == null) {
GalaxyRepositoryGrpc.getTestConnectionMethod = getTestConnectionMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "TestConnection"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("TestConnection"))
.build();
}
}
}
return getTestConnectionMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "GetLastDeployTime",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod;
if ((getGetLastDeployTimeMethod = GalaxyRepositoryGrpc.getGetLastDeployTimeMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getGetLastDeployTimeMethod = GalaxyRepositoryGrpc.getGetLastDeployTimeMethod) == null) {
GalaxyRepositoryGrpc.getGetLastDeployTimeMethod = getGetLastDeployTimeMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "GetLastDeployTime"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("GetLastDeployTime"))
.build();
}
}
}
return getGetLastDeployTimeMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "DiscoverHierarchy",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod;
if ((getDiscoverHierarchyMethod = GalaxyRepositoryGrpc.getDiscoverHierarchyMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getDiscoverHierarchyMethod = GalaxyRepositoryGrpc.getDiscoverHierarchyMethod) == null) {
GalaxyRepositoryGrpc.getDiscoverHierarchyMethod = getDiscoverHierarchyMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "DiscoverHierarchy"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("DiscoverHierarchy"))
.build();
}
}
}
return getDiscoverHierarchyMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "WatchDeployEvents",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent.class,
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod;
if ((getWatchDeployEventsMethod = GalaxyRepositoryGrpc.getWatchDeployEventsMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getWatchDeployEventsMethod = GalaxyRepositoryGrpc.getWatchDeployEventsMethod) == null) {
GalaxyRepositoryGrpc.getWatchDeployEventsMethod = getWatchDeployEventsMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "WatchDeployEvents"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("WatchDeployEvents"))
.build();
}
}
}
return getWatchDeployEventsMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> getBrowseChildrenMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "BrowseChildren",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> getBrowseChildrenMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> getBrowseChildrenMethod;
if ((getBrowseChildrenMethod = GalaxyRepositoryGrpc.getBrowseChildrenMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getBrowseChildrenMethod = GalaxyRepositoryGrpc.getBrowseChildrenMethod) == null) {
GalaxyRepositoryGrpc.getBrowseChildrenMethod = getBrowseChildrenMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "BrowseChildren"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("BrowseChildren"))
.build();
}
}
}
return getBrowseChildrenMethod;
}
/**
* Creates a new async stub that supports all call types for the service
*/
public static GalaxyRepositoryStub newStub(io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryStub>() {
@java.lang.Override
public GalaxyRepositoryStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryStub(channel, callOptions);
}
};
return GalaxyRepositoryStub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports all types of calls on the service
*/
public static GalaxyRepositoryBlockingV2Stub newBlockingV2Stub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingV2Stub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingV2Stub>() {
@java.lang.Override
public GalaxyRepositoryBlockingV2Stub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingV2Stub(channel, callOptions);
}
};
return GalaxyRepositoryBlockingV2Stub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports unary and streaming output calls on the service
*/
public static GalaxyRepositoryBlockingStub newBlockingStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingStub>() {
@java.lang.Override
public GalaxyRepositoryBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingStub(channel, callOptions);
}
};
return GalaxyRepositoryBlockingStub.newStub(factory, channel);
}
/**
* Creates a new ListenableFuture-style stub that supports unary calls on the service
*/
public static GalaxyRepositoryFutureStub newFutureStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryFutureStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryFutureStub>() {
@java.lang.Override
public GalaxyRepositoryFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryFutureStub(channel, callOptions);
}
};
return GalaxyRepositoryFutureStub.newStub(factory, channel);
}
/**
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public interface AsyncService {
/**
*/
default void testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getTestConnectionMethod(), responseObserver);
}
/**
*/
default void getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getGetLastDeployTimeMethod(), responseObserver);
}
/**
*/
default void discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getDiscoverHierarchyMethod(), responseObserver);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
default void watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getWatchDeployEventsMethod(), responseObserver);
}
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
default void browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getBrowseChildrenMethod(), responseObserver);
}
}
/**
* Base class for the server implementation of the service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static abstract class GalaxyRepositoryImplBase
implements io.grpc.BindableService, AsyncService {
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
return GalaxyRepositoryGrpc.bindService(this);
}
}
/**
* A stub to allow clients to do asynchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryStub
extends io.grpc.stub.AbstractAsyncStub<GalaxyRepositoryStub> {
private GalaxyRepositoryStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryStub(channel, callOptions);
}
/**
*/
public void testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getTestConnectionMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getGetLastDeployTimeMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request, responseObserver);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
public void watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
getChannel().newCall(getWatchDeployEventsMethod(), getCallOptions()), request, responseObserver);
}
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
public void browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getBrowseChildrenMethod(), getCallOptions()), request, responseObserver);
}
}
/**
* A stub to allow clients to do synchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryBlockingV2Stub
extends io.grpc.stub.AbstractBlockingStub<GalaxyRepositoryBlockingV2Stub> {
private GalaxyRepositoryBlockingV2Stub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryBlockingV2Stub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingV2Stub(channel, callOptions);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getTestConnectionMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getGetLastDeployTimeMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getDiscoverHierarchyMethod(), getCallOptions(), request);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
public io.grpc.stub.BlockingClientCall<?, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>
watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
}
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getBrowseChildrenMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do limited synchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryBlockingStub
extends io.grpc.stub.AbstractBlockingStub<GalaxyRepositoryBlockingStub> {
private GalaxyRepositoryBlockingStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryBlockingStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingStub(channel, callOptions);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getTestConnectionMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getGetLastDeployTimeMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getDiscoverHierarchyMethod(), getCallOptions(), request);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
public java.util.Iterator<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> watchDeployEvents(
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
}
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getBrowseChildrenMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do ListenableFuture-style rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryFutureStub
extends io.grpc.stub.AbstractFutureStub<GalaxyRepositoryFutureStub> {
private GalaxyRepositoryFutureStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryFutureStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryFutureStub(channel, callOptions);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> testConnection(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getTestConnectionMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getLastDeployTime(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getGetLastDeployTimeMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> discoverHierarchy(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request);
}
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> browseChildren(
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getBrowseChildrenMethod(), getCallOptions()), request);
}
}
private static final int METHODID_TEST_CONNECTION = 0;
private static final int METHODID_GET_LAST_DEPLOY_TIME = 1;
private static final int METHODID_DISCOVER_HIERARCHY = 2;
private static final int METHODID_WATCH_DEPLOY_EVENTS = 3;
private static final int METHODID_BROWSE_CHILDREN = 4;
private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ServerStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ClientStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.BidiStreamingMethod<Req, Resp> {
private final AsyncService serviceImpl;
private final int methodId;
MethodHandlers(AsyncService serviceImpl, int methodId) {
this.serviceImpl = serviceImpl;
this.methodId = methodId;
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public void invoke(Req request, io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
case METHODID_TEST_CONNECTION:
serviceImpl.testConnection((galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>) responseObserver);
break;
case METHODID_GET_LAST_DEPLOY_TIME:
serviceImpl.getLastDeployTime((galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>) responseObserver);
break;
case METHODID_DISCOVER_HIERARCHY:
serviceImpl.discoverHierarchy((galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>) responseObserver);
break;
case METHODID_WATCH_DEPLOY_EVENTS:
serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver);
break;
case METHODID_BROWSE_CHILDREN:
serviceImpl.browseChildren((galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply>) responseObserver);
break;
default:
throw new AssertionError();
}
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public io.grpc.stub.StreamObserver<Req> invoke(
io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
default:
throw new AssertionError();
}
}
}
public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) {
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
.addMethod(
getTestConnectionMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>(
service, METHODID_TEST_CONNECTION)))
.addMethod(
getGetLastDeployTimeMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>(
service, METHODID_GET_LAST_DEPLOY_TIME)))
.addMethod(
getDiscoverHierarchyMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>(
service, METHODID_DISCOVER_HIERARCHY)))
.addMethod(
getWatchDeployEventsMethod(),
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>(
service, METHODID_WATCH_DEPLOY_EVENTS)))
.addMethod(
getBrowseChildrenMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply>(
service, METHODID_BROWSE_CHILDREN)))
.build();
}
private static abstract class GalaxyRepositoryBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier {
GalaxyRepositoryBaseDescriptorSupplier() {}
@java.lang.Override
public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() {
return galaxy_repository.v1.GalaxyRepositoryOuterClass.getDescriptor();
}
@java.lang.Override
public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() {
return getFileDescriptor().findServiceByName("GalaxyRepository");
}
}
private static final class GalaxyRepositoryFileDescriptorSupplier
extends GalaxyRepositoryBaseDescriptorSupplier {
GalaxyRepositoryFileDescriptorSupplier() {}
}
private static final class GalaxyRepositoryMethodDescriptorSupplier
extends GalaxyRepositoryBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoMethodDescriptorSupplier {
private final java.lang.String methodName;
GalaxyRepositoryMethodDescriptorSupplier(java.lang.String methodName) {
this.methodName = methodName;
}
@java.lang.Override
public com.google.protobuf.Descriptors.MethodDescriptor getMethodDescriptor() {
return getServiceDescriptor().findMethodByName(methodName);
}
}
private static volatile io.grpc.ServiceDescriptor serviceDescriptor;
public static io.grpc.ServiceDescriptor getServiceDescriptor() {
io.grpc.ServiceDescriptor result = serviceDescriptor;
if (result == null) {
synchronized (GalaxyRepositoryGrpc.class) {
result = serviceDescriptor;
if (result == null) {
serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME)
.setSchemaDescriptor(new GalaxyRepositoryFileDescriptorSupplier())
.addMethod(getTestConnectionMethod())
.addMethod(getGetLastDeployTimeMethod())
.addMethod(getDiscoverHierarchyMethod())
.addMethod(getWatchDeployEventsMethod())
.addMethod(getBrowseChildrenMethod())
.build();
}
}
}
return result;
}
}

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