38 Commits

Author SHA1 Message Date
Joseph Doherty bd6c0b4d3d docs: complete XML doc comments via fixdocs (2757 to 131 findings)
Add missing <returns>/<param>/<summary>/<typeparam> tags and clean up
misused inheritdoc across 481 files so the documented API surface is
complete. Documentation-only (zero code lines changed). The 131 remaining
findings are inheritdoc-style warnings deliberately left to preserve
hand-written implementation rationale (plan-decision notes, race-condition
explanations).
2026-06-03 12:34:34 -04:00
Joseph Doherty c6d9b20d9f chore(adminui): prune kit-duplicate + dead shell CSS from site.css
v2-ci / build (push) Failing after 6m40s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
The ZB.MOM.WW.Theme cutover left site.css carrying a near-verbatim copy of the
kit's layout.css (.app-shell/.side-rail/.rail-link/.rail-foot/.login-*) plus two
dead rules (#sidebar-collapse — the kit emits #theme-rail; .rail-eyebrow-chevron
— rendered by the deleted NavSection.razor). Those duplicates loaded after the
kit and could silently override it. Removed them; kept only the app-only rules
the kit does not provide: .rail-eyebrow (footer Session label) and
.chip-alert/.chip-caution (domain status variants). 167 lines removed; builds clean.
2026-06-03 04:37:23 -04:00
Joseph Doherty 11de14d12e refactor(adminui): explicit ClaimTypes.Role footer filter; fix stale NavSidebar comment
v2-ci / build (push) Failing after 45s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-03 03:18:08 -04:00
Joseph Doherty aadbf49678 feat(adminui): LoginCard sign-in; remove dead StatusBadge 2026-06-03 03:13:23 -04:00
Joseph Doherty 70d764b063 feat(adminui): MainLayout delegates to ZB.MOM.WW.Theme ThemeShell + kit nav 2026-06-03 03:10:49 -04:00
Joseph Doherty 11bcff6af5 refactor(adminui): drop vendored theme.css/fonts/nav-state.js; keep app-only CSS in site.css 2026-06-03 03:07:21 -04:00
Joseph Doherty de41963587 feat(adminui): use ZB.MOM.WW.Theme ThemeHead + ThemeScripts 2026-06-03 03:03:45 -04:00
Joseph Doherty a78b212c95 build(adminui): reference ZB.MOM.WW.Theme 0.2.0 2026-06-03 03:02:23 -04:00
Joseph Doherty 075c0e69da feat(audit): OtOpcUa IAuditActorAccessor seam + HTTP impl (audit Actor from Auth principal) (Phase 3)
v2-ci / build (push) Failing after 40s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Introduces the IAuditActorAccessor seam and HttpAuditActorAccessor impl so the
ZB.MOM.WW.Audit.AuditEvent Actor field can be sourced from the authenticated Blazor
cookie principal (ZbClaimTypes.Username) when structured emitters are added. Adds the
AuditActor.Resolve static helper (accessor value → SystemFallback/"system") as the
canonical pattern for future emit sites. Wires DI in AddOtOpcUaAuth (TryAddScoped) with
AddHttpContextAccessor(). The structured AuditEvent path remains DORMANT — no live emit
sites exist; seam is forward-looking. SP-based audit path left untouched. 9 new unit
tests all green; Security (54) and ControlPlane (45) test suites fully pass.
2026-06-02 15:25:49 -04:00
Joseph Doherty b7f5e887ee feat(audit): OtOpcUa ConfigAuditLog.Outcome column + migration + ClusterAudit visibility fix (Task 2.2)
Persist the canonical AuditOutcome and make structured audit rows visible.

- ConfigAuditLog gains a nullable Outcome column, stored as the AuditOutcome
  enum member name (nvarchar(16), mirroring how AdminRole is persisted). The
  AuditWriterActor flush now writes Outcome = evt.Outcome.ToString(). Nullable so
  legacy rows and the bespoke stored-procedure path (no derived outcome) write
  NULL.
- Migration 20260602135350_AddConfigAuditLogOutcome: additive nullable column,
  no backfill. Up adds the column, Down drops it. Chains after
  20260602112419_CanonicalizeAdminRoles; `dotnet ef migrations
  has-pending-model-changes` is clean.
- ClusterAudit visibility fix: the page filtered solely on ClusterId, but the
  structured AuditWriterActor path stamps NodeId (ClusterId null), so those rows
  were invisible. Extracted ClusterAuditQuery.ForClusterAsync (shared by the page
  and tests) which ORs in rows whose NodeId belongs to a node in the cluster —
  membership resolved from ClusterNode (NodeId -> ClusterId). SP-path
  ClusterId-stamped rows still match.

Tests: ControlPlane 45/45 (adds Outcome persistence + Denied-outcome asserts);
new Configuration ClusterAuditQueryTests 3/3 (both-paths visible, other-cluster
excluded, page-size cap); AdminUI 121/121. Configuration Unit suite is green on a
clean run (a pre-existing timing flake in ResilientConfigReaderTests, untouched
here, occasionally fails under parallel load and passes in isolation).
2026-06-02 09:59:22 -04:00
Joseph Doherty 933dd1a874 feat(audit): OtOpcUa adopt canonical ZB.MOM.WW.Audit.AuditEvent + AuditWriterActor:IAuditWriter + Outcome derivation (Task 2.1)
Deep-adopt the shared audit record. Deletes the bespoke 8-field positional
Commons AuditEvent and repoints the writer path at ZB.MOM.WW.Audit.AuditEvent
(0.1.0, feed-mapped via dohertj2-gitea). Adds the package reference to both
Commons and ControlPlane.

- AuditWriterActor now implements IAuditWriter: WriteAsync(evt, ct) is a
  best-effort, never-throwing entry point that Self.Tell()s the event onto the
  same batching/dedup/flush pipeline and returns Task.CompletedTask. Existing
  Receive<AuditEvent> + 500/5s batching + two-layer dedup unchanged.
- Flush mapping updated for the canonical field types: OccurredAtUtc is now
  DateTimeOffset (.UtcDateTime into the datetime2 column), SourceNode is string?
  (was NodeId.Value), CorrelationId is Guid? (stored null when null). Outcome is
  NOT yet persisted (column lands in Task 2.2).
- New AuditOutcomeMapper.FromAction maps the OtOpcUa action vocabulary to the
  required canonical Outcome: OpcUaAccessDenied / CrossClusterNamespaceAttempt ->
  Denied; config verbs (DraftCreated/Edited, Published, RolledBack, NodeApplied,
  ClusterCreated, NodeAdded, CredentialAdded/Disabled, ExternalIdReleased) ->
  Success. OtOpcUa emits no Failure events.

The Akka message shape changed, but the structured audit path is dormant (zero
production emit/Tell sites; all live audit flows through the bespoke SP path),
so there is no rolling-deploy wire-compat concern. Tested-not-exercised by
design.

ControlPlane.Tests: 44/44 green (AuditWriterActor suite rewritten to construct
the canonical record + assert the Outcome derivation table + the WriteAsync
best-effort/mailbox-routing contract + null SourceNode/CorrelationId handling).
2026-06-02 09:53:12 -04:00
Joseph Doherty c1619d95f5 feat(auth)!: OtOpcUa canonical control-plane roles + config-DB migration (Task 1.7)
Standardize the control-plane admin role VALUES on the canonical six
(ZB.MOM.WW.Auth CanonicalRole). OtOpcUa uses four:
  ConfigViewer   -> Viewer
  ConfigEditor   -> Designer
  FleetAdmin     -> Administrator
  DriverOperator -> Operator   (appsettings-only string role)

This is a rename, not a permission change: enforcement semantics are
preserved (whoever could deploy/administer/operate before still can).

- AdminRole enum members renamed (persisted as string names via
  HasConversion<string>); RoleGrants.razor dropdown default updated.
- EF DATA migration CanonicalizeAdminRoles rewrites existing
  LdapGroupRoleMapping.Role rows old->new (Up) and back (Down); schema /
  model snapshot byte-identical (no pending model changes).
- Enforcement role STRINGS canonicalized:
  * Security policies keep their NAMES ("DriverOperator"/"FleetAdmin")
    but require canonical roles: RequireRole("Operator","Administrator")
    and RequireRole("Administrator").
  * Deployments.razor [Authorize(Roles="Administrator,Designer")].
  * DevStub now grants "Administrator"; LdapOptions/doc-comment examples
    canonicalized.
- Data-plane authorization (NodePermissions/NodeAcl/IPermissionEvaluator/
  TriePermissionEvaluator/UserAuthorizationState) UNTOUCHED.
- New CanonicalAdminRolesTests pins canonical claim values end-to-end and
  the real registered policies; existing role-string tests updated.
2026-06-02 07:30:00 -04:00
Joseph Doherty 8ba289f975 chore(auth): OtOpcUa unify dev LDAP base DN to dc=zb,dc=local (Task 1.6)
Replace all dev-directory dc=lmxopcua,dc=local references with dc=zb,dc=local
across LdapOptions default, integration harness overrides, docker-compose LDAP_ROOT,
AclEdit placeholder DN, and dev/smoke-test docs. CN/OU prefixes preserved.
2026-06-02 06:45:23 -04:00
Joseph Doherty d0777eee29 fix(auth): OtOpcUa Task 1.5 review — pin JWT role-claim test + document issued-only JWT role key
Fix 1 (test): Token_payload_uses_canonical_zb_claim_keys now asserts that the JWT
payload carries at least one role under JwtTokenService.RoleClaimType ("Role"),
pinning the role-key contract so a future rename is caught immediately. Adds a
comment explaining why alice has roles (appsettings "ReadOnly"→"ConfigViewer"
baseline). Adds missing `using ZB.MOM.WW.OtOpcUa.Security.Jwt` to the test file.

Fix 2 (no-validation path — no AddJwtBearer in production pipeline): grep of src/
confirms no AddJwtBearer / JwtBearer scheme in ServiceCollectionExtensions or Host;
the ServiceCollectionExtensions doc comment explicitly states "no JwtBearer parallel
scheme". RoleClaimType intentionally stays the short "Role" key. Three changes:
  - RoleClaimType doc comment documents issued-only nature, the caveat that a
    JwtBearer scheme MUST use BuildValidationParameters(), and that BuildValidationParameters
    is already wired to set RoleClaimType+NameClaimType correctly.
  - Issue() inline comment at the role-mint site references RoleClaimType docs.
  - BuildValidationParameters() now sets RoleClaimType=RoleClaimType and
    NameClaimType=UsernameClaimType so that if it is ever passed to AddJwtBearer,
    role/name resolution is correct without any extra wiring. TryValidate() is
    refactored to delegate to BuildValidationParameters() so the two can never drift.

All 35 security tests green.
2026-06-02 06:30:10 -04:00
Joseph Doherty 83856b7c27 feat(auth): OtOpcUa adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5)
Add ZB.MOM.WW.Auth.AspNetCore package ref to Security project (version 0.1.1
from central PM). Alias JwtTokenService.UsernameClaimType and DisplayNameClaimType
to ZbClaimTypes.Username ("zb:username") and ZbClaimTypes.DisplayName ("zb:displayname")
so every mint/read site inherits the canonical spelling. AuthEndpoints login path now
emits ZbClaimTypes.Name (= ClaimTypes.Name, populates Identity.Name) instead of
ClaimTypes.NameIdentifier (no other read site used it), and references ZbClaimTypes.Role
(= ClaimTypes.Role) for role claims so [Authorize(Roles=...)] continues to resolve.
Cookie hardening now flows through ZbCookieDefaults.Apply (sets HttpOnly, SameSite=Strict,
SlidingExpiration, SecurePolicy, ExpireTimeSpan) followed by opts.Cookie.Name = v.Name to
preserve the OtOpcUa-specific "ZB.MOM.WW.OtOpcUa.Auth" cookie name. Two new tests added
to AuthEndpointsIntegrationTests assert canonical ZbClaimTypes on the cookie principal and
canonical zb: keys in the JWT payload; all 35 security tests green.
2026-06-02 06:11:00 -04:00
Joseph Doherty c4f315ec90 fix(auth): OtOpcUa 1.2 review fixes — startup insecure-transport guard + Ldaps in prod overlays, test fidelity, 0.1.1 pin 2026-06-02 01:37:29 -04:00
Joseph Doherty 257caa7bd1 feat(auth): cut OtOpcUa over to ZB.MOM.WW.Auth.Ldap; preserve DevStubMode; route roles via IGroupRoleMapper (Task 1.2/1.4) 2026-06-02 00:55:10 -04:00
Joseph Doherty 6534875476 feat(auth): add IGroupRoleMapper<string> seam (Task 1.1) 2026-06-02 00:29:45 -04:00
Joseph Doherty d2d7730830 build: add ZB.MOM.WW.Auth/Audit feed mapping + version pins
Maps ZB.MOM.WW.Auth, ZB.MOM.WW.Auth.*, ZB.MOM.WW.Audit to the gitea feed
and pins Auth.Abstractions/Ldap/AspNetCore + Audit at 0.1.0. No project
references yet (added during Phase 1/2 adoption). OtOpcUa omits Auth.ApiKeys
(OPC UA transport security).
2026-06-02 00:16:39 -04:00
Joseph Doherty 2844180865 fix: honor LdapOptions.Enabled at runtime; dedupe ILdapAuthService registration; +SearchBase test, doc fix
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-01 23:03:12 -04:00
Joseph Doherty d3ab2bfbaf fix: bind OtOpcUa LdapOptions from real Security:Ldap section; gate validator on DevStubMode 2026-06-01 22:46:09 -04:00
Joseph Doherty 88e773af36 feat: validate OpcUa host options at startup (route through IOptions + ValidateOnStart) 2026-06-01 18:45:55 -04:00
Joseph Doherty f35ebd7aaf feat: add fail-fast LDAP options validation in OtOpcUa via ZB.MOM.WW.Configuration 2026-06-01 18:32:44 -04:00
Joseph Doherty 0cbb82e466 build: add ZB.MOM.WW.Configuration feed mapping + version pin 2026-06-01 18:10:28 -04:00
Joseph Doherty 7b6884031d Merge feat/telemetry-followons: telemetry follow-ons for OtOpcUa
v2-ci / build (push) Failing after 34s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Serilog.AspNetCore/Extensions.Hosting/Settings.Configuration aligned to 10.0.0;
config-driven OTLP exporter opt-in (default Prometheus; also makes recorded
spans exportable when OTLP is configured).
2026-06-01 17:17:23 -04:00
Joseph Doherty 7ff7a60ae0 feat(otopcua): config-driven OTLP exporter opt-in (default Prometheus) 2026-06-01 16:40:24 -04:00
Joseph Doherty 8faa2bf23d build(otopcua): align Serilog.AspNetCore/Extensions.Hosting/Settings.Configuration to 10.0.0 2026-06-01 16:35:34 -04:00
Joseph Doherty 2099713ed8 Merge feat/adopt-zb-telemetry: adopt ZB.MOM.WW.Telemetry across OtOpcUa
v2-ci / build (push) Failing after 51s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
AddZbTelemetry (shared OTel Resource identity + standard instrumentation;
kept meter ZB.MOM.WW.OtOpcUa + /metrics) and AddZbSerilog (shared enrichers +
trace correlation; sinks moved to appsettings). Behaviour-preserving.
2026-06-01 16:05:34 -04:00
Joseph Doherty c05ffc7b39 build(otopcua): add <clear/> to NuGet.config packageSources for supply-chain hygiene parity 2026-06-01 16:03:15 -04:00
Joseph Doherty 60017177cb feat(otopcua): adopt AddZbSerilog (shared enrichers + trace correlation); sinks to config 2026-06-01 15:41:21 -04:00
Joseph Doherty 26bae36f8b feat(otopcua): wire OTel via AddZbTelemetry (shared Resource + std instrumentation) 2026-06-01 15:33:28 -04:00
Joseph Doherty 368390ea9d build(otopcua): reference ZB.MOM.WW.Telemetry packages from Gitea feed 2026-06-01 15:29:46 -04:00
Joseph Doherty 8f950722c6 Merge feat/adopt-zb-health: adopt ZB.MOM.WW.Health shared probes (OtOpcUaCompat policy, admin-leader, ProbeQuery)
v2-ci / build (push) Failing after 5m5s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-01 14:07:02 -04:00
Joseph Doherty 1d729fb0f8 feat: adopt shared ZB.MOM.WW.Health probes (preserve tiers + OtOpcUaCompat policy) 2026-06-01 13:36:28 -04:00
Joseph Doherty 0b99aceacb build: reference ZB.MOM.WW.Health packages from the Gitea feed 2026-06-01 13:30:13 -04:00
Joseph Doherty d57b42bcd6 chore: gitignore local credentials file and runtime PKI store
v2-ci / build (push) Failing after 45s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
sql_login.txt holds DB creds and the Host pki/ dir is the runtime OPC UA
certificate store (private keys + issued/trusted certs); neither belongs
in source control, and ignoring them prevents an accidental git add .
2026-05-31 10:27:59 -04:00
Joseph Doherty 5e87f7e16f docs(alarms): record 2026-05-31 live re-confirmation of native alarm feed
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Independently re-ran the D.1 alarm-source smoke against the live gateway
(10.100.0.48:5120) to back the native MxAccess alarm-event claim with a
fresh empirical run, not just the original 2026-05-29 capture.
2026-05-31 10:12:47 -04:00
Joseph Doherty 695fa6408b docs(alarms): record native alarms verified working; add D.1 smoke
v2-ci / build (push) Failing after 47s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
The 2026-04-30 alarm plan banners claimed worker-side native alarm
subscription was blocked on a COM-bitness finding. That's stale: the
mxaccessgw .NET client now has true MxAccess alarm-event support, and a
live StreamAlarms check (+ new Skip-gated GatewayGalaxyAlarmFeedLiveTests
through the lmxopcua consumer) confirms native alarms — operator comment,
category, severity, timestamps — flow end-to-end. Reconcile both plan docs
to reality and add docs/plans/alarms-d1-smoke-artifact.md as the D.1
alarm-source deliverable. Historian-write live smoke + full server->A&C
round-trip remain (Windows parity rig only).
2026-05-31 09:59:01 -04:00
543 changed files with 8953 additions and 3093 deletions
+6
View File
@@ -42,3 +42,9 @@ config_cache*.db
# Client CLI/UI runtime scratch (last-connected endpoint cache)
session.dat
# Secrets / local credentials — never commit
sql_login.txt
# OPC UA certificate store (runtime PKI: own/trusted/issued/rejected certs + keys)
src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/
+15 -4
View File
@@ -79,11 +79,11 @@
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="Polly.Core" Version="8.6.6" />
<PackageVersion Include="S7netplus" Version="0.20.0" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageVersion Include="Serilog" Version="4.3.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
@@ -96,7 +96,18 @@
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageVersion Include="xunit.v3" Version="1.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />
</ItemGroup>
</Project>
+21
View File
@@ -1,7 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="local-mxgw" value="./nuget-packages" />
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="local-mxgw">
<package pattern="ZB.MOM.WW.MxGateway.*" />
</packageSource>
<packageSource key="dohertj2-gitea">
<package pattern="ZB.MOM.WW.Health" />
<package pattern="ZB.MOM.WW.Health.*" />
<package pattern="ZB.MOM.WW.Telemetry" />
<package pattern="ZB.MOM.WW.Telemetry.*" />
<package pattern="ZB.MOM.WW.Configuration" />
<package pattern="ZB.MOM.WW.Auth" />
<package pattern="ZB.MOM.WW.Auth.*" />
<package pattern="ZB.MOM.WW.Audit" />
<package pattern="ZB.MOM.WW.Theme" />
</packageSource>
</packageSourceMapping>
</configuration>
+106
View File
@@ -0,0 +1,106 @@
# Alarms D.1 — smoke artifact
> **Status (2026-05-29): alarm-source leg VERIFIED. Historian-write leg still
> pending the Windows sidecar + live AVEVA Historian.**
>
> **Re-confirmed 2026-05-31** against the same gateway (`http://10.100.0.48:5120`):
> the Skip-gated live test passed again, pulling a native `Raise` transition
> (`Galaxy!TestArea.TestMachine_001.TestAlarm001`, raw sev 500 → OPC UA 750/High,
> category `TestArea`, operator comment `Test alarm #1`) through the production
> consumer. Independent re-run, not the original capture.
>
> This is the D.1 deliverable called for by `docs/plans/alarms-worker-wiring-plan.md`
> — captured evidence that a live Galaxy alarm reaches lmxopcua through the native
> gateway path (not the sub-attribute fallback). It supersedes the "A.2 blocked"
> banners in `alarms-over-gateway.md` / `alarms-worker-wiring-plan.md`, which were
> written 2026-04-30 before the gateway's alarm feed was working.
## What was verified
The mxaccessgw gateway **does** serve native MxAccess alarms today, and the lmxopcua
consumer ingests them with full fidelity — **including operator-comment**, the field
the 2026-04-30 plan flagged as "the only v1 regression."
Verified from the macOS dev box against the live gateway at `http://10.100.0.48:5120`
(reachable; `nc -z` succeeds). No acknowledge / no writes were issued — read-only
`StreamAlarms`.
### 1. Gateway boundary — raw `StreamAlarms` (`ZB.MOM.WW.MxGateway.Client`)
A standalone client streamed the active-alarm snapshot: **20 active alarms**, each
carrying native metadata. Sample (one of 20):
```json
{ "alarmFullReference": "Galaxy!TestArea.TestMachine_001.TestAlarm001",
"sourceObjectReference": "TestMachine_001.TestAlarm001",
"alarmTypeName": "DSC", "severity": 500,
"currentState": "ALARM_CONDITION_STATE_ACTIVE", "category": "TestArea",
"lastTransitionTimestamp": "2026-05-24T16:04:10.856Z",
"operatorComment": "Test alarm #1" }
```
Followed by the `SnapshotComplete` marker. `operatorComment`, `category`, `severity`,
`currentState`, and `lastTransitionTimestamp` are all populated.
### 2. lmxopcua consumer — `GatewayGalaxyAlarmFeed` → `GalaxyAlarmTransition`
The Skip-gated live test
`Runtime/GatewayGalaxyAlarmFeedLiveTests.Live_gateway_delivers_native_alarm_transitions_through_the_consumer`
wires the real `MxGatewayClient.StreamAlarmsAsync` into the production consumer seam
and **passes**. Captured output (`D1_SMOKE_OUT`):
```
# consumer transitions observed: 2+
Raise Galaxy!TestArea.TestMachine_001.TestAlarm001 | sev=750(High) raw=500 | cat=TestArea | comment='Test alarm #1' | xitionUtc=2026-05-24T16:04:10.856Z
Raise Galaxy!TestArea.TestMachine_003.TestAlarm001 | sev=750(High) raw=500 | cat=TestArea | comment='Test alarm #1' | xitionUtc=2026-05-07T18:14:00.594Z
```
The consumer preserves `operatorComment` + `category` + transition timestamp and
applies the OPC UA severity-bucket mapping (`MxAccessSeverityMapper`: raw 500 →
OPC UA 750, bucket `High`).
### 3. Full chain to the OPC UA Part 9 surface (code-path verified)
`GalaxyDriver.OnAlarmFeedTransition` maps `GalaxyAlarmTransition`
`AlarmEventArgs`, carrying `OperatorComment`, `OriginalRaiseTimestampUtc`,
`AlarmCategory`, and the severity bucket onto `IAlarmSource.OnAlarmEvent`.
`AlarmEventArgs` already declares those fields — so the **E.7 contract extension is
done**, not pending. The server's Part-9 condition layer consumes `IAlarmSource`
via `AlarmSurfaceInvoker``GenericDriverNodeManager`. Unit coverage:
`GalaxyDriverAlarmSourceTests`, `GatewayGalaxyAlarmFeedTests`.
## How to re-run
```bash
export MXGW_ENDPOINT="http://10.100.0.48:5120"
export GALAXY_MXGW_API_KEY="<dev key from docker-dev/docker-compose.yml>"
export D1_SMOKE_OUT="/tmp/d1-consumer-transitions.txt" # optional capture
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests \
--filter "FullyQualifiedName~GatewayGalaxyAlarmFeedLiveTests"
```
Without the env vars the test `Skip`s, so normal `dotnet test` runs are unaffected.
## Not covered here (still open)
1. **Scripted-alarm historian write-back → AVEVA Historian** (C.1's live leg). The
`SdkAlarmHistorianWriteBackend` (real `HistorianAccess.AddStreamedValue` path) is
implemented and unit-tested, but its `Live_*` write smoke needs the Windows
historian sidecar + a live AVEVA Historian — neither reachable from the macOS dev
box. Capture this leg on the Windows parity rig.
2. **Running-server → OPC UA A&C client round-trip.** This artifact proves the driver
consumer end; it does not exercise a full OtOpcUa server surfacing the condition to
an OPC UA client, because the docker-dev stack stubs the Galaxy driver on Linux
(`DriverInstanceActor.ShouldStub`). Capture on the Windows parity rig (or a Linux
host with `ShouldStub` overridden to point the real driver at the gateway).
## Mechanism — true MxAccess alarm-event support
The gateway delivers these alarms via **true MxAccess alarm-event support** in the
mxaccessgw .NET client — a real alarm-event subscription, **not** the value-driven
sub-attribute fallback. (Confirmed by the gateway maintainer; the client-side stream
check above can only observe the resulting feed, which is why this artifact records the
mechanism here rather than inferring it.) So A.2 is implemented as originally specified:
`MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries genuine native alarm-event metadata, and
the operator-comment / original-raise-time / category fields are first-class — not
reconstructed from attribute reads.
+33 -16
View File
@@ -9,24 +9,41 @@
> the new RPCs; the sub-attribute fallback path keeps Galaxy alarms
> functional today.
>
> ⚠️ **Worker-side native alarm subscription blocked on a dev-rig
> finding (2026-04-30):** the MXAccess COM Toolkit at
> **UPDATE 2026-05-29 — native alarm feed VERIFIED working; the
> 2026-04-30 "blocked" finding below is superseded.** A live
> `StreamAlarms` check against the gateway at `10.100.0.48:5120`
> returned the active-alarm snapshot (20 alarms) with full native
> metadata — `severity`, `category`, `currentState`,
> `lastTransitionTimestamp`, **and `operatorComment`** (the field the
> note below called "the only v1 regression"). The lmxopcua consumer
> (`GatewayGalaxyAlarmFeed` → `GalaxyAlarmTransition` →
> `AlarmEventArgs` → `IAlarmSource`) ingests it with full fidelity and
> the OPC UA severity-bucket mapping applied — proven by the passing
> Skip-gated live test `GatewayGalaxyAlarmFeedLiveTests`. `AlarmEventArgs`
> already carries operator-comment / original-raise-time / category, so
> **E.7 is done too**. See `docs/plans/alarms-d1-smoke-artifact.md` for
> the captured evidence. The gateway delivers this via **true MxAccess
> alarm-event support** in the mxaccessgw .NET client (a real
> alarm-event subscription — **not** the sub-attribute fallback), so A.2
> is implemented as originally specified. Still open: the scripted-alarm
> → AVEVA Historian write-back live smoke (C.1's `Live_*` leg) and a full
> running-server → OPC UA A&C round-trip — both need the Windows parity rig.
>
> ⚠️ **[SUPERSEDED — kept for history] Worker-side native alarm
> subscription blocked on a dev-rig finding (2026-04-30):** the MXAccess
> COM Toolkit at
> `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
> exposes no alarm-event family — only `OnDataChange`,
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange`.
> exposed no alarm-event family — only `OnDataChange`,
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange` — and
> AVEVA's `aaAlarmManagedClient` / `ArchestrAAlarmsAndEvents.SDK`
> assemblies are x64-only and incompatible with the worker's x86
> bitness. **Operator decision needed before
> `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries any events:** either
> accept the value-driven sub-attribute path as the production
> architecture (operator-comment fidelity is the only v1 regression)
> or add an x64 alarm-helper sub-process alongside the worker. See
> `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` in the
> mxaccessgw repo for the architectural notes. Live
> `aahClientManaged` alarm-event write call site
> (`SdkAlarmHistorianWriteBackend` placeholder from PR C.1) and the
> D.1 smoke artifact ship once those decisions resolve. The
> remainder of this document is preserved as the design record.
> assemblies are x64-only vs. the worker's x86 bitness. The operator
> decision (accept the value-driven sub-attribute path, or add an x64
> alarm-helper sub-process) has since been resolved on the gateway side
> — `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` now carries events (verified
> above). The C.1 `SdkAlarmHistorianWriteBackend` is **no longer a
> placeholder** — it writes through the real
> `HistorianAccess.AddStreamedValue` path (only its live-rig write
> smoke remains).
Coordinated epic across two repos:
+27 -10
View File
@@ -1,5 +1,18 @@
# Alarms Worker Wiring Plan
> ✅ **UPDATE 2026-05-29 — the blocker below is RESOLVED on the gateway side; this
> plan is largely complete.** A live `StreamAlarms` check against `10.100.0.48:5120`
> returns the active-alarm snapshot with full native metadata **including
> `operatorComment`**, and the lmxopcua consumer ingests it end-to-end (passing live
> test `GatewayGalaxyAlarmFeedLiveTests`). So **A.2 / A.3 / A.4** are functionally done
> at the gateway boundary (the worker now emits native alarm transitions and the client
> exposes `AcknowledgeAlarm` / `QueryActiveAlarms` RPCs). **C.1** ships real code
> (`SdkAlarmHistorianWriteBackend``HistorianAccess.AddStreamedValue`). **D.1**'s
> alarm-source leg is captured in `docs/plans/alarms-d1-smoke-artifact.md`. Only two
> things remain, both needing the Windows parity rig: C.1's live historian-write smoke
> and a full running-server → OPC UA A&C round-trip. The per-item detail below is kept
> as the historical record of the original blocked state.
>
> **Context**: The alarms-over-gateway epic shipped 19 PRs across the
> `lmxopcua` and `mxaccessgw` repos (merged 2026-04-30). Contracts are live;
> the sub-attribute fallback path keeps Galaxy alarms functional today. Four
@@ -16,7 +29,7 @@
---
## Dev-rig finding that blocks everything (2026-04-30)
## Dev-rig finding that blocks everything (2026-04-30) — [SUPERSEDED 2026-05-29]
During PR A.2 work the following was discovered on the dev box:
@@ -318,16 +331,20 @@ fallback as production).
## Summary of blocks
| Item | Blocked by | Estimated effort once unblocked |
|------|-----------|--------------------------------|
| A.2 | Architectural decision (x64 alarm-helper vs. sub-attribute fallback as production) | 23 days implementation; 1 day tests |
| A.3 | A.2 delivering WorkerEvent bodies | 12 days |
| A.4 | A.2 (active-alarm query needs AlarmClient session) | 1 day |
| C.1 | aahClientManaged SDK access (available on dev box); NOT blocked by A.2 | 12 days |
| D.1 | A.2 + A.3 + C.1 all passing on parity rig | 0.5 day (smoke + artifact capture) |
> **Resolved as of 2026-05-29** — see the update banner at the top and
> `docs/plans/alarms-d1-smoke-artifact.md`. Original status table kept for history.
C.1 can proceed in parallel with A.2 / A.3 since the sidecar's `aahClientManaged`
is x64 and does not share the worker bitness constraint.
| Item | Status (2026-05-29) | Original block |
|------|--------------------|----------------|
| A.2 | ✅ **True MxAccess alarm-event support** in the gateway client (real alarm-event subscription, not the sub-attribute fallback); verified via live `StreamAlarms` with operator-comment fidelity | Architectural decision (x64 alarm-helper vs. sub-attribute fallback) |
| A.3 | ✅ Dispatch + `AcknowledgeAlarm` RPC present on the client surface | A.2 delivering WorkerEvent bodies |
| A.4 | ✅ `QueryActiveAlarms` RPC present on the client surface | A.2 (active-alarm query needs AlarmClient session) |
| C.1 | ✅ Code shipped (`AddStreamedValue` path); ⏳ live historian-write smoke needs the Windows rig | aahClientManaged SDK access |
| D.1 | ◑ Alarm-source leg captured (`alarms-d1-smoke-artifact.md`); ⏳ historian-write leg + full server→A&C round-trip need the Windows rig | A.2 + A.3 + C.1 all passing on parity rig |
The gateway delivers operator-comment fidelity through **true MxAccess alarm-event
support** in the mxaccessgw .NET client — a real alarm-event subscription, not the
value-driven sub-attribute path. The sub-attribute fallback is now legacy.
---
+1 -1
View File
@@ -65,7 +65,7 @@ Running record of v2 dev services on the Windows dev VM. Updated on every instal
|---------|---------------------|---------|-----------|------------------------|---------------|--------|
| **Central config DB** | Docker container `otopcua-mssql` on the Linux Docker host (image `mcr.microsoft.com/mssql/server:2022-latest`) | 16.0.4250.1 (RTM-CU24-GDR, KB5083252) | `10.100.0.35:14330``1433` (container) — port 14330 retained from the previous local-container setup so connection-string ports don't churn | User `sa` / Password `OtOpcUaDev_2026!` | Docker named volume `otopcua-mssql-data` on the Docker host | ✅ Running on Docker host (`/opt/otopcua-mssql/`) since 2026-04-28; carries `project=lmxopcua` label |
| Dev Galaxy (AVEVA System Platform) | Local install on this dev box — full ArchestrA + Historian + OI-Server stack | v1 baseline | Local COM via MXAccess (`C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`); Historian via `aaH*` services; SuiteLink via `slssvc` | Windows Auth | Galaxy repository DB `ZB` on local SQL Server (separate instance from `otopcua-mssql` — legacy v1 Galaxy DB, not related to v2 config DB) | ✅ **Fully available — Phase 2 lift unblocked.** 27 ArchestrA / AVEVA / Wonderware services running incl. `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`, `ArchestrADataStore`, `AsbServiceManager`, `AutoBuild_Service`; full Historian set (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `aahSupervisor`, `InSQLStorage`, `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`, `InSQLManualStorage`, `InSQLSystemDriver`, `HistorianSearch-x64`); `slssvc` (Wonderware SuiteLink); `OI-Gateway` install present at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` (decision #142 AppServer-via-OI-Gateway smoke test now also unblocked) |
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v2.4.0 | `localhost:3893` (LDAP) / `3894` (LDAPS, disabled) | Direct-bind `cn={user},dc=lmxopcua,dc=local` per `auth.md`; users `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack`/`admin`/`serviceaccount` (passwords in `glauth.cfg` as SHA-256) | `C:\publish\glauth\` | ✅ Running (NSSM service `GLAuth`). Phase 1 Admin uses GroupToRole map `ReadOnly→ConfigViewer`, `WriteOperate→ConfigEditor`, `AlarmAck→FleetAdmin`. v2-rebrand to `dc=otopcua,dc=local` is a future cosmetic change |
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v2.4.0 | `localhost:3893` (LDAP) / `3894` (LDAPS, disabled) | Direct-bind `cn={user},dc=zb,dc=local` per `auth.md`; users `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack`/`admin`/`serviceaccount` (passwords in `glauth.cfg` as SHA-256) | `C:\publish\glauth\` | ✅ Running (NSSM service `GLAuth`). Phase 1 Admin uses GroupToRole map `ReadOnly→ConfigViewer`, `WriteOperate→ConfigEditor`, `AlarmAck→FleetAdmin`. Dev base DN unified to `dc=zb,dc=local` (Task 1.6) |
| OPC Foundation reference server | Not yet built | — | `10.100.0.35:62541` (target) | `user1` / `password1` (reference-server defaults) | — | Pending (needed for Phase 5 OPC UA Client driver testing) |
| FOCAS TCP stub | Not yet built | — | `10.100.0.35:8193` (target) | n/a | — | Pending (built in Phase 5; runs on Docker host) |
| Modbus simulator (`otopcua-pymodbus:3.13.0`) | Docker compose at `/opt/otopcua-modbus/` on Docker host | pinned 3.13.0 | `10.100.0.35:5020` | n/a | n/a | Stack staged; bring up with `lmxopcua-fix up modbus <profile>` from this VM |
+2 -2
View File
@@ -104,8 +104,8 @@ Anonymous OPC UA sessions are denied writes against `Operate`-classified tags by
"Enabled": true,
"Server": "localhost",
"Port": 3893,
"SearchBase": "dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"SearchBase": "dc=zb,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
"ServiceAccountPassword": "serviceaccount123",
"GroupToRole": {
"ReadOnly": "ReadOnly",
@@ -67,11 +67,13 @@ public abstract class CommandBase : ICommand
/// Executes the command-specific workflow against the configured OPC UA endpoint.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <returns>A value task that represents the asynchronous command execution.</returns>
public abstract ValueTask ExecuteAsync(IConsole console);
/// <summary>
/// Creates a <see cref="ConnectionSettings" /> from the common command options.
/// </summary>
/// <returns>A <see cref="ConnectionSettings"/> populated from the current command option values.</returns>
protected ConnectionSettings CreateConnectionSettings()
{
var securityMode = SecurityModeMapper.FromString(Security);
@@ -97,6 +99,7 @@ public abstract class CommandBase : ICommand
/// and returns both the service and the connection info.
/// </summary>
/// <param name="ct">The cancellation token that aborts connection setup for the command.</param>
/// <returns>A tuple of the connected <see cref="IOpcUaClientService"/> and the resulting <see cref="ConnectionInfo"/>.</returns>
protected async Task<(IOpcUaClientService Service, ConnectionInfo Info)> CreateServiceAndConnectAsync(
CancellationToken ct)
{
@@ -12,9 +12,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
{
private static readonly ILogger Logger = Log.ForContext<DefaultApplicationConfigurationFactory>();
/// <summary>Creates an OPC UA application configuration from the provided connection settings.</summary>
/// <param name="settings">The connection settings to use.</param>
/// <param name="ct">Token to cancel the operation.</param>
/// <inheritdoc />
public async Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct)
{
// Resolve the canonical PKI path lazily on first use so constructing a
@@ -11,10 +11,7 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
{
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
/// <summary>Selects an OPC UA endpoint matching the requested security mode.</summary>
/// <param name="config">The application configuration.</param>
/// <param name="endpointUrl">The endpoint URL to query.</param>
/// <param name="requestedMode">The requested message security mode.</param>
/// <inheritdoc />
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode)
{
@@ -53,6 +50,7 @@ internal static class EndpointSelector
/// Thrown when no endpoint matches <paramref name="requestedMode"/>; the message lists the
/// security mode + policy combinations the server returned so operators can diagnose mismatches.
/// </exception>
/// <returns>The best matching <see cref="EndpointDescription"/> with its URL rewritten to the requested host.</returns>
public static EndpointDescription SelectBest(
IEnumerable<EndpointDescription> allEndpoints,
string endpointUrl,
@@ -13,5 +13,6 @@ internal interface IApplicationConfigurationFactory
/// </summary>
/// <param name="settings">The connection settings to configure.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the validated <see cref="ApplicationConfiguration"/>.</returns>
Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct = default);
}
@@ -14,6 +14,7 @@ internal interface IEndpointDiscovery
/// <param name="config">The OPC UA application configuration.</param>
/// <param name="endpointUrl">The endpoint URL to discover.</param>
/// <param name="requestedMode">The requested message security mode.</param>
/// <returns>The best matching endpoint description for the requested security mode.</returns>
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode);
}
@@ -58,6 +58,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary>
/// <param name="nodeId">The node whose current runtime value should be read.</param>
/// <param name="ct">The cancellation token that aborts the server read if the client cancels the request.</param>
/// <returns>A task that resolves to the current <see cref="DataValue"/> for the node.</returns>
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
@@ -66,6 +67,7 @@ internal interface ISessionAdapter : IDisposable
/// <param name="nodeId">The node whose value should be updated.</param>
/// <param name="value">The typed OPC UA data value to write to the server.</param>
/// <param name="ct">The cancellation token that aborts the write if the client cancels the request.</param>
/// <returns>A task that resolves to the OPC UA <see cref="StatusCode"/> for the write operation.</returns>
Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct = default);
/// <summary>
@@ -75,6 +77,7 @@ internal interface ISessionAdapter : IDisposable
/// <param name="nodeId">The starting node for the hierarchical browse.</param>
/// <param name="nodeClassMask">The node classes that should be returned to the caller.</param>
/// <param name="ct">The cancellation token that aborts the browse request.</param>
/// <returns>A task that resolves to a tuple of an optional continuation point and the returned references.</returns>
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
@@ -83,6 +86,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary>
/// <param name="continuationPoint">The continuation token returned by a prior browse result page.</param>
/// <param name="ct">The cancellation token that aborts the browse-next request.</param>
/// <returns>A task that resolves to a tuple of an optional next continuation point and the returned references.</returns>
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct = default);
@@ -91,6 +95,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary>
/// <param name="nodeId">The node to inspect for child objects or variables.</param>
/// <param name="ct">The cancellation token that aborts the child lookup.</param>
/// <returns>A task that resolves to <see langword="true"/> if the node has at least one child; otherwise <see langword="false"/>.</returns>
Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
@@ -101,6 +106,7 @@ internal interface ISessionAdapter : IDisposable
/// <param name="endTime">The inclusive end of the requested history window.</param>
/// <param name="maxValues">The maximum number of raw samples to return to the client.</param>
/// <param name="ct">The cancellation token that aborts the history read.</param>
/// <returns>A task that resolves to the ordered list of raw historical data values.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues, CancellationToken ct = default);
@@ -113,6 +119,7 @@ internal interface ISessionAdapter : IDisposable
/// <param name="aggregateId">The OPC UA aggregate function to evaluate over the history window.</param>
/// <param name="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
/// <param name="ct">The cancellation token that aborts the aggregate history read.</param>
/// <returns>A task that resolves to the ordered list of processed aggregate data values.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
NodeId aggregateId, double intervalMs, CancellationToken ct = default);
@@ -121,6 +128,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary>
/// <param name="publishingIntervalMs">The requested publishing interval for monitored items on the new subscription.</param>
/// <param name="ct">The cancellation token that aborts subscription creation.</param>
/// <returns>A task that resolves to the newly created <see cref="ISubscriptionAdapter"/>.</returns>
Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default);
/// <summary>
@@ -130,11 +138,13 @@ internal interface ISessionAdapter : IDisposable
/// <param name="methodId">The method node to invoke.</param>
/// <param name="inputArguments">The ordered input arguments supplied to the server method call.</param>
/// <param name="ct">The cancellation token that aborts the method invocation.</param>
/// <returns>A task that resolves to the list of output arguments returned by the method, or <see langword="null"/> if none.</returns>
Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default);
/// <summary>
/// Closes the underlying session gracefully before the adapter is disposed or replaced during failover.
/// </summary>
/// <param name="ct">The cancellation token that aborts the close request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task CloseAsync(CancellationToken ct = default);
}
@@ -28,6 +28,7 @@ internal interface ISubscriptionAdapter : IDisposable
/// </summary>
/// <param name="clientHandle">The client handle returned when the monitored item was created.</param>
/// <param name="ct">The cancellation token that aborts the monitored-item removal.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct = default);
/// <summary>
@@ -46,11 +47,13 @@ internal interface ISubscriptionAdapter : IDisposable
/// Requests a condition refresh for this subscription.
/// </summary>
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ConditionRefreshAsync(CancellationToken ct = default);
/// <summary>
/// Removes all monitored items and deletes the subscription.
/// </summary>
/// <param name="ct">The cancellation token that aborts subscription deletion.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task DeleteAsync(CancellationToken ct = default);
}
@@ -28,6 +28,7 @@ public static class ClientStoragePaths
/// one-shot legacy-folder migration before returning so callers that depend on this
/// path (PKI store, settings file) find their existing state at the canonical name.
/// </summary>
/// <returns>The absolute path to the client's top-level folder under LocalApplicationData.</returns>
public static string GetRoot()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
@@ -37,6 +38,7 @@ public static class ClientStoragePaths
}
/// <summary>Subfolder for the application's PKI store — used by both CLI + UI.</summary>
/// <returns>The absolute path to the PKI store subfolder.</returns>
public static string GetPkiPath() => Path.Combine(GetRoot(), "pki");
/// <summary>
@@ -45,6 +47,7 @@ public static class ClientStoragePaths
/// folder existed + was moved to canonical, false when no migration was needed or
/// canonical was already present.
/// </summary>
/// <returns><see langword="true"/> when the legacy folder was found and moved; <see langword="false"/> when no migration was needed.</returns>
public static bool TryRunLegacyMigration()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
@@ -24,12 +24,14 @@ public interface IOpcUaClientService : IDisposable
/// </summary>
/// <param name="settings">The endpoint, security, and authentication settings used to establish the session.</param>
/// <param name="ct">The cancellation token that aborts the connect workflow.</param>
/// <returns>A <see cref="ConnectionInfo"/> describing the active session after a successful connect.</returns>
Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default);
/// <summary>
/// Disconnects from the active OPC UA endpoint and tears down subscriptions owned by the client.
/// </summary>
/// <param name="ct">The cancellation token that aborts disconnect cleanup.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task DisconnectAsync(CancellationToken ct = default);
/// <summary>
@@ -37,6 +39,7 @@ public interface IOpcUaClientService : IDisposable
/// </summary>
/// <param name="nodeId">The node whose value should be retrieved.</param>
/// <param name="ct">The cancellation token that aborts the read request.</param>
/// <returns>The current <see cref="DataValue"/> including value, status code, and timestamps.</returns>
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
@@ -45,6 +48,7 @@ public interface IOpcUaClientService : IDisposable
/// <param name="nodeId">The node whose value should be updated.</param>
/// <param name="value">The raw value supplied by the CLI or UI workflow.</param>
/// <param name="ct">The cancellation token that aborts the write request.</param>
/// <returns>The OPC UA <see cref="StatusCode"/> returned by the server for the write operation.</returns>
Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default);
/// <summary>
@@ -52,6 +56,7 @@ public interface IOpcUaClientService : IDisposable
/// </summary>
/// <param name="parentNodeId">The node to browse, or <see cref="ObjectIds.ObjectsFolder"/> when omitted.</param>
/// <param name="ct">The cancellation token that aborts the browse request.</param>
/// <returns>The list of child nodes discovered under the specified parent.</returns>
Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default);
/// <summary>
@@ -60,6 +65,7 @@ public interface IOpcUaClientService : IDisposable
/// <param name="nodeId">The node whose value changes should be monitored.</param>
/// <param name="intervalMs">The monitored-item sampling and publishing interval in milliseconds.</param>
/// <param name="ct">The cancellation token that aborts subscription creation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default);
/// <summary>
@@ -67,6 +73,7 @@ public interface IOpcUaClientService : IDisposable
/// </summary>
/// <param name="nodeId">The node whose live-data subscription should be removed.</param>
/// <param name="ct">The cancellation token that aborts the unsubscribe request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
@@ -75,18 +82,21 @@ public interface IOpcUaClientService : IDisposable
/// <param name="sourceNodeId">The event source to monitor, or the server object when omitted.</param>
/// <param name="intervalMs">The publishing interval in milliseconds for the alarm subscription.</param>
/// <param name="ct">The cancellation token that aborts alarm subscription creation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default);
/// <summary>
/// Removes the active alarm subscription.
/// </summary>
/// <param name="ct">The cancellation token that aborts alarm subscription cleanup.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
/// <summary>
/// Requests retained alarm conditions again so a client can repopulate its alarm list after reconnecting.
/// </summary>
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RequestConditionRefreshAsync(CancellationToken ct = default);
/// <summary>
@@ -111,6 +121,7 @@ public interface IOpcUaClientService : IDisposable
/// <param name="endTime">The inclusive end of the requested history range.</param>
/// <param name="maxValues">The maximum number of raw values to return.</param>
/// <param name="ct">The cancellation token that aborts the history read.</param>
/// <returns>The raw historical <see cref="DataValue"/> samples in the requested range.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default);
@@ -123,6 +134,7 @@ public interface IOpcUaClientService : IDisposable
/// <param name="aggregate">The aggregate function the operator selected for processed history.</param>
/// <param name="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
/// <param name="ct">The cancellation token that aborts the processed history request.</param>
/// <returns>The processed historical <see cref="DataValue"/> samples computed by the requested aggregate.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
@@ -130,6 +142,7 @@ public interface IOpcUaClientService : IDisposable
/// Reads redundancy status data such as redundancy mode, service level, and partner endpoint URIs.
/// </summary>
/// <param name="ct">The cancellation token that aborts redundancy inspection.</param>
/// <returns>A <see cref="RedundancyInfo"/> snapshot containing redundancy mode, service level, and partner endpoint URIs.</returns>
Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default);
/// <summary>
@@ -73,13 +73,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
{
}
/// <inheritdoc />
/// <summary>Raised when subscribed node values change.</summary>
public event EventHandler<DataChangedEventArgs>? DataChanged;
/// <inheritdoc />
/// <summary>Raised when an alarm event is received from the server.</summary>
public event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <inheritdoc />
/// <summary>Raised when the connection state changes.</summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
@@ -7,8 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary>
public sealed class AvaloniaUiDispatcher : IUiDispatcher
{
/// <summary>Posts an action to the Avalonia UI thread for execution.</summary>
/// <param name="action">The action to execute on the UI thread.</param>
/// <inheritdoc />
public void Post(Action action)
{
Dispatcher.UIThread.Post(action);
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
public interface ISettingsService
{
/// <summary>Loads user settings from persistent storage.</summary>
/// <returns>The persisted <see cref="UserSettings"/>, or a default instance if none are saved.</returns>
UserSettings Load();
/// <summary>Saves user settings to persistent storage.</summary>
/// <param name="settings">The settings to save.</param>
@@ -19,8 +19,7 @@ public sealed class JsonSettingsService : ISettingsService
WriteIndented = true
};
/// <summary>Loads user settings from the settings file.</summary>
/// <returns>The loaded user settings, or a new default instance if load fails.</returns>
/// <inheritdoc />
public UserSettings Load()
{
try
@@ -37,8 +36,7 @@ public sealed class JsonSettingsService : ISettingsService
}
}
/// <summary>Saves user settings to the settings file.</summary>
/// <param name="settings">The user settings to save.</param>
/// <inheritdoc />
public void Save(UserSettings settings)
{
try
@@ -6,8 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary>
public sealed class SynchronousUiDispatcher : IUiDispatcher
{
/// <summary>Executes the action synchronously on the calling thread.</summary>
/// <param name="action">The action to execute.</param>
/// <inheritdoc />
public void Post(Action action)
{
action();
@@ -195,6 +195,7 @@ public partial class AlarmsViewModel : ObservableObject
/// <summary>
/// Returns the monitored node ID for persistence, or null if not subscribed.
/// </summary>
/// <returns>The monitored node ID string, or null if not currently subscribed.</returns>
public string? GetAlarmSourceNodeId()
{
return IsSubscribed ? MonitoredNodeIdText : null;
@@ -30,6 +30,7 @@ public class BrowseTreeViewModel : ObservableObject
/// <summary>
/// Loads root nodes by browsing with a null parent.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task LoadRootsAsync()
{
var results = await _service.BrowseAsync();
@@ -143,6 +143,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// </summary>
/// <param name="nodeIdStr">The node ID to subscribe to from the browse tree or persisted settings.</param>
/// <param name="intervalMs">The monitored-item interval, in milliseconds, for the subscription.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AddSubscriptionForNodeAsync(string nodeIdStr, int intervalMs = 1000)
{
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
@@ -176,6 +177,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// <param name="nodeIdStr">The root node whose variables should be subscribed recursively.</param>
/// <param name="nodeClass">The node class of the starting node so variables can be subscribed immediately.</param>
/// <param name="intervalMs">The monitored-item interval, in milliseconds, used for created subscriptions.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task AddSubscriptionRecursiveAsync(string nodeIdStr, string nodeClass, int intervalMs = 1000)
{
return AddSubscriptionRecursiveAsync(nodeIdStr, nodeClass, intervalMs, maxDepth: 10, currentDepth: 0);
@@ -211,6 +213,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// <summary>
/// Returns the node IDs of all active subscriptions for persistence.
/// </summary>
/// <returns>The list of node ID strings for all currently active subscriptions.</returns>
public List<string> GetSubscribedNodeIds()
{
return ActiveSubscriptions.Select(s => s.NodeId).ToList();
@@ -220,6 +223,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// Restores subscriptions from a saved list of node IDs.
/// </summary>
/// <param name="nodeIds">The node IDs persisted from a prior UI session.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RestoreSubscriptionsAsync(IEnumerable<string> nodeIds)
{
foreach (var nodeId in nodeIds)
@@ -232,6 +236,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// </summary>
/// <param name="nodeIdStr">The node ID the operator wants to write.</param>
/// <param name="rawValue">The raw text value entered by the operator.</param>
/// <returns>A tuple of (success flag, operator-readable message) describing the outcome of the write.</returns>
public async Task<(bool Success, string Message)> ValidateAndWriteAsync(string nodeIdStr, string rawValue)
{
try
@@ -43,20 +43,16 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
}
/// <summary>Gets the local cluster node identifier.</summary>
/// <inheritdoc />
public CommonsNodeId LocalNode => _localNode;
/// <summary>Gets the set of roles assigned to the local node.</summary>
/// <inheritdoc />
public IReadOnlySet<string> LocalRoles => _localRoles;
/// <summary>Checks if the local node has a specific role.</summary>
/// <param name="role">The role name to check.</param>
/// <returns>True if the local node has the specified role; otherwise false.</returns>
/// <inheritdoc />
public bool HasRole(string role) => _localRoles.Contains(role);
/// <summary>Gets all cluster members that have a specific role.</summary>
/// <param name="role">The role name.</param>
/// <returns>A read-only list of node IDs with the specified role.</returns>
/// <inheritdoc />
public IReadOnlyList<CommonsNodeId> MembersWithRole(string role)
{
lock (_lock)
@@ -68,9 +64,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
}
}
/// <summary>Gets the current leader node for a specific role.</summary>
/// <param name="role">The role name.</param>
/// <returns>The node ID of the current role leader, or null if no leader is elected.</returns>
/// <inheritdoc />
public CommonsNodeId? RoleLeader(string role)
{
lock (_lock)
@@ -9,6 +9,7 @@ public static class RoleParser
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
/// <param name="raw">The raw role string to parse.</param>
/// <returns>An array of validated, distinct, lower-cased role names; empty array when the input is null or whitespace.</returns>
public static string[] Parse(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
@@ -18,6 +18,7 @@ public static class ServiceCollectionExtensions
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="configuration">The application configuration containing cluster options.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<AkkaClusterOptions>()
@@ -45,6 +46,7 @@ public static class ServiceCollectionExtensions
/// </summary>
/// <param name="builder">The Akka configuration builder to configure.</param>
/// <param name="serviceProvider">The service provider for resolving cluster options.</param>
/// <returns>The same <see cref="AkkaConfigurationBuilder"/> for chaining.</returns>
public static AkkaConfigurationBuilder WithOtOpcUaClusterBootstrap(
this AkkaConfigurationBuilder builder,
IServiceProvider serviceProvider)
@@ -16,14 +16,22 @@ public interface IBrowseSession : IAsyncDisposable
DateTime LastUsedUtc { get; }
/// <summary>Returns the top-level browse nodes.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the list of top-level browse nodes.</returns>
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);
/// <summary>Returns the direct children of the node identified by
/// <paramref name="nodeId"/>.</summary>
/// <param name="nodeId">The identifier of the node whose children to expand.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the list of direct child nodes.</returns>
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken);
/// <summary>Returns the attributes of the node identified by <paramref name="nodeId"/>.
/// Empty for drivers whose tree is uniform (OPC UA Client). Galaxy uses this to populate
/// the attribute side-panel after the user selects an object.</summary>
/// <param name="nodeId">The identifier of the node whose attributes to retrieve.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the list of attribute descriptors for the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
@@ -15,5 +15,6 @@ public interface IDriverBrowser
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
/// driver would consume.</param>
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
/// <returns>A task containing the opened browse session.</returns>
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken);
}
@@ -18,6 +18,7 @@ public interface IAlarmActorStateStore
/// <summary>Saves the alarm actor state snapshot.</summary>
/// <param name="snapshot">The state snapshot to persist.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
}
@@ -41,14 +42,10 @@ public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
{
public static readonly NullAlarmActorStateStore Instance = new();
private NullAlarmActorStateStore() { }
/// <summary>Always returns null, indicating no persisted state.</summary>
/// <param name="alarmId">The alarm identifier (unused).</param>
/// <param name="ct">Cancellation token (unused).</param>
/// <inheritdoc />
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
Task.FromResult<AlarmActorStateSnapshot?>(null);
/// <summary>Completes immediately without persisting anything.</summary>
/// <param name="snapshot">The state snapshot (ignored).</param>
/// <param name="ct">Cancellation token (unused).</param>
/// <inheritdoc />
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
Task.CompletedTask;
}
@@ -43,11 +43,7 @@ public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
{
public static readonly NullVirtualTagEvaluator Instance = new();
private NullVirtualTagEvaluator() { }
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> for every evaluation.</summary>
/// <param name="virtualTagId">The virtual tag identifier (ignored).</param>
/// <param name="expression">The expression string (ignored).</param>
/// <param name="dependencies">The variable dependencies (ignored).</param>
/// <returns>Always returns <see cref="VirtualTagEvalResult.NoChange"/>.</returns>
/// <inheritdoc />
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> VirtualTagEvalResult.NoChange;
}
@@ -23,5 +23,6 @@ public interface IAdminOperationsClient
/// <typeparam name="T">Expected reply type.</typeparam>
/// <param name="message">The message to send.</param>
/// <param name="ct">Cancellation token (caller-controlled timeout).</param>
/// <returns>A task that resolves to the reply of type <typeparamref name="T"/>.</returns>
Task<T> AskAsync<T>(object message, CancellationToken ct);
}
@@ -11,5 +11,6 @@ public interface IFleetDiagnosticsClient
/// <summary>Gets diagnostics for the specified node.</summary>
/// <param name="nodeId">The node ID to retrieve diagnostics for.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that resolves to the diagnostics snapshot for the specified node.</returns>
Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct);
}
@@ -1,17 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit;
/// <summary>
/// Cluster-broadcast audit event consumed by the <c>AuditWriterActor</c> singleton, which
/// batches and idempotently inserts into <c>ConfigAuditLog</c>.
/// </summary>
public sealed record AuditEvent(
Guid EventId,
string Category,
string Action,
string Actor,
DateTime OccurredAtUtc,
string? DetailsJson,
NodeId SourceNode,
CorrelationId CorrelationId);
@@ -69,6 +69,7 @@ public static class OtOpcUaTelemetry
/// null when no listener is attached so the call site stays cheap on undecorated builds.
/// </summary>
/// <param name="deploymentId">The deployment identifier to tag the span with.</param>
/// <returns>The started <see cref="Activity"/>, or null when no listener is attached.</returns>
public static Activity? StartDeployApplySpan(string deploymentId)
{
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
@@ -77,6 +78,7 @@ public static class OtOpcUaTelemetry
}
/// <summary>Span wrapping a full OPC UA address-space rebuild (Phase7 plan → apply).</summary>
/// <returns>The started <see cref="Activity"/>, or null when no listener is attached.</returns>
public static Activity? StartAddressSpaceRebuildSpan()
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
}
@@ -22,37 +22,22 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
public void SetSink(IOpcUaAddressSpaceSink? sink) =>
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
/// <summary>Writes a value to the OPC UA address space through the inner sink.</summary>
/// <param name="nodeId">The node ID of the variable.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
/// <summary>Writes an alarm state through the inner sink.</summary>
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
/// <summary>Ensures a folder exists in the address space through the inner sink.</summary>
/// <param name="folderNodeId">The node ID of the folder.</param>
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the folder.</param>
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
/// <summary>Ensures a variable exists in the address space through the inner sink.</summary>
/// <param name="variableNodeId">The node ID of the variable.</param>
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA data type of the variable.</param>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
/// <summary>Rebuilds the address space through the inner sink.</summary>
/// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
}
@@ -16,7 +16,6 @@ public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
public void SetInner(IServiceLevelPublisher? inner) =>
_inner = inner ?? NullServiceLevelPublisher.Instance;
/// <summary>Publishes a service level value to the inner publisher.</summary>
/// <param name="serviceLevel">The service level to publish.</param>
/// <inheritdoc />
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
}
@@ -3,15 +3,18 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
public readonly record struct CorrelationId(Guid Value)
{
/// <summary>Creates a new CorrelationId with a randomly generated GUID.</summary>
/// <returns>A new <see cref="CorrelationId"/> backed by a random GUID.</returns>
public static CorrelationId NewId() => new(Guid.NewGuid());
/// <inheritdoc />
public override string ToString() => Value.ToString("N");
/// <summary>Parses a lowercase hex string without hyphens into a CorrelationId.</summary>
/// <param name="s">The string to parse.</param>
/// <returns>A <see cref="CorrelationId"/> parsed from the supplied string.</returns>
public static CorrelationId Parse(string s) => new(Guid.ParseExact(s, "N"));
/// <summary>Attempts to parse a lowercase hex string without hyphens into a CorrelationId.</summary>
/// <param name="s">The string to parse, or null.</param>
/// <param name="id">The resulting CorrelationId if parsing succeeds.</param>
/// <returns><see langword="true"/> if parsing succeeded; otherwise <see langword="false"/>.</returns>
public static bool TryParse(string? s, out CorrelationId id)
{
if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; }
@@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="Akka"/>
<PackageReference Include="ZB.MOM.WW.Audit"/>
</ItemGroup>
</Project>
@@ -41,4 +41,10 @@ public sealed class ConfigAuditLog
/// <summary>Correlation ID from <c>AuditEvent.CorrelationId</c> so an audit row joins to its
/// originating request/workflow. Nullable for the same backfill reason as <see cref="EventId"/>.</summary>
public Guid? CorrelationId { get; set; }
/// <summary>Normalized outcome from <c>AuditEvent.Outcome</c> (the canonical
/// <c>ZB.MOM.WW.Audit.AuditOutcome</c>: <c>Success</c> | <c>Failure</c> | <c>Denied</c>),
/// stored as its enum member name. Nullable so pre-Outcome rows backfill cleanly and the
/// bespoke stored-procedure audit path (which does not derive an outcome) writes NULL.</summary>
public string? Outcome { get; set; }
}
@@ -7,20 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <see cref="Entities.NodeAcl"/> joined against LDAP group memberships directly.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
/// table would collapse the distinction + let a user inherit tag permissions via their
/// admin-role claim path.
/// <para>
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
/// table would collapse the distinction + let a user inherit tag permissions via their
/// admin-role claim path.
/// </para>
/// <para>
/// Task 1.7 standardized the member names on the canonical control-plane role vocabulary
/// (<c>ZB.MOM.WW.Auth</c> <c>CanonicalRole</c>): <c>ConfigViewer → Viewer</c>,
/// <c>ConfigEditor → Designer</c>, <c>FleetAdmin → Administrator</c>. The appsettings-only
/// <c>DriverOperator</c> string role likewise became <c>Operator</c>. These members persist
/// as their string names (EF <c>HasConversion&lt;string&gt;</c>); the rename is paired with
/// a data migration (<c>CanonicalizeAdminRoles</c>) that rewrites existing rows. This is a
/// rename, not a permission change — enforcement semantics are preserved.
/// </para>
/// </remarks>
public enum AdminRole
{
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history.</summary>
ConfigViewer,
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history. (Canonical: Viewer; was ConfigViewer.)</summary>
Viewer,
/// <summary>Can author drafts + submit for publish.</summary>
ConfigEditor,
/// <summary>Can author drafts + submit for publish. (Canonical: Designer; was ConfigEditor.)</summary>
Designer,
/// <summary>Full Admin UI privileges including publish + fleet-admin actions.</summary>
FleetAdmin,
/// <summary>Full Admin UI privileges including publish + fleet-admin actions. (Canonical: Administrator; was FleetAdmin.)</summary>
Administrator,
}
@@ -21,10 +21,12 @@ public interface ILocalConfigCache
/// <summary>Stores a generation snapshot in the local cache.</summary>
/// <param name="snapshot">The generation snapshot to store.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
/// <summary>Removes old generations, keeping only the most recent N.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <param name="keepLatest">The number of latest generations to keep.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
}
@@ -45,9 +45,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
}
}
/// <summary>Gets the most recent snapshot for the specified cluster.</summary>
/// <param name="clusterId">The cluster ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <inheritdoc />
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
@@ -58,9 +56,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
return Task.FromResult<GenerationSnapshot?>(snapshot);
}
/// <summary>Stores a snapshot in the cache.</summary>
/// <param name="snapshot">The snapshot to store.</param>
/// <param name="ct">Cancellation token.</param>
/// <inheritdoc />
public async Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
@@ -89,10 +85,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
}
}
/// <summary>Removes old generation snapshots, keeping only the latest ones.</summary>
/// <param name="clusterId">The cluster ID.</param>
/// <param name="keepLatest">Number of latest generations to keep.</param>
/// <param name="ct">Cancellation token.</param>
/// <inheritdoc />
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Task 1.7 — canonicalizes the control-plane admin role VALUES persisted in the
/// <c>LdapGroupRoleMapping.Role</c> column. The column stores the <c>AdminRole</c> enum
/// member name as a string (EF <c>HasConversion&lt;string&gt;</c>, <c>nvarchar(32)</c>);
/// renaming the enum members (<c>ConfigViewer → Viewer</c>, <c>ConfigEditor → Designer</c>,
/// <c>FleetAdmin → Administrator</c>) therefore requires rewriting existing rows so the C#
/// enum and the stored strings stay in sync.
/// </summary>
/// <remarks>
/// This is a pure DATA migration: the schema (column type, length, indexes) is unchanged,
/// so the model snapshot is byte-identical to the prior migration. The new canonical strings
/// ("Viewer" = 6, "Designer" = 8, "Administrator" = 13 chars) all fit the existing
/// <c>nvarchar(32)</c> column. Enforcement semantics are preserved — it is a rename only.
/// </remarks>
public partial class CanonicalizeAdminRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Viewer' WHERE [Role] = N'ConfigViewer';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Designer' WHERE [Role] = N'ConfigEditor';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Administrator' WHERE [Role] = N'FleetAdmin';");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'FleetAdmin' WHERE [Role] = N'Administrator';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigEditor' WHERE [Role] = N'Designer';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigViewer' WHERE [Role] = N'Viewer';");
}
}
}
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Task 2.2 — adds the nullable <c>Outcome</c> column to <c>ConfigAuditLog</c> for the
/// canonical <c>ZB.MOM.WW.Audit.AuditOutcome</c> (stored as its enum member name,
/// <c>nvarchar(16)</c>, mirroring how <c>AdminRole</c> is persisted). Purely additive:
/// nullable with no backfill, so existing rows and the bespoke stored-procedure audit
/// path (which does not derive an outcome) keep writing NULL.
/// </summary>
public partial class AddConfigAuditLogOutcome : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Outcome",
table: "ConfigAuditLog",
type: "nvarchar(16)",
maxLength: 16,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Outcome",
table: "ConfigAuditLog");
}
}
}
@@ -186,6 +186,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Outcome")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("Principal")
.IsRequired()
.HasMaxLength(128)
@@ -445,6 +445,9 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
e.Property(x => x.EventId);
e.Property(x => x.CorrelationId);
// Stored as the AuditOutcome enum member name (mirrors AdminRole's string storage):
// "Success" | "Failure" | "Denied" all fit nvarchar(16). Nullable for legacy + SP-path rows.
e.Property(x => x.Outcome).HasMaxLength(16);
e.HasIndex(x => new { x.ClusterId, x.Timestamp })
.IsDescending(false, true)
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Queries;
/// <summary>
/// Shared query for the cluster-scoped audit view. Audit rows reach <c>ConfigAuditLog</c> by two
/// paths that stamp different columns:
/// <list type="bullet">
/// <item>the bespoke stored-procedure path stamps <c>ClusterId</c> directly;</item>
/// <item>the structured <c>AuditWriterActor</c> path stamps <c>NodeId</c> (leaving
/// <c>ClusterId</c> null).</item>
/// </list>
/// A cluster-scoped view must surface both, so this query matches rows whose <c>ClusterId</c>
/// equals the cluster <em>or</em> whose <c>NodeId</c> belongs to a node in the cluster
/// (membership from <see cref="ClusterNode"/>: <c>NodeId → ClusterId</c>).
/// </summary>
public static class ClusterAuditQuery
{
/// <summary>
/// Returns the newest <paramref name="pageSize"/> audit rows visible for
/// <paramref name="clusterId"/>, newest first. Executes one query to resolve the cluster's
/// node IDs, then one filtered query against <c>ConfigAuditLog</c>.
/// </summary>
/// <param name="db">The config database context.</param>
/// <param name="clusterId">The cluster whose audit rows to fetch.</param>
/// <param name="pageSize">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The matching audit rows, newest first.</returns>
public static async Task<List<ConfigAuditLog>> ForClusterAsync(
OtOpcUaConfigDbContext db, string clusterId, int pageSize, CancellationToken ct = default)
{
var nodeIds = await db.ClusterNodes.AsNoTracking()
.Where(n => n.ClusterId == clusterId)
.Select(n => n.NodeId)
.ToListAsync(ct);
return await db.ConfigAuditLogs.AsNoTracking()
.Where(a => a.ClusterId == clusterId
|| (a.ClusterId == null && a.NodeId != null && nodeIds.Contains(a.NodeId)))
.OrderByDescending(a => a.Timestamp)
.Take(pageSize)
.ToListAsync(ct);
}
}
@@ -24,11 +24,13 @@ public interface ILdapGroupRoleMappingService
/// </remarks>
/// <param name="ldapGroups">The LDAP groups to search for.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task resolving to the list of mappings whose LDAP group matches any of the provided groups.</returns>
Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken);
/// <summary>Enumerate every mapping; Admin UI listing only.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task resolving to all LDAP group role mappings.</returns>
Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken);
/// <summary>Create a new grant.</summary>
@@ -39,11 +41,13 @@ public interface ILdapGroupRoleMappingService
/// </exception>
/// <param name="row">The LDAP group role mapping to create.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task resolving to the newly created <see cref="LdapGroupRoleMapping"/> with any DB-assigned values populated.</returns>
Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken);
/// <summary>Delete a mapping by its surrogate key.</summary>
/// <param name="id">The unique identifier of the mapping to delete.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous delete operation.</returns>
Task DeleteAsync(Guid id, CancellationToken cancellationToken);
}
@@ -10,10 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
/// </summary>
public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
{
/// <summary>Gets LDAP group role mappings for the specified groups.</summary>
/// <param name="ldapGroups">The LDAP group names to query.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The matching role mappings.</returns>
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
{
@@ -21,6 +21,7 @@ public static class DraftValidator
/// Validates a draft snapshot and returns all validation errors found in a single pass.
/// </summary>
/// <param name="draft">The draft snapshot to validate.</param>
/// <returns>A read-only list of all validation errors found; empty if the draft is valid.</returns>
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
{
var errors = new List<ValidationError>();
@@ -147,6 +148,7 @@ public static class DraftValidator
/// <summary>Decision #125: EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
/// <param name="uuid">The equipment UUID to derive the ID from.</param>
/// <returns>The derived equipment ID string in the form <c>EQ-xxxxxxxxxxxx</c>.</returns>
public static string DeriveEquipmentId(Guid uuid) =>
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
@@ -203,6 +205,7 @@ public static class DraftValidator
/// </remarks>
/// <param name="cluster">The server cluster to validate.</param>
/// <param name="clusterNodes">The cluster nodes to validate against the cluster configuration.</param>
/// <returns>A read-only list of all validation errors found; empty if the topology is valid.</returns>
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
ServerCluster cluster,
IReadOnlyList<ClusterNode> clusterNodes)
@@ -55,6 +55,7 @@ public sealed class DriverTypeRegistry
/// <summary>Look up a driver type by name. Throws if unknown.</summary>
/// <param name="driverType">The driver type name to look up.</param>
/// <returns>The <see cref="DriverTypeMetadata"/> registered for the specified type name.</returns>
public DriverTypeMetadata Get(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -69,6 +70,7 @@ public sealed class DriverTypeRegistry
/// <summary>Try to look up a driver type by name. Returns null if unknown (no exception).</summary>
/// <param name="driverType">The driver type name to look up.</param>
/// <returns>The matching <see cref="DriverTypeMetadata"/>, or <c>null</c> if not registered.</returns>
public DriverTypeMetadata? TryGet(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -76,6 +78,7 @@ public sealed class DriverTypeRegistry
}
/// <summary>Snapshot of all registered driver types.</summary>
/// <returns>A read-only collection of all currently registered driver type metadata entries.</returns>
public IReadOnlyCollection<DriverTypeMetadata> All() => _types.Values.ToList();
}
@@ -28,6 +28,7 @@ public interface IHistorianDataSource : IDisposable
/// <param name="endUtc">The end of the time range in UTC.</param>
/// <param name="maxValuesPerNode">The maximum number of values to return per node.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task resolving to a <see cref="HistoryReadResult"/> containing the raw samples.</returns>
Task<HistoryReadResult> ReadRawAsync(
string fullReference,
DateTime startUtc,
@@ -46,6 +47,7 @@ public interface IHistorianDataSource : IDisposable
/// <param name="interval">The interval for bucketing samples.</param>
/// <param name="aggregate">The aggregation function to apply to each bucket.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task resolving to a <see cref="HistoryReadResult"/> containing the processed interval samples.</returns>
Task<HistoryReadResult> ReadProcessedAsync(
string fullReference,
DateTime startUtc,
@@ -63,6 +65,7 @@ public interface IHistorianDataSource : IDisposable
/// <param name="fullReference">The full reference of the tag to read.</param>
/// <param name="timestampsUtc">The list of timestamps to read values at.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task resolving to a <see cref="HistoryReadResult"/> with one sample per requested timestamp.</returns>
Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference,
IReadOnlyList<DateTime> timestampsUtc,
@@ -93,6 +96,7 @@ public interface IHistorianDataSource : IDisposable
/// <param name="endUtc">The end of the time range in UTC.</param>
/// <param name="maxEvents">The maximum number of events to return, or a non-positive value to use the default backend cap.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task resolving to a <see cref="HistoricalEventsResult"/> containing historical alarm and event records.</returns>
Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
@@ -104,5 +108,6 @@ public interface IHistorianDataSource : IDisposable
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
/// observation; never blocks on backend I/O.
/// </summary>
/// <returns>The current <see cref="HistorianHealthSnapshot"/> for this data source.</returns>
HistorianHealthSnapshot GetHealthSnapshot();
}
@@ -18,6 +18,7 @@ public interface IAddressSpaceBuilder
/// </summary>
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
/// <returns>A child builder scoped to inside this folder.</returns>
IAddressSpaceBuilder Folder(string browseName, string displayName);
/// <summary>
@@ -27,6 +28,7 @@ public interface IAddressSpaceBuilder
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent folder).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
/// <param name="attributeInfo">Driver-side metadata for the variable.</param>
/// <returns>An opaque handle for the registered variable.</returns>
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo);
/// <summary>
@@ -56,6 +58,7 @@ public interface IVariableHandle
/// <c>Acknowledge</c>, <c>Deactivate</c>).
/// </summary>
/// <param name="info">The alarm condition information.</param>
/// <returns>A sink that receives alarm lifecycle transitions for this condition.</returns>
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
}
@@ -13,6 +13,7 @@ public interface IAlarmSource
/// </summary>
/// <param name="sourceNodeIds">The driver node IDs to subscribe to.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that resolves to an opaque <see cref="IAlarmSubscriptionHandle"/> for the new subscription.</returns>
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds,
CancellationToken cancellationToken);
@@ -20,11 +21,13 @@ public interface IAlarmSource
/// <summary>Cancel an alarm subscription returned by <see cref="SubscribeAlarmsAsync"/>.</summary>
/// <param name="handle">The subscription handle returned from <see cref="SubscribeAlarmsAsync"/>.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken);
/// <summary>Acknowledge one or more active alarms by source node ID + condition ID.</summary>
/// <param name="acknowledgements">The batch of alarm acknowledgement requests.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
CancellationToken cancellationToken);
@@ -23,6 +23,7 @@ public interface IDriver
/// <summary>Initialize the driver from its <c>DriverConfig</c> JSON; open connections; prepare for first use.</summary>
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
/// <summary>
@@ -37,13 +38,16 @@ public interface IDriver
/// </remarks>
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(CancellationToken cancellationToken);
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
/// <returns>The current driver health snapshot.</returns>
DriverHealth GetHealth();
/// <summary>
@@ -56,6 +60,7 @@ public interface IDriver
/// allocation tracking". Tier C drivers (process-isolated) report through the same
/// interface but the cache-flush is internal to their host.
/// </remarks>
/// <returns>The approximate driver-attributable memory footprint in bytes.</returns>
long GetMemoryFootprint();
/// <summary>
@@ -63,5 +68,6 @@ public interface IDriver
/// Required-for-correctness state must NOT be flushed.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
}
@@ -34,12 +34,8 @@ public sealed class NullDriverFactory : IDriverFactory
public static readonly NullDriverFactory Instance = new();
private NullDriverFactory() { }
/// <summary>Creates a driver (always returns null in this null implementation).</summary>
/// <param name="driverType">The driver type name.</param>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <returns>Always returns null.</returns>
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => null;
/// <summary>Gets the collection of supported driver types (empty in this null implementation).</summary>
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
}
@@ -11,6 +11,10 @@ public interface IDriverHealthPublisher
/// Publishes a health snapshot for one driver instance. Implementations must be
/// non-blocking and tolerant of being called from any thread.
/// </summary>
/// <param name="clusterId">The cluster identifier the driver instance belongs to.</param>
/// <param name="driverInstanceId">The unique identifier of the driver instance.</param>
/// <param name="health">The current health state of the driver instance.</param>
/// <param name="errorCount5Min">Number of errors recorded in the past 5 minutes.</param>
void Publish(
string clusterId,
string driverInstanceId,
@@ -17,6 +17,10 @@ public interface IDriverProbe
/// timeout cancellation. Never throw on connection failure; instead return a result
/// with <c>Ok = false</c> + a message.
/// </summary>
/// <param name="configJson">Driver configuration JSON; same shape the runtime driver consumes.</param>
/// <param name="timeout">Maximum duration for the probe attempt.</param>
/// <param name="ct">Cancellation token for the probe operation.</param>
/// <returns>A task containing the probe result with success status and optional latency.</returns>
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
}
@@ -22,5 +22,6 @@ public interface IDriverSupervisor
/// </summary>
/// <param name="reason">Human-readable reason — flows into the supervisor's logs.</param>
/// <param name="cancellationToken">Cancels the recycle request; an in-flight restart is not interrupted.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RecycleAsync(string reason, CancellationToken cancellationToken);
}
@@ -94,6 +94,7 @@ public interface IHistoryProvider
/// <c>HistorianDataSource</c>). The asymmetry is intentional — Core.Abstractions-006.
/// </param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <returns>A task that resolves to the historical events result for the requested window.</returns>
/// <remarks>
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
/// Wonderware Alarm &amp; Events log) override. Modbus / the OPC UA Client driver stay
@@ -16,6 +16,7 @@ public interface IHostConnectivityProbe
/// Snapshot of host-level connectivity. The Core uses this to drive Bad-quality
/// fan-out scoped to the affected host's subtree (not the whole driver namespace).
/// </summary>
/// <returns>A snapshot list of per-host connectivity statuses.</returns>
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
@@ -13,5 +13,6 @@ public interface ITagDiscovery
/// </summary>
/// <param name="builder">The address space builder to stream discovered nodes into.</param>
/// <param name="cancellationToken">A cancellation token for the discovery operation.</param>
/// <returns>A task that represents the asynchronous discovery operation.</returns>
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
}
@@ -19,6 +19,7 @@ public interface IWritable
/// </summary>
/// <param name="writes">Pairs of full reference + value to write.</param>
/// <param name="cancellationToken">Cancellation token; the driver should abort the batch if cancelled.</param>
/// <returns>A task that resolves to one <see cref="WriteResult"/> per requested write, in the same order.</returns>
Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
CancellationToken cancellationToken);
@@ -41,6 +41,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);
/// <summary>Initializes a new poll-group engine with the supplied reader, change callback, interval floor, and optional error sink.</summary>
/// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
/// order as the input references.</param>
/// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
@@ -68,6 +69,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
/// <summary>Register a new polled subscription and start its background loop.</summary>
/// <param name="fullReferences">The list of tag references to poll.</param>
/// <param name="publishingInterval">The desired polling interval; will be clamped to the configured minimum.</param>
/// <returns>A subscription handle that can be passed to <see cref="Unsubscribe"/> to cancel the loop.</returns>
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
{
ArgumentNullException.ThrowIfNull(fullReferences);
@@ -207,6 +209,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
}
/// <summary>Cancel every active subscription and await all loop tasks. Idempotent.</summary>
/// <returns>A value task that represents the asynchronous dispose operation.</returns>
public async ValueTask DisposeAsync()
{
// Cancel all loops first so they can all start winding down in parallel.
@@ -253,7 +256,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
{
/// <summary>Gets a diagnostic identifier for this subscription.</summary>
/// <inheritdoc />
public string DiagnosticId => $"poll-sub-{Id}";
}
}
@@ -26,9 +26,11 @@ public interface IAlarmHistorianSink
/// <summary>Durably enqueue the event. Returns as soon as the queue row is committed.</summary>
/// <param name="evt">The alarm historian event to enqueue.</param>
/// <param name="cancellationToken">A cancellation token for async operations.</param>
/// <returns>A task that represents the asynchronous enqueue operation.</returns>
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
/// <summary>Snapshot of current queue depth + drain health.</summary>
/// <returns>A snapshot of the current queue depth and drain state.</returns>
HistorianSinkStatus GetStatus();
}
@@ -97,6 +99,7 @@ public interface IAlarmHistorianWriter
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
/// <param name="batch">The batch of alarm historian events to write.</param>
/// <param name="cancellationToken">A cancellation token for async operations.</param>
/// <returns>A task that resolves to one write outcome per event, in the same order as the batch.</returns>
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
}
@@ -255,6 +255,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
/// </remarks>
/// <param name="evt">The alarm historian event to enqueue.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
{
if (evt is null) throw new ArgumentNullException(nameof(evt));
@@ -345,6 +346,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
/// connections per tick, each paying the open + PRAGMA cost.
/// </remarks>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DrainOnceAsync(CancellationToken ct)
{
if (_disposed) return;
@@ -490,7 +492,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
}
}
/// <summary>Gets the current status of the historian sink including queue depth and drain state.</summary>
/// <inheritdoc />
public HistorianSinkStatus GetStatus()
{
// Core.AlarmHistorian-008: read the non-dead-lettered count from the in-memory
@@ -534,6 +536,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
}
/// <summary>Operator action from Admin UI — retry every dead-lettered row. Non-cascading: they rejoin the regular queue + get a fresh backoff.</summary>
/// <returns>The number of rows moved back to the active queue.</returns>
public int RetryDeadLettered()
{
using var conn = OpenConnection();
@@ -97,6 +97,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// copy under the gate. (Core.ScriptedAlarms-013.)
/// </remarks>
/// <param name="alarmId">The alarm identifier to look up.</param>
/// <returns>The live read-cache dictionary for the alarm, or <see langword="null"/> if not yet allocated.</returns>
internal IReadOnlyDictionary<string, DataValueSnapshot>? TryGetScratchReadCacheForTest(string alarmId)
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.ReadCache : null;
@@ -113,6 +114,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// (Core.ScriptedAlarms-013.)
/// </remarks>
/// <param name="alarmId">The alarm identifier to look up.</param>
/// <returns>The reusable <see cref="AlarmPredicateContext"/> for the alarm, or <see langword="null"/> if not yet allocated.</returns>
internal AlarmPredicateContext? TryGetScratchContextForTest(string alarmId)
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
@@ -175,6 +177,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// </summary>
/// <param name="definitions">The alarm definitions to load.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
@@ -306,10 +309,12 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
/// </summary>
/// <param name="alarmId">The alarm identifier.</param>
/// <returns>The current <see cref="AlarmConditionState"/> for the alarm, or <see langword="null"/> if the alarm is unknown.</returns>
public AlarmConditionState? GetState(string alarmId)
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
/// <summary>Gets the current persisted state for all loaded alarms.</summary>
/// <returns>A snapshot collection of all current alarm condition states.</returns>
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
=> _alarms.Values.Select(a => a.Condition).ToArray();
@@ -318,6 +323,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user performing the acknowledgment.</param>
/// <param name="comment">An optional comment to attach to the acknowledgment.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
@@ -326,6 +332,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user performing the confirmation.</param>
/// <param name="comment">An optional comment to attach to the confirmation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
@@ -333,6 +340,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the shelve operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
@@ -341,6 +349,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user performing the shelve operation.</param>
/// <param name="unshelveAtUtc">The UTC time at which the shelve will automatically expire.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
@@ -348,6 +357,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the unshelve operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
@@ -355,6 +365,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the enable operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
@@ -362,6 +373,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the disable operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
@@ -370,6 +382,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user adding the comment.</param>
/// <param name="text">The comment text.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
@@ -39,6 +39,7 @@ public static class DependencyExtractor
/// paths, or a list of rejection messages if non-literal paths were used.
/// </summary>
/// <param name="scriptSource">The script source code to analyze.</param>
/// <returns>The extracted dependency paths, or rejection messages for unsupported patterns.</returns>
public static DependencyExtractionResult Extract(string scriptSource)
{
if (string.IsNullOrWhiteSpace(scriptSource))
@@ -41,6 +41,7 @@ public abstract class ScriptContext
/// right upstream tags at load time.
/// </remarks>
/// <param name="path">The literal tag path to read.</param>
/// <returns>The current <see cref="DataValueSnapshot"/> for the tag, including value, quality, and timestamp.</returns>
public abstract DataValueSnapshot GetTag(string path);
/// <summary>
@@ -81,6 +82,7 @@ public abstract class ScriptContext
/// <param name="current">The current value to check.</param>
/// <param name="previous">The previous value to compare against.</param>
/// <param name="tolerance">The minimum difference threshold for a change to be detected.</param>
/// <returns><see langword="true"/> when the absolute difference between current and previous exceeds tolerance.</returns>
public static bool Deadband(double current, double previous, double tolerance)
=> Math.Abs(current - previous) > tolerance;
}
@@ -66,6 +66,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
/// <summary>Compiles user script source into an evaluator.</summary>
/// <param name="scriptSource">The user script source code to compile.</param>
/// <returns>A compiled <see cref="ScriptEvaluator{TContext, TResult}"/> ready to invoke.</returns>
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
{
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
@@ -173,6 +174,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
/// <summary>Runs the script against an already-constructed context.</summary>
/// <param name="context">The script context.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the script's return value.</returns>
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
{
if (_disposed) throw new ObjectDisposedException(nameof(ScriptEvaluator<TContext, TResult>));
@@ -43,6 +43,7 @@ public static class ScriptSandbox
/// to resolve <c>ctx.GetTag(...)</c> calls.
/// </summary>
/// <param name="contextType">The concrete script context type to use for compilation.</param>
/// <returns>The sandbox configuration for compiling scripts with the given context type.</returns>
public static SandboxConfig Build(Type contextType)
{
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
@@ -156,6 +156,7 @@ public sealed class DependencyGraph
/// dependencies. Throws <see cref="DependencyCycleException"/> if any cycle
/// exists. Implemented via Kahn's algorithm.
/// </summary>
/// <returns>A list of node IDs in topological evaluation order.</returns>
public IReadOnlyList<string> TopologicalSort()
{
// Kahn's framing: edge u -> v means "u must come before v". For dependencies,
@@ -205,6 +206,7 @@ public sealed class DependencyGraph
/// Empty list means the graph is a DAG. Useful for surfacing every cycle in one
/// rejection pass so operators see all of them, not just one at a time.
/// </summary>
/// <returns>A list of strongly-connected components that form cycles; empty if the graph is acyclic.</returns>
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
{
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
@@ -30,6 +30,7 @@ public interface ITagUpstreamSource
/// when the path isn't configured.
/// </summary>
/// <param name="path">The tag path to read.</param>
/// <returns>The last-known value and quality snapshot for the tag.</returns>
DataValueSnapshot ReadTag(string path);
/// <summary>
@@ -40,5 +41,6 @@ public interface ITagUpstreamSource
/// </summary>
/// <param name="path">The tag path to subscribe to.</param>
/// <param name="observer">The callback to invoke when the value changes.</param>
/// <returns>An <see cref="IDisposable"/> that cancels the subscription when disposed.</returns>
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
}
@@ -198,6 +198,7 @@ public sealed class VirtualTagEngine : IDisposable
/// default. Also called after a config reload.
/// </summary>
/// <param name="ct">Cancellation token to stop evaluation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task EvaluateAllAsync(CancellationToken ct = default)
{
EnsureLoaded();
@@ -212,6 +213,7 @@ public sealed class VirtualTagEngine : IDisposable
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
/// <param name="path">Path of the virtual tag to evaluate.</param>
/// <param name="ct">Cancellation token to stop evaluation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
{
EnsureLoaded();
@@ -226,6 +228,7 @@ public sealed class VirtualTagEngine : IDisposable
/// evaluation result.
/// </summary>
/// <param name="path">Path of the tag to read.</param>
/// <returns>The most recently cached value and quality for the tag path.</returns>
public DataValueSnapshot Read(string path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -242,6 +245,7 @@ public sealed class VirtualTagEngine : IDisposable
/// </summary>
/// <param name="path">Path of the tag to subscribe to.</param>
/// <param name="observer">Callback invoked with the tag path and new value on each evaluation.</param>
/// <returns>An <see cref="IDisposable"/> that cancels the subscription when disposed.</returns>
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
{
// Race-safe pattern paired with Unsub.Dispose: if Unsub.Dispose removed the
@@ -19,6 +19,7 @@ public sealed record AuthorizationDecision(
public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
/// <summary>Convenience constructor for the common "no grants matched" outcome.</summary>
/// <returns>An <see cref="AuthorizationDecision"/> with <see cref="AuthorizationVerdict.NotGranted"/> and empty provenance.</returns>
public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
/// <summary>Allow with the list of grants that matched.</summary>
@@ -22,5 +22,6 @@ public interface IPermissionEvaluator
/// <param name="session">The user session containing resolved LDAP groups and roles.</param>
/// <param name="operation">The OPC UA operation being requested.</param>
/// <param name="scope">The node address scope being accessed.</param>
/// <returns>An <see cref="AuthorizationDecision"/> indicating whether the operation is allowed.</returns>
AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
}
@@ -33,6 +33,7 @@ public sealed class PermissionTrie
/// </summary>
/// <param name="scope">The node scope to match permissions for.</param>
/// <param name="ldapGroups">The user's LDAP group memberships.</param>
/// <returns>The list of grants that apply to the given scope for any of the session's LDAP groups.</returns>
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
{
ArgumentNullException.ThrowIfNull(scope);
@@ -41,6 +41,7 @@ public static class PermissionTrieBuilder
/// Core-011 production hazard. The callback fires only when <paramref name="scopePaths"/>
/// is non-null (a null lookup is the explicit deterministic-test fallback mode).
/// </param>
/// <returns>An immutable <see cref="PermissionTrie"/> for the given cluster and generation.</returns>
public static PermissionTrie Build(
string clusterId,
long generationId,
@@ -34,6 +34,7 @@ public sealed class PermissionTrieCache
/// <summary>Get the current-generation trie for a cluster; null when nothing installed.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <returns>The current-generation trie, or null if nothing is installed for the cluster.</returns>
public PermissionTrie? GetTrie(string clusterId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
@@ -43,6 +44,7 @@ public sealed class PermissionTrieCache
/// <summary>Get a specific (cluster, generation) trie; null if that pair isn't cached.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <param name="generationId">The generation identifier.</param>
/// <returns>The trie for the specified cluster and generation, or null if not cached.</returns>
public PermissionTrie? GetTrie(string clusterId, long generationId)
{
if (!_byCluster.TryGetValue(clusterId, out var entry)) return null;
@@ -51,6 +53,7 @@ public sealed class PermissionTrieCache
/// <summary>The generation id the <see cref="GetTrie(string)"/> shortcut currently serves for a cluster.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <returns>The current generation ID, or null if no trie is installed for the cluster.</returns>
public long? CurrentGenerationId(string clusterId)
=> _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
@@ -111,11 +114,13 @@ public sealed class PermissionTrieCache
/// <summary>Creates a cluster entry from a single trie.</summary>
/// <param name="trie">The permission trie to create the entry from.</param>
/// <returns>A new <see cref="ClusterEntry"/> containing the single trie as the current generation.</returns>
public static ClusterEntry FromSingle(PermissionTrie trie) =>
new(trie, new Dictionary<long, PermissionTrie> { [trie.GenerationId] = trie });
/// <summary>Creates a new entry with an additional trie, updating current if it's newer.</summary>
/// <param name="trie">The new permission trie to add.</param>
/// <returns>A new <see cref="ClusterEntry"/> with the trie added and the current pointer updated if the new generation is newer.</returns>
public ClusterEntry WithAdditional(PermissionTrie trie)
{
var next = new Dictionary<long, PermissionTrie>(Tries) { [trie.GenerationId] = trie };
@@ -24,11 +24,7 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>Authorizes an operation against the user's session and node scope.</summary>
/// <param name="session">The user's authorization session.</param>
/// <param name="operation">The OPC UA operation to authorize.</param>
/// <param name="scope">The target node scope.</param>
/// <returns>An authorization decision indicating whether the operation is allowed.</returns>
/// <inheritdoc />
public AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
{
ArgumentNullException.ThrowIfNull(session);
@@ -64,6 +64,7 @@ public sealed record UserAuthorizationState
/// whenever this is true.
/// </summary>
/// <param name="utcNow">The current UTC time.</param>
/// <returns><c>true</c> when the state exceeds its maximum staleness ceiling.</returns>
public bool IsStale(DateTime utcNow) => utcNow - MembershipResolvedUtc > AuthCacheMaxStaleness;
/// <summary>
@@ -72,6 +73,7 @@ public sealed record UserAuthorizationState
/// call still evaluates against the cached memberships.
/// </summary>
/// <param name="utcNow">The current UTC time.</param>
/// <returns><c>true</c> when a background refresh should be initiated but the current cached memberships are still usable.</returns>
public bool NeedsRefresh(DateTime utcNow) =>
!IsStale(utcNow) && utcNow - MembershipResolvedUtc > MembershipFreshnessInterval;
}
@@ -63,6 +63,7 @@ public sealed class DriverFactoryRegistry
/// missing-assembly deployment doesn't take down the whole server.
/// </summary>
/// <param name="driverType">The driver type to look up.</param>
/// <returns>The registered factory delegate, or <see langword="null"/> if no factory was registered for the type.</returns>
public Func<string, string, IDriver>? TryGet(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -75,6 +76,7 @@ public sealed class DriverFactoryRegistry
/// case upstream; we don't double-surface that failure here.
/// </summary>
/// <param name="driverType">The driver type to look up.</param>
/// <returns>The registered <see cref="DriverTier"/>, or <see cref="DriverTier.A"/> if the type is unknown.</returns>
public DriverTier GetTier(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -20,16 +20,13 @@ public sealed class DriverFactoryRegistryAdapter : IDriverFactory
_registry = registry;
}
/// <summary>Attempts to create a driver instance by type and configuration.</summary>
/// <param name="driverType">The driver type name.</param>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
var factory = _registry.TryGet(driverType);
return factory?.Invoke(driverInstanceId, driverConfigJson);
}
/// <summary>Gets the collection of supported driver type names.</summary>
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => _registry.RegisteredTypes;
}
@@ -21,6 +21,7 @@ public sealed class DriverHost : IAsyncDisposable
/// <summary>Gets the health status of a registered driver.</summary>
/// <param name="driverInstanceId">The driver instance identifier to query.</param>
/// <returns>The driver health if the driver is registered; otherwise null.</returns>
public DriverHealth? GetHealth(string driverInstanceId)
{
lock (_lock)
@@ -33,6 +34,7 @@ public sealed class DriverHost : IAsyncDisposable
/// startup. Returns null when the driver is not registered.
/// </summary>
/// <param name="driverInstanceId">The driver instance identifier to look up.</param>
/// <returns>The driver instance if registered; otherwise null.</returns>
public IDriver? GetDriver(string driverInstanceId)
{
lock (_lock)
@@ -47,6 +49,7 @@ public sealed class DriverHost : IAsyncDisposable
/// <param name="driver">The driver instance to register.</param>
/// <param name="driverConfigJson">The configuration JSON for the driver.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RegisterAsync(IDriver driver, string driverConfigJson, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(driver);
@@ -70,6 +73,7 @@ public sealed class DriverHost : IAsyncDisposable
/// <summary>Unregisters a driver and calls shutdown.</summary>
/// <param name="driverInstanceId">The driver instance identifier to unregister.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task UnregisterAsync(string driverInstanceId, CancellationToken ct)
{
IDriver? driver;
@@ -84,6 +88,7 @@ public sealed class DriverHost : IAsyncDisposable
}
/// <summary>Disposes the driver host and all registered drivers.</summary>
/// <returns>A value task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
List<IDriver> snapshot;
@@ -27,6 +27,7 @@ public static class DriverHealthReport
{
/// <summary>Compute the fleet-wide readiness verdict from per-driver states.</summary>
/// <param name="drivers">The list of per-driver health snapshots to aggregate.</param>
/// <returns>The fleet-wide <see cref="ReadinessVerdict"/> derived from all driver states.</returns>
public static ReadinessVerdict Aggregate(IReadOnlyList<DriverHealthSnapshot> drivers)
{
ArgumentNullException.ThrowIfNull(drivers);
@@ -54,6 +55,7 @@ public static class DriverHealthReport
/// return per the Stream C.1 state matrix.
/// </summary>
/// <param name="verdict">The readiness verdict to map to HTTP status.</param>
/// <returns>The HTTP status code (200 or 503) corresponding to the verdict.</returns>
public static int HttpStatus(ReadinessVerdict verdict) => verdict switch
{
ReadinessVerdict.Healthy => 200,
@@ -22,6 +22,7 @@ public static class LogContextEnricher
/// <param name="driverType">The driver type name.</param>
/// <param name="capability">The driver capability being invoked.</param>
/// <param name="correlationId">The correlation ID for tracing the call.</param>
/// <returns>A scope that pops the pushed properties when disposed.</returns>
public static IDisposable Push(string driverInstanceId, string driverType, DriverCapability capability, string correlationId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
@@ -40,6 +41,7 @@ public static class LogContextEnricher
/// 12-hex-char slice of a GUID — long enough for log correlation, short enough to
/// scan visually.
/// </summary>
/// <returns>A 12-character hex string suitable for log correlation.</returns>
public static string NewCorrelationId() => Guid.NewGuid().ToString("N")[..12];
private sealed class CompositeScope : IDisposable
@@ -183,6 +183,7 @@ public static class EquipmentNodeWalker
/// wants an opaque non-JSON reference.
/// </remarks>
/// <param name="tagConfig">The tag configuration JSON or string.</param>
/// <returns>The value of the <c>FullName</c> field from the JSON, or the raw <paramref name="tagConfig"/> string as a fallback.</returns>
internal static string ExtractFullName(string tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
@@ -49,6 +49,7 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
/// </summary>
/// <param name="builder">The address space builder to populate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous address space build operation.</returns>
public async Task BuildAddressSpaceAsync(IAddressSpaceBuilder builder, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -111,23 +112,15 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
IAddressSpaceBuilder inner,
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IAddressSpaceBuilder
{
/// <summary>Adds a folder to the address space.</summary>
/// <param name="browseName">The browse name of the folder node.</param>
/// <param name="displayName">The display name of the folder node.</param>
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName)
=> new CapturingBuilder(inner.Folder(browseName, displayName), sinks);
/// <summary>Adds a variable to the address space.</summary>
/// <param name="browseName">The browse name of the variable node.</param>
/// <param name="displayName">The display name of the variable node.</param>
/// <param name="attributeInfo">Metadata describing the variable's data type and properties.</param>
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> new CapturingHandle(inner.Variable(browseName, displayName, attributeInfo), sinks);
/// <summary>Adds a property to the address space.</summary>
/// <param name="browseName">The browse name of the property node.</param>
/// <param name="dataType">The OPC UA data type of the property.</param>
/// <param name="value">The initial value of the property, or null.</param>
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value)
=> inner.AddProperty(browseName, dataType, value);
}
@@ -136,11 +129,10 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
IVariableHandle inner,
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IVariableHandle
{
/// <summary>Gets the full reference for the variable.</summary>
/// <inheritdoc />
public string FullReference => inner.FullReference;
/// <summary>Marks the variable as an alarm condition and registers its sink.</summary>
/// <param name="info">Configuration for the alarm condition.</param>
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
var sink = inner.MarkAsAlarmCondition(info);
@@ -59,6 +59,7 @@ public sealed class AlarmSurfaceInvoker
/// </summary>
/// <param name="sourceNodeIds">The source node IDs to subscribe to.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that resolves to one subscription handle per resolved host.</returns>
public async Task<IReadOnlyList<IAlarmSubscriptionHandle>> SubscribeAsync(
IReadOnlyList<string> sourceNodeIds,
CancellationToken cancellationToken)
@@ -89,6 +90,7 @@ public sealed class AlarmSurfaceInvoker
/// </summary>
/// <param name="handle">The subscription handle to unsubscribe.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous unsubscribe operation.</returns>
public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(handle);
@@ -110,6 +112,7 @@ public sealed class AlarmSurfaceInvoker
/// </summary>
/// <param name="acknowledgements">The alarm acknowledgement requests.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous acknowledgement operation.</returns>
public async Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
CancellationToken cancellationToken)
@@ -166,7 +169,7 @@ public sealed class AlarmSurfaceInvoker
public IAlarmSubscriptionHandle Inner { get; } = inner;
/// <summary>Gets the resolved host name.</summary>
public string Host { get; } = host;
/// <summary>Gets the diagnostic ID from the inner handle.</summary>
/// <inheritdoc />
public string DiagnosticId => Inner.DiagnosticId;
}
}
@@ -58,6 +58,7 @@ public sealed class CapabilityInvoker
/// <param name="hostName">The host name for logging and status tracking.</param>
/// <param name="callSite">The async function to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The result produced by <paramref name="callSite"/> after executing through the pipeline.</returns>
public async ValueTask<TResult> ExecuteAsync<TResult>(
DriverCapability capability,
string hostName,
@@ -86,6 +87,7 @@ public sealed class CapabilityInvoker
/// <param name="hostName">The host name for logging and status tracking.</param>
/// <param name="callSite">The async function to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A value task that represents the asynchronous operation.</returns>
public async ValueTask ExecuteAsync(
DriverCapability capability,
string hostName,
@@ -121,6 +123,7 @@ public sealed class CapabilityInvoker
/// <param name="isIdempotent">Whether the write operation is idempotent.</param>
/// <param name="callSite">The async function to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The result produced by <paramref name="callSite"/> after executing through the write pipeline.</returns>
public async ValueTask<TResult> ExecuteWriteAsync<TResult>(
string hostName,
bool isIdempotent,
@@ -50,6 +50,7 @@ public static class DriverResilienceOptionsParser
/// <param name="tier">The driver tier for default resilience options.</param>
/// <param name="resilienceConfigJson">The optional JSON configuration string to parse.</param>
/// <param name="parseDiagnostic">An out parameter containing diagnostic information if parsing fails.</param>
/// <returns>The effective resilience options; tier defaults when the JSON is null or malformed.</returns>
public static DriverResilienceOptions ParseOrDefaults(
DriverTier tier,
string? resilienceConfigJson,
@@ -54,6 +54,7 @@ public sealed class DriverResiliencePipelineBuilder
/// </param>
/// <param name="capability">Which capability surface is being called.</param>
/// <param name="options">Per-driver-instance options (tier + per-capability overrides).</param>
/// <returns>The cached or newly created <see cref="ResiliencePipeline"/> for the given key.</returns>
public ResiliencePipeline GetOrCreate(
string driverInstanceId,
string hostName,
@@ -128,10 +128,12 @@ public sealed class DriverResilienceStatusTracker
/// <summary>Snapshot of a specific (instance, host) pair; null if no counters recorded yet.</summary>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="hostName">The host name.</param>
/// <returns>The current <see cref="ResilienceStatusSnapshot"/> for the pair, or <see langword="null"/> if no counters have been recorded.</returns>
public ResilienceStatusSnapshot? TryGet(string driverInstanceId, string hostName) =>
_status.TryGetValue(new StatusKey(driverInstanceId, hostName), out var snapshot) ? snapshot : null;
/// <summary>Copy of every currently-tracked (instance, host, snapshot) triple. Safe under concurrent writes.</summary>
/// <returns>A snapshot list of all currently tracked driver instance and host resilience states.</returns>
public IReadOnlyList<(string DriverInstanceId, string HostName, ResilienceStatusSnapshot Snapshot)> Snapshot() =>
_status.Select(kvp => (kvp.Key.DriverInstanceId, kvp.Key.HostName, kvp.Value)).ToList();
@@ -33,6 +33,7 @@ public sealed class MemoryTracking
/// <summary>Tier-default multiplier/floor constants per decision #146.</summary>
/// <param name="tier">The driver tier.</param>
/// <returns>A tuple with the growth multiplier and the minimum floor bytes for the specified tier.</returns>
public static (int Multiplier, long FloorBytes) GetTierConstants(DriverTier tier) => tier switch
{
DriverTier.A => (Multiplier: 3, FloorBytes: 50L * 1024 * 1024),
@@ -73,6 +74,7 @@ public sealed class MemoryTracking
/// </summary>
/// <param name="footprintBytes">The current memory footprint in bytes.</param>
/// <param name="utcNow">The current UTC time.</param>
/// <returns>The <see cref="MemoryTrackingAction"/> classifying this sample against the soft/hard thresholds.</returns>
public MemoryTrackingAction Sample(long footprintBytes, DateTime utcNow)
{
if (_phase == TrackingPhase.WarmingUp)
@@ -60,6 +60,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
/// probe loop would race the operator's own reads.
/// </summary>
/// <param name="tags">The list of tag definitions to include in the options.</param>
/// <returns>A fully-configured <see cref="AbCipDriverOptions"/> with probe and alarm projection disabled.</returns>
protected AbCipDriverOptions BuildOptions(IReadOnlyList<AbCipTagDefinition> tags) => new()
{
Devices = [new AbCipDeviceOptions(
@@ -66,6 +66,7 @@ public sealed class ReadCommand : AbCipCommandBase
/// </summary>
/// <param name="tagPath">The symbolic tag path.</param>
/// <param name="type">The data type.</param>
/// <returns>A combined tag-name string in <c>path:type</c> form.</returns>
internal static string SynthesiseTagName(string tagPath, AbCipDataType type)
=> $"{tagPath}:{type}";
}

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