Compare commits

...

48 Commits

Author SHA1 Message Date
Joseph Doherty
8adc8f5ab8 Phase 3 PR 37 — End-to-end live-stack Galaxy smoke test. Closes the code side of LMX follow-up #5; once OtOpcUaGalaxyHost is installed + started on the dev box, the suite exercises the full topology GalaxyProxyDriver in-process → named-pipe IPC → running OtOpcUaGalaxyHost Windows service → MxAccessGalaxyBackend → live MXAccess runtime → real deployed Galaxy objects. Never spawns the Host process itself — connects to the already-running service per project_galaxy_host_service.md, which is the only way to exercise the production COM-apartment + service-account + pipe-ACL configuration.
LiveStackConfig resolves the pipe name + per-install shared secret from two sources in order: OTOPCUA_GALAXY_PIPE + OTOPCUA_GALAXY_SECRET env vars first (for CI / benchwork overrides), then the service's per-process Environment registry values under HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost (what Install-Services.ps1 writes at install time). Registry read requires the test host to run elevated on most boxes — the skip message says so explicitly so operators see the right remediation. Hard-coded secrets are deliberately avoided: the installer generates 32 fresh random bytes per install, a committed secret would diverge from production the moment the service is re-installed.
LiveStackFixture is an IAsyncLifetime that (1) runs AvevaPrerequisites.CheckAllAsync with CheckGalaxyHostPipe=true + CheckHistorian=false — produces a structured PrerequisiteReport whose SkipReason is the exact operator-facing 'here's what you need to fix' text, (2) resolves LiveStackConfig and surfaces a clear skip when the secret isn't discoverable, (3) instantiates GalaxyProxyDriver + calls InitializeAsync (the IPC handshake), capturing a skip with the exception detail + common-cause hints (secret mismatch, SID not in pipe ACL, Host's backend couldn't connect to ZB) rather than letting a NullRef cascade through every subsequent test. SkipIfUnavailable() translates the captured SkipReason into Assert.Skip at the top of every fact so tests read as cleanly-skipped with a visible reason, not silently-passed or crashed.
LiveStackSmokeTests (5 facts, Collection=LiveStack, Category=LiveGalaxy): Fixture_initialized_successfully (cheapest possible end-to-end assertion — if this passes, the IPC handshake worked); Driver_reports_Healthy_after_IPC_handshake (DriverHealth.State post-connect); DiscoverAsync_returns_at_least_one_variable_from_live_galaxy (captures every Variable() call from DiscoverAsync via CapturingAddressSpaceBuilder and asserts > 0 — zero here usually means the Host couldn't read ZB, the skip message names OTOPCUA_GALAXY_ZB_CONN to check); GetHostStatuses_reports_at_least_one_platform (IHostConnectivityProbe surface — zero means the probe loop hasn't fired or no Platform is deployed locally); Can_read_a_discovered_variable_from_live_galaxy (reads the first discovered attribute's full reference, asserts status != BadInternalError — Galaxy's Uncertain-quality-until-first-Engine-scan is intentionally NOT treated as failure since it depends on runtime state that varies across test runs). Read-only by design; writes need an agreed scratch tag to avoid mutating a process-critical attribute — deferred to a follow-up PR that reuses this fixture.
CapturingAddressSpaceBuilder is a minimal IAddressSpaceBuilder that flattens every Variable() call into a list so tests can inspect what discovery produced without booting the full OPC UA node-manager stack; alarm annotation + property calls are no-ops. Scoped private to the test class.
Galaxy.Proxy.Tests csproj gains a ProjectReference to Driver.Galaxy.TestSupport (PR 36) for AvevaPrerequisites. The NU1702 warning about the Host project being net48-referenced-by-net10 is pre-existing from the HostSubprocessParityTests — Proxy.Tests only needs the Host EXE path for that parity scenario, not type surface.
Test run on THIS machine (OtOpcUaGalaxyHost not yet installed): Skipped! Failed 0, Passed 0, Skipped 5 — each skip message includes the full prerequisites report pointing at the missing service. Once the service is installed + started (scripts\install\Install-Services.ps1), the 5 facts will execute against live Galaxy. Proxy.Tests Unit: 17 pass / 0 fail (unchanged — new tests are Category=LiveGalaxy, separate suite). Full Proxy build clean. Memory already captures the 'live tests run via already-running service, don't spawn' convention (project_galaxy_host_service.md).
lmx-followups.md #5 updated: status is 'IN PROGRESS' across PRs 36 + 37 with the explicit remaining work (install + start services, subscribe-and-receive, write round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:49:51 -04:00
261869d84e Merge pull request 'Phase 3 PR 36 — AVEVA prerequisites test-support library' (#35) from phase-3-pr36-aveva-prerequisites into v2 2026-04-18 16:44:41 -04:00
Joseph Doherty
08c90d19fd Phase 3 PR 36 — AVEVA prerequisites test-support library. New tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport multi-targeted class library (net10.0 + net48 so both the modern and the MXAccess-COM x86 test projects can consume it) that probes every piece of the AVEVA System Platform + OtOpcUa stack a live-Galaxy test depends on and returns a structured PrerequisiteReport. Closes the gap where live-smoke tests silently returned 'unreachable' without telling operators which specific piece failed.
AvevaPrerequisites.CheckAllAsync walks eight probe categories producing PrerequisiteCheck rows each with Name (e.g. 'service:aaBootstrap', 'sql:ZB', 'com:LMXProxy', 'registry:ArchestrA.Framework'), Category (AvevaCoreService / AvevaSoftService / AvevaInstall / MxAccessCom / GalaxyRepository / AvevaHistorian / OtOpcUaService / Environment), Status (Pass / Warn / Fail / Skip), and operator-facing Detail message. Report aggregates them: IsLivetestReady (no Fails anywhere) and IsAvevaSideReady (AVEVA-side categories pass, our v2 services can be absent while still considering the environment AVEVA-ready) so different test tiers can use the right threshold.
Individual probes: ServiceProbe.Check queries the Windows Service Control Manager via System.ServiceProcess.ServiceController — treats DemandStart+Stopped as Warn (NmxSvc is DemandStart by design; master pulls it up) but AutoStart+Stopped as Fail; not-installed is Fail for hard-required services, Warn for soft ones; non-Windows hosts get Skip; transitional states like StartPending get Warn with a 'try again' hint. RegistryProbe reads HKLM\SOFTWARE\WOW6432Node\ArchestrA\{Framework,Framework\Platform,MSIInstall} — Framework key presence + populated InstallPath/RootPath values mean System Platform installed; PfeConfigOptions in the Platform subkey (format 'PlatformId=N,EngineId=N,...') indicates a Platform has been deployed from the IDE (PlatformId=0 means never deployed — MXAccess will connect but every subscription will be Bad quality); RebootRequired='True' under MSIInstall surfaces as a loud warn since post-patch behavior is undefined. MxAccessComProbe resolves the LMXProxy.LMXProxyServer ProgID → CLSID → HKLM\SOFTWARE\Classes\WOW6432Node\CLSID\{guid}\InprocServer32, verifying the registered file exists on disk (catches the orphan-registry case where a previous uninstall left the ProgID registered but the DLL is gone — distinguishes it from the 'totally not installed' case by message); also emits a Warn when the test process is 64-bit (MXAccess COM activation fails with REGDB_E_CLASSNOTREG 0x80040154 regardless of registration, so seeing this warning tells operators why the activation would fail even on a fully-installed machine). SqlProbe tests Galaxy Repository via Microsoft.Data.SqlClient using the Windows-auth localhost connection string the repo code defaults to — distinguishes 'SQL Server unreachable' (connection fails) from 'ZB database does not exist' (SELECT DB_ID('ZB') returns null) because they have different remediation paths (sc.exe start MSSQLSERVER vs. restore from .cab backup); a secondary CheckDeployedObjectCountAsync query on 'gobject WHERE deployed_version > 0' warns when the count is zero because discovery smoke tests will return empty hierarchies. NamedPipeProbe opens a 2s NamedPipeClientStream against OtOpcUaGalaxyHost's pipe ('OtOpcUaGalaxy' per the installer default) — pipe accepting a connection proves the Host service is listening; disconnects immediately so we don't consume a session slot.
Service lists kept as internal static data so tests can inspect + override: CoreServices (aaBootstrap + aaGR + NmxSvc + MSSQLSERVER — hard fail if missing), SoftServices (aaLogger + aaUserValidator + aaGlobalDataCacheMonitorSvr — warn only; stack runs without them but diagnostics/auth are degraded), HistorianServices (aahClientAccessPoint + aahGateway — opt-in via Options.CheckHistorian, only matters for HistoryRead IPC paths), OtOpcUaServices (our OtOpcUaGalaxyHost hard-required for end-to-end live tests + OtOpcUa warn + GLAuth warn). Narrower entry points CheckRepositoryOnlyAsync and CheckGalaxyHostPipeOnlyAsync for tests that only care about specific subsystems — avoid paying the full probe cost on every GalaxyRepositoryLiveSmokeTests fact.
Multi-targeting mechanics: System.ServiceProcess.ServiceController + Microsoft.Win32.Registry are NuGet packages on net10 but in-box BCL references on net48; csproj conditions Package vs Reference by TargetFramework. Microsoft.Data.SqlClient v6 supports both frameworks so single PackageReference. Net48Polyfills.cs provides IsExternalInit shim (records/init-only setters) and SupportedOSPlatformAttribute stub so the same Probe sources compile on both frameworks without per-callsite preprocessor guards — lets Roslyn's platform-compatibility analyzer stay useful on net10 without breaking net48 builds.
Existing GalaxyRepositoryLiveSmokeTests updated to delegate its skip decision to AvevaPrerequisites.CheckRepositoryOnlyAsync (legacy ZbReachableAsync kept as a compatibility adapter so the in-test 'if (!await ZbReachableAsync()) return;' pattern keeps working while the surrounding fixtures gradually migrate to Assert.Skip-with-reason). Slnx file registers the new project.
Tests — AvevaPrerequisitesLiveTests (8 new Integration cases, Category=LiveGalaxy): the helper correctly reports Framework install (registry pass), aaBootstrap Running (service pass), aaGR Running (service pass), MxAccess COM registered (com pass), ZB database reachable (sql pass), deployed-object count > 0 (warn-upgraded-to-pass because this box has 49 objects deployed), the AVEVA side is ready even when our own services (OtOpcUaGalaxyHost) aren't installed yet (IsAvevaSideReady=true), and the helper emits rows for OtOpcUaGalaxyHost + OtOpcUa + GLAuth even when not installed (regression guard — nobody can accidentally ship a check that omits our own services). Full Galaxy.Host.Tests Category=LiveGalaxy suite: 13 pass (5 prior smoke + 8 new prerequisites). Full solution build clean, 0 errors.
What's NOT in this PR: end-to-end Galaxy stack smoke (Proxy → Host pipe → MXAccess → real Galaxy tag). That's the next PR — this one is the gate the end-to-end smoke will call first to produce actionable skip messages instead of silent returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:36:13 -04:00
5cc120d836 Merge pull request 'Phase 3 PR 35 — IHistoryProvider gains ReadAtTime + ReadEvents; Proxy implements both' (#34) from phase-3-pr35-history-readtime-readevents into v2 2026-04-18 16:12:43 -04:00
Joseph Doherty
bf329b05d8 Phase 3 PR 35 — IHistoryProvider gains ReadAtTimeAsync + ReadEventsAsync; GalaxyProxyDriver implements both. Extends Core.Abstractions.IHistoryProvider with two new methods that round out the OPC UA Part 11 HistoryRead surface (HistoryReadAtTime + HistoryReadEvents are the last two modes not covered by the PR 19-era ReadRawAsync + ReadProcessedAsync) and wires GalaxyProxyDriver to call the existing PR-10/PR-11 IPC contracts the Host already implements.
Interface additions use C# default interface implementations that throw NotSupportedException — existing IHistoryProvider implementations keep compiling, only drivers whose backend carries the relevant capability override. This matches the 'capabilities are optional per driver' design already used by IHistoryProvider.ReadProcessedAsync's docs (Modbus / OPC UA Client drivers never had an event historian and the default-throw path lets callers see BadHistoryOperationUnsupported naturally). New HistoricalEvent record models one historian row (EventId, SourceName, EventTimeUtc + ReceivedTimeUtc — process vs historian-persist timestamps, Message, Severity mapped to OPC UA's 1-1000 range); HistoricalEventsResult pairs the event list with a continuation-point token for future batching. Both live in Core.Abstractions so downstream (Proxy, Host, Server) reference a single domain shape — no Shared-contract leak into the driver-facing interface.
GalaxyProxyDriver.ReadAtTimeAsync maps the domain DateTime[] to Unix-ms longs, calls CallAsync on the existing MessageKind.HistoryReadAtTimeRequest, and trusts the Host's one-sample-per-requested-timestamp contract (the Host pads with bad-quality snapshots for timestamps it can't interpolate; re-aligning on the Proxy side would duplicate the Host's interpolation policy logic). ReadEventsAsync does the same for HistoryReadEventsRequest; ToHistoricalEvent translates GalaxyHistoricalEvent (MessagePack-annotated, Unix-ms) to the domain record, explicitly tagging DateTimeKind.Utc on both timestamp fields so downstream serializers (JSON, OPC UA types) don't apply an unexpected local-time offset.
Tests — HistoricalEventMappingTests (3 new Proxy.Tests unit cases): every field maps correctly from wire to domain; null SourceName and null DisplayText preserve through the mapping (system events without a source come out with null so callers can distinguish them from alarm events); both timestamps come out as DateTimeKind.Utc (regression guard against a future refactor using DateTime.FromFileTimeUtc or similar that defaults to Unspecified). Driver.Galaxy.Proxy.Tests Unit suite: 17 pass / 0 fail (14 prior + 3 new). Full solution build clean, 0 errors.
Scope exclusions — DriverNodeManager HistoryRead service-handler wiring (on the OPC UA Server side, where HistoryReadAtTime and HistoryReadEvents service requests land) and the full-loop integration test (OPC UA client → server → IPC → Host → HistorianDataSource → back) are deferred to a focused follow-up PR. The capability surface is the load-bearing change; wiring the service handlers is mechanical in comparison and worth its own PR for reviewability. docs/v2/lmx-followups.md #1 updated with the split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:08:27 -04:00
2584379e75 Merge pull request 'Phase 3 PR 34 — Host-status publisher (Server) + /hosts drill-down page (Admin)' (#33) from phase-3-pr34-host-status-publisher-page into v2 2026-04-18 16:04:20 -04:00
Joseph Doherty
ef2a810b2d Phase 3 PR 34 — Host-status publisher (Server) + /hosts drill-down page (Admin). Closes LMX follow-up #7 by wiring together the data layer from PR 33. Server.HostStatusPublisher is a BackgroundService that walks every driver registered in DriverHost every 10 seconds, skips drivers that don't implement IHostConnectivityProbe, calls GetHostStatuses() on each probe-capable driver, and upserts one DriverHostStatus row per (NodeId, DriverInstanceId, HostName) into the central config DB. Upsert path: SingleOrDefaultAsync on the composite PK; if no row exists, Add a new one; if a row exists, LastSeenUtc advances unconditionally (heartbeat) and State + StateChangedUtc update only on transitions so Admin UI can distinguish 'still reporting, still Running' from 'freshly transitioned to Running'. MapState translates Core.Abstractions.HostState to Configuration.Enums.DriverHostState (intentional duplicate enum — Configuration project stays free of driver-runtime deps per PR 33's choice). If a driver's GetHostStatuses throws, log warning and skip that driver this tick — never take down the Server on a publisher failure. If the DB is unreachable, log warning + retry next heartbeat (no buffering — next tick's current-state snapshot is more useful than replaying stale transitions after a long outage). 2-second startup delay so NodeBootstrap's RegisterAsync calls land before the first publish tick, then tick runs immediately so a freshly-started Server surfaces its host topology in the Admin UI without waiting a full interval.
Polling chosen over event-driven for initial scope: simpler, matches Admin UI consumer cadence, avoids DriverHost lifecycle-event plumbing that doesn't exist today. Event-driven push for sub-heartbeat latency is a straightforward follow-up.
Admin.Services.HostStatusService left-joins DriverHostStatus against ClusterNode on NodeId so rows persist even when the ClusterNode entry doesn't exist yet (first-boot bootstrap case). StaleThreshold = 30s — covers one missed publisher heartbeat plus a generous buffer for clock skew and GC pauses. Admin Components/Pages/Hosts.razor — FleetAdmin-visible page grouped by cluster (handles the '(unassigned)' case for rows without a matching ClusterNode). Four summary cards (Hosts / Running / Stale / Faulted); per-cluster table with Node / Driver / Host / State + Stale-badge / Last-transition / Last-seen / Detail columns; 10s auto-refresh via IServiceScopeFactory timer pattern matching FleetStatusPoller + Fleet dashboard (PR 27). Row-class highlighting: Faulted → table-danger, Stale → table-warning, else default. State badge maps DriverHostState enum to bootstrap color classes. Sidebar link added between 'Fleet status' and 'Clusters'.
Server csproj adds Microsoft.EntityFrameworkCore.SqlServer 10.0.0 + registers OtOpcUaConfigDbContext in Program.cs scoped via NodeOptions.ConfigDbConnectionString (no Admin-style manual SQL raw — the DbContext is the only access path, keeps migrations owner-of-record).
Tests — HostStatusPublisherTests (4 new Integration cases, uses per-run throwaway DB matching the FleetStatusPollerTests pattern): publisher upserts one row per host from each probe-capable driver and skips non-probe drivers; second tick advances LastSeenUtc without creating duplicate rows (upsert pattern verified end-to-end); state change between ticks updates State AND StateChangedUtc (datetime2(3) rounds to millisecond precision so comparison uses 1ms tolerance — documented inline); MapState translates every HostState enum member. Server.Tests Integration: 4 new tests pass. Admin build clean, Admin.Tests Unit still 23 / 0. docs/v2/lmx-followups.md item #7 marked DONE with three explicit deferred items (event-driven push, failure-count column, SignalR fan-out).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:51:55 -04:00
a7764e50f3 Merge pull request 'Phase 3 PR 33 — DriverHostStatus entity + migration (LMX #7 data layer)' (#32) from phase-3-pr33-driverhoststatus-entity into v2 2026-04-18 15:43:37 -04:00
Joseph Doherty
8464e3f376 Phase 3 PR 33 — DriverHostStatus entity + EF migration (data-layer for LMX #7). New DriverHostStatus entity with composite key (NodeId, DriverInstanceId, HostName) persists each server node's per-host connectivity view — one row per (server node, driver instance, probe-reported host), which means a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6 rows because each server node owns its own runtime view of the shared host topology, not 3. Fields: NodeId (64), DriverInstanceId (64), HostName (256 — fits Galaxy FQDNs and Modbus host:port strings), State (DriverHostState enum — Unknown/Running/Stopped/Faulted, persisted as nvarchar(16) via HasConversion<string> so DBAs inspecting the table see readable state names not ordinals), StateChangedUtc + LastSeenUtc (datetime2(3) — StateChangedUtc tracks actual transitions while LastSeenUtc advances on every publisher heartbeat so the Admin UI can flag stale rows from a crashed Server independent of State), Detail (nullable 1024 — exception message from the driver's probe when Faulted, null otherwise).
DriverHostState enum lives in Configuration.Enums/ rather than reusing Core.Abstractions.HostState so the Configuration project stays free of driver-runtime dependencies (it's referenced by both the Admin process and the Server process, so pulling in the driver-abstractions assembly to every Admin build would be unnecessary weight). The server-side publisher hosted service (follow-up PR 34) will translate HostStatusChangedEventArgs.NewState to this enum on every transition.
No foreign key to ClusterNode — a Server may start reporting host status before its ClusterNode row exists (first-boot bootstrap), and we'd rather keep the status row than drop it. The Admin-side service that renders the dashboard will left-join on NodeId when presenting. Two indexes declared: IX_DriverHostStatus_Node drives the per-cluster drill-down (Admin UI joins ClusterNode on ClusterId to pick which NodeIds to fetch), IX_DriverHostStatus_LastSeen drives the stale-row query (now - LastSeen > threshold).
EF migration AddDriverHostStatus creates the table + PK + both indexes. Model snapshot updated. SchemaComplianceTests expected-tables list extended. DriverHostStatusTests (3 new cases, category SchemaCompliance, uses the shared fixture DB): composite key allows same (host, driver) across different nodes AND same (node, host) across different drivers — both real-world cases the publisher needs to support; upsert-in-place pattern (fetch-by-composite-PK, mutate, save) produces one row not two — the pattern the publisher will use; State enum persists as string not int — reading the DB via ADO.NET returns 'Faulted' not '3'.
Configuration.Tests SchemaCompliance suite: 10 pass / 0 fail (7 prior + 3 new). Configuration build clean. No Server or Admin code changes yet — publisher + /hosts page are PR 34.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:38:41 -04:00
a9357600e7 Merge pull request 'Phase 3 PR 32 — Multi-driver integration test' (#31) from phase-3-pr32-multi-driver-integration into v2 2026-04-18 15:34:16 -04:00
Joseph Doherty
2f00c74bbb Phase 3 PR 32 — Multi-driver integration test. Closes LMX follow-up #6 with Server.Tests/MultipleDriverInstancesIntegrationTests.cs: registers two StubDriver instances (alpha + beta) with distinct DriverInstanceIds on one DriverHost, boots the full OpcUaApplicationHost, and exercises three behaviors end-to-end via a real OPC UA client session. (1) Each driver's namespace URI resolves to a distinct index in the client's NamespaceUris (alpha → urn:OtOpcUa:alpha, beta → urn:OtOpcUa:beta) — proves DriverNodeManager's namespaceUris-per-driver base-ctor wiring actually lands two separate INodeManager registrations. (2) Browsing one subtree returns only that driver's folder; the other driver's folder does NOT leak into the wrong subtree. This is the test that catches a cross-driver routing regression the v1 single-driver code path couldn't surface — if a future refactor flattens both drivers into a shared namespace, the 'shouldNotContain' assertion fails cleanly. (3) Reads route to the owning driver by namespace — alpha's ReadAsync returns 42 while beta's returns 99; a misroute would surface as 99 showing up on an alpha node id or vice versa. StubDriver is parameterized on (DriverInstanceId, folderName, readValue) so the same class constructs both instances without copy-paste.
No production code changes — pure additive test. Server.Tests Integration: 3 new tests pass; existing OpcUaServerIntegrationTests stays green (single-driver case still exercised there). Full Server.Tests Unit still 43 / 0. Deferred: multi-driver alarm-event case (two drivers each raising a GalaxyAlarmEvent, assert each condition lands on its owning instance's condition node) — needs a stub IAlarmSource and is worth its own focused PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:29:49 -04:00
5d5e1f9650 Merge pull request 'Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility' (#30) from phase-3-pr31-live-ldap-ad-compat into v2 2026-04-18 15:27:54 -04:00
Joseph Doherty
4886a5783f Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility. Closes LMX follow-up #4 with 6 live-bind tests in Server.Tests/LdapUserAuthenticatorLiveTests.cs against the dev GLAuth instance at localhost:3893 (skipped cleanly when unreachable via Assert.Skip + a clear SkipReason — matches the GalaxyRepositoryLiveSmokeTests pattern). Coverage: valid credentials bind + surface DisplayName; wrong password fails; unknown user fails; empty credentials fail pre-flight without touching the directory; writeop user's memberOf maps through GroupToRole to WriteOperate (the exact string WriteAuthzPolicy.IsAllowed expects); admin user surfaces all four mapped roles (WriteOperate + WriteTune + WriteConfigure + AlarmAck) proving memberOf parsing doesn't stop after the first match. While wiring this up, the authenticator's hard-coded user-lookup filter 'uid=<name>' didn't match GLAuth (which keys users by cn and doesn't populate uid) — AND it doesn't match Active Directory either, which uses sAMAccountName. Added UserNameAttribute to LdapOptions (default 'uid' for RFC 2307 backcompat) so deployments override to 'cn' / 'sAMAccountName' / 'userPrincipalName' as the directory requires; authenticator filter now interpolates the configured attribute. The default stays 'uid' so existing test fixtures and OpenLDAP installs keep working without a config change — a regression guard in LdapUserAuthenticatorAdCompatTests.LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat pins this so a future 'helpful' default change can't silently break anyone.
Active Directory compatibility. LdapOptions xml-doc expanded with a cheat-sheet covering Server (DC FQDN), Port 389 vs 636, UseTls=true under AD LDAP-signing enforcement, dedicated read-only service account DN, sAMAccountName vs userPrincipalName vs cn trade-offs, memberOf DN shape (CN=Group,OU=...,DC=... with the CN= RDN stripped to become the GroupToRole key), and the explicit 'nested groups NOT expanded' call-out (LDAP_MATCHING_RULE_IN_CHAIN / tokenGroups is a future authenticator enhancement, not a config change). docs/security.md §'Active Directory configuration' adds a complete appsettings.json snippet with realistic AD group names (OPCUA-Operators → WriteOperate, OPCUA-Engineers → WriteConfigure, OPCUA-AlarmAck → AlarmAck, OPCUA-Tuners → WriteTune), LDAPS port 636, TLS on, insecure-LDAP off, and operator-facing notes on each field. LdapUserAuthenticatorAdCompatTests (5 unit guards): ExtractFirstRdnValue parses AD-style 'CN=OPCUA-Operators,OU=...,DC=...' DNs correctly (case-preserving — operators' GroupToRole keys stay readable); also handles mixed case and spaces in group names ('Domain Users'); also works against the OpenLDAP ou=<group>,ou=groups shape (GLAuth) so one extractor tolerates both memberOf formats common in the field; EscapeLdapFilter escapes the RFC 4515 injection set (\, *, (, ), \0) so a malicious login like 'admin)(cn=*' can't break out of the filter; default UserNameAttribute regression guard.
Test posture — Server.Tests Unit: 43 pass / 0 fail (38 prior + 5 new AD-compat guards). Server.Tests LiveLdap category: 6 pass / 0 fail against running GLAuth (would skip cleanly without). Server build clean, 0 errors, 0 warnings.
Deferred: the session-identity end-to-end check (drive a full OPC UA UserName session, then read a 'whoami' node to verify the role landed on RoleBasedIdentity). That needs a test-only address-space node and is scoped for a separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:23:22 -04:00
d70a2e0077 Merge pull request 'Phase 3 PR 30 — Modbus integration-test project scaffold + DL205 smoke test' (#29) from phase-3-pr30-modbus-integration-scaffold into v2 2026-04-18 15:08:45 -04:00
Joseph Doherty
cb7b81a87a Phase 3 PR 30 — Modbus integration-test project scaffold. New tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests project is the harness modbus-test-plan.md called for: a skip-when-unreachable fixture that TCP-probes a Modbus simulator endpoint (MODBUS_SIM_ENDPOINT, default localhost:502) once per test session, a DL205 device profile stub (single writable holding register at address 100, probe disabled to avoid racing with assertions), and one happy-path smoke test that initializes the real ModbusDriver + real ModbusTcpTransport, writes a known Int16 value, reads it back, and asserts status=0 + value round-trip. No DL205 quirk assertions yet — those land one-per-PR as the user validates each behavior in ModbusPal (word order for 32-bit, register-zero access, coil addressing base, max registers per FC03, response framing under load, exception code on protected-bit coil write).
ModbusSimulatorFixture is a collection fixture so the 2s TCP probe runs once per run, not per test; SkipReason gets a clear operator-facing message ('start ModbusPal or override MODBUS_SIM_ENDPOINT'). Tests call Assert.Skip(sim.SkipReason) rather than silently returning — matches the test-plan convention and reads cleanly in CI logs. DL205Profile.BuildOptions deliberately disables the background probe loop since integration tests drive reads explicitly and the probe would race with assertions. Tag naming uses the DL205_ prefix so filter 'DisplayName~DL205' surfaces device-specific failures at a glance.
Project references: xunit.v3 + Shouldly + Microsoft.NET.Test.Sdk + xunit.runner.visualstudio (matches the existing Driver.Modbus.Tests unit project), project ref to src/Driver.Modbus. Registered in ZB.MOM.WW.OtOpcUa.slnx under tests/. ModbusPal/README.md documents the dev loop (install ModbusPal jar, load profile, start simulator, dotnet test), explains MODBUS_SIM_ENDPOINT override for real-PLC benchwork, and flags DL205.xmpp as the first profile to add in a follow-up PR.
dotnet test run against the scaffold (no simulator running) skips cleanly: 0 failed, 0 passed, 1 skipped, with the SkipReason surfaced. dotnet build clean (0 warnings, 0 errors). Updated docs/v2/modbus-test-plan.md to mark the scaffold PR done and renumbered future PRs from 'PR 27+' to 'PR 31+' to stay in sync with the actual PR chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:02:39 -04:00
901d2b8019 Merge pull request 'Phase 3 PR 29 — Account/session page with roles + capabilities' (#28) from phase-3-pr29-account-page into v2 2026-04-18 14:46:45 -04:00
Joseph Doherty
d5fa1f450e Phase 3 PR 29 — Account/session page expanding the minimal sidebar role display into a dedicated /account route. Shows the authenticated operator's identity (username from ClaimTypes.NameIdentifier, display name from ClaimTypes.Name), their Admin roles as badges (from ClaimTypes.Role), the raw LDAP groups that mapped to those roles (from the 'ldap_group' claim added by Login.razor at sign-in), and a capability table listing each Admin capability with its required role and a Yes/No badge showing whether this session has it. Capability list mirrors the Program.cs authorization policies + each page's [Authorize] attribute so operators can self-service check whether their session has access without trial-and-error navigation — capabilities covered: view clusters + fleet status (all roles), edit configuration drafts (ConfigEditor or FleetAdmin per CanEdit policy), publish generations (FleetAdmin per CanPublish policy), manage certificate trust (FleetAdmin per PR 28 Certificates page attribute), manage external-ID reservations (ConfigEditor or FleetAdmin per Reservations page attribute).
Sidebar's 'Signed in as' line now wraps the display name in a link to /account so the existing sidebar-compact view becomes the entry point for the fuller page — keeps the sign-out button where it was for muscle memory, just adds the detail page one click away. Page is gated with [Authorize] (any authenticated admin) rather than a specific role — the capability table deliberately works for every signed-in user so they can see what they DON'T have access to, which helps them file the right ticket with their LDAP admin instead of getting a plain Access Denied when navigating blindly.
Capability → required-role table is defined as a private readonly record list in the page rather than pulled from a service because it's a UI-presentation concern, not runtime policy state — the runtime policy IS Program.cs's AddAuthorizationBuilder + each page's [Authorize] attribute, and this table just mirrors it for operator readability. Comment on the list reminds future-me to extend it when a new policy or [Authorize] page lands. No behavior change if roles are empty, but the page surfaces a hint ('Sign-in would have been blocked, so if you're seeing this, the session claim is likely stale') that nudges the operator toward signing out + back in.
No new tests added — the page is pure display over claims; its only logic is the 'has-capability' Any-overlap check which is exactly what ASP.NET's [Authorize(Roles=...)] does in-framework, and duplicating that in a unit test would test the framework rather than our code. Admin.Tests Unit stays 23 pass / 0 fail. Admin build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:43:35 -04:00
6fdaee3a71 Merge pull request 'Phase 3 PR 28 — Admin UI cert-trust management page' (#27) from phase-3-pr28-cert-trust into v2 2026-04-18 14:42:52 -04:00
Joseph Doherty
ed88835d34 Phase 3 PR 28 — Admin UI cert-trust management page. New /certificates route (FleetAdmin-only) surfaces the OPC UA server's PKI store rejected + trusted certs and gives operators Trust / Delete / Revoke actions so rejected client certs can be promoted without touching disk. CertTrustService reads $PkiStoreRoot/{rejected,trusted}/certs/*.der files directly via X509CertificateLoader — no Opc.Ua dependency in the Admin project, which keeps the Admin host runnable on a machine that doesn't have the full Server install locally (only needs the shared PKI directory reachable; typical deployment has Admin + Server side-by-side on the same box and PkiStoreRoot defaults match so a plain-vanilla install needs no override). CertTrustOptions bound from the Admin's 'CertTrust:PkiStoreRoot' section, default %ProgramData%\OtOpcUa\pki (matches OpcUaServerOptions.PkiStoreRoot default). Trust action moves the .der from rejected/certs/ to trusted/certs/ via File.Move(overwrite:true) — idempotent, tolerates a concurrent operator doing the same move. Delete wipes the file. Revoke removes from trusted/certs/ (Opc.Ua re-reads the Directory store on each new client handshake, so no explicit reload signal is needed; operators retry the rejected connection after trusting). Thumbprint matching is case-insensitive because X509Certificate2.Thumbprint is upper-case hex but operators copy-paste from logs that sometimes lowercase it. Malformed files in the store are logged + skipped — a single bad .der can't take the whole management page offline. Missing store directories produce empty lists rather than exceptions so a pristine install (Server never run yet, no rejected/trusted dirs yet) doesn't crash the page.
Razor page layout: two tables (Rejected / Trusted) with Subject / Issuer / Thumbprint / Valid-window / Actions columns, status banner after each action with success or warning kind ('file missing' = another admin handled it), FleetAdmin-only via [Authorize(Roles=AdminRoles.FleetAdmin)]. Each action invokes LogActionAsync which Serilog-logs the authenticated admin user + thumbprint + action for an audit trail — DB-level ConfigAuditLog persistence is deferred because its schema is cluster-scoped and cert actions are cluster-agnostic; Serilog + CertTrustService's filesystem-op info logs give the forensic trail in the meantime. Sidebar link added to MainLayout between Reservations and the future Account page.
Tests — CertTrustServiceTests (9 new unit cases): ListRejected parses Subject + Thumbprint + store kind from a self-signed test cert written into rejected/certs/; rejected and trusted stores are kept separate; TrustRejected moves the file and the Rejected list is empty afterwards; TrustRejected with a thumbprint not in rejected returns false without touching trusted; DeleteRejected removes the file; UntrustCert removes from trusted only; thumbprint match is case-insensitive (operator UX); missing store directories produce empty lists instead of throwing DirectoryNotFoundException (pristine-install tolerance); a junk .der in the store is logged + skipped and the valid certs still surface (one bad file doesn't break the page). Full Admin.Tests Unit suite: 23 pass / 0 fail (14 prior + 9 new). Full Admin build clean — 0 errors, 0 warnings.
lmx-followups.md #3 marked DONE with a cross-reference to this PR and a note that flipping AutoAcceptUntrustedClientCertificates to false as the production default is a deployment-config follow-up, not a code gap — the Admin UI is now ready to be the trust gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:37:55 -04:00
5389d4d22d Phase 3 PR 27 � Fleet status dashboard page (#26) 2026-04-18 14:07:16 -04:00
Joseph Doherty
b5f8661e98 Phase 3 PR 27 — Fleet status dashboard page. New /fleet route shows per-node apply state (ClusterNodeGenerationState joined with ClusterNode for the ClusterId) in a sortable table with summary cards for Total / Applied / Stale / Failed node counts. Stale detection: LastSeenAt older than 30s triggers a table-warning row class + yellow count card. Failed rows get table-danger + red card. Badge classes per LastAppliedStatus: Applied=bg-success, Failed=bg-danger, Applying=bg-info, unknown=bg-secondary. Timestamps rendered as relative-age strings ('42s ago', '15m ago', '3h ago', then absolute date for >24h). Error column is truncated to 320px with the full message in a tooltip so the table stays readable on wide fleets. Initial data load on OnInitializedAsync; auto-refresh every 5s via a Timer that calls InvokeAsync(RefreshAsync) — matches the FleetStatusPoller's 5s cadence so the dashboard sees the most recent state without polling ahead of the broadcaster. A Refresh button also kicks a manual reload; _refreshing gate prevents double-runs when the timer fires during an in-flight query. IServiceScopeFactory (matches FleetStatusPoller's pattern) creates a fresh DI scope per refresh so the per-page DbContext can't race the timer with the render thread; no new DI registrations needed. Live SignalR hub push is deliberately deferred to a follow-up PR — the existing FleetStatusHub + NodeStateChangedMessage already works for external JavaScript clients; wiring an in-process Blazor Server consumer adds HubConnectionBuilder plumbing that's worth its own focused change. Sidebar link added to MainLayout between Overview and Clusters. Full Admin.Tests Unit suite 14 pass / 0 fail — unchanged, no tests regressed. Full Admin build clean (0 errors, 0 warnings). Closes the 'no per-driver dashboard' gap from lmx-followups item #7 at the fleet level; per-host (platform/engine/Modbus PLC) granularity still needs a dedicated page that consumes IHostConnectivityProbe.GetHostStatuses from the Server process — that's the live-SignalR follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:12:31 -04:00
4058b88784 Merge pull request 'Phase 3 PR 26 — server-layer write authorization by role' (#25) from phase-3-pr26-server-write-authz into v2 2026-04-18 13:04:35 -04:00
Joseph Doherty
6b04a85f86 Phase 3 PR 26 — server-layer write authorization gating by role. Per the user's ACL-at-server-layer directive (saved as feedback_acl_at_server_layer.md in memory), write authorization is enforced in DriverNodeManager.OnWriteValue and never delegated to the driver or to driver-specific auth (the v1 Galaxy-provided security path is explicitly not part of v2 — drivers report SecurityClassification as discovery metadata only). New WriteAuthzPolicy static class in Server/Security/ maps SecurityClassification → required role per the table documented in docs/Configuration.md: FreeAccess = no role required (anonymous sessions can write), Operate + SecuredWrite = WriteOperate, Tune = WriteTune, VerifiedWrite + Configure = WriteConfigure, ViewOnly = deny regardless of roles. Role matching is case-insensitive and role requirements do NOT cascade — a session with WriteConfigure can write Configure attributes but needs WriteOperate separately to write Operate attributes; this is deliberate so escalation is an explicit LDAP group assignment, not a hierarchy the policy silently grants. DriverNodeManager gains a _securityByFullRef Dictionary populated during Variable() registration (parallel to the existing _variablesByFullRef) so OnWriteValue can look up the classification in O(1) on the hot path. OnWriteValue casts the session's context.UserIdentity to the new IRoleBearer interface (implemented by OtOpcUaServer.RoleBasedIdentity from PR 19) — empty Roles collection when the session is anonymous; the same WriteAuthzPolicy.IsAllowed check then either short-circuits true (FreeAccess), false (ViewOnly), or walks the roles list looking for the required one. On deny, OnWriteValue logs 'Write denied for {FullRef}: classification=X userRoles=[...]' at Information level (readable trail for operator complaints) and returns BadUserAccessDenied without touching IWritable.WriteAsync — drivers never see a request we'd have refused. IRoleBearer kept as a minimal server-side interface rather than reusing some abstraction from Core.Abstractions because the concept is OPC-UA-session-scoped and doesn't generalize (the driver side has no notion of a user session). Tests — WriteAuthzPolicyTests (17 new cases): FreeAccess allows write with empty role set + arbitrary roles; ViewOnly denies write even with every role; Operate requires WriteOperate; role match is case-insensitive; Operate denies empty role set + wrong role; SecuredWrite shares Operate's requirement; Tune requires WriteTune; Tune denies WriteOperate-only (asserts roles don't cascade — this is the test that catches a future regression where someone 'helpfully' adds a role-escalation table); Configure requires WriteConfigure; VerifiedWrite shares Configure's requirement; multi-role session allowed when any role matches; unrelated roles denied; RequiredRole theory covering all 5 classified-and-mapped rows + null for FreeAccess/ViewOnly special cases. lmx-followups.md follow-up #2 marked DONE with a back-reference to this PR and the memory note. Full Server.Tests Unit suite: 38 pass / 0 fail (17 new WriteAuthz + 14 SecurityConfiguration from PR 19 + 2 NodeBootstrap + 5 others). Server.Tests Integration (Category=Integration) 2 pass — existing PR 17 anonymous-endpoint smoke tests stay green since the read path doesn't hit OnWriteValue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:01:01 -04:00
cd8691280a Merge pull request 'Phase 3 PR 25 — Modbus test plan + DL205 quirk catalog' (#24) from phase-3-pr25-modbus-test-plan into v2 2026-04-18 12:49:19 -04:00
Joseph Doherty
77d09bf64e Phase 3 PR 25 — modbus-test-plan.md: integration-test playbook with per-device quirk catalog. ModbusPal is the chosen simulator; AutomationDirect DL205 is the first target device class with 6 pending quirks to document and cover with named tests (word order for 32-bit values, register-zero access policy, coil addressing base, maximum registers per FC03, response framing under sustained load, exception code on protected-bit coil write). Each quirk placeholder has a proposed test name so the user's validation work translates directly into integration tests. Test conventions section codifies the named-per-quirk pattern, skip-when-unreachable guard, real ModbusTcpTransport usage, and inter-test isolation. Sets up the harness-and-catalog structure future device families (Allen-Bradley Micrologix, Siemens S7-1200 Modbus gateway, Schneider M340, whatever the user hits) will slot into — same per-device catalog shape, cross-device patterns section for recurring quirks that can get promoted into driver defaults. Next concrete PRs proposed: PR 26 for the integration test project scaffold + DL205 profile + fixture with skip-guard + one smoke test, PR 27+ for the individual confirmed quirks one-per-PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:45:21 -04:00
163c821e74 Merge pull request 'Phase 3 PR 24 — Modbus PLC data type extensions' (#23) from phase-3-pr24-modbus-types into v2 2026-04-18 12:32:55 -04:00
Joseph Doherty
eea31dcc4e Phase 3 PR 24 — Modbus PLC data type extensions. Extends ModbusDataType beyond the textbook Int16/UInt16/Int32/UInt32/Float32 set with Int64/UInt64/Float64 (4-register types), BitInRegister (single bit within a holding register, BitIndex 0-15 LSB-first), and String (ASCII packed 2 chars per register with StringLength-driven sizing). Adds ModbusByteOrder enum on ModbusTagDefinition covering the two word-orderings that matter in the real PLC population: BigEndian (ABCD — Modbus TCP standard, Schneider PLCs that follow it strictly) and WordSwap (CDAB — Siemens S7 family, several Allen-Bradley series, some Modicon families). NormalizeWordOrder helper reverses word pairs in-place for 32-bit values and reverses all four words for 64-bit values (keeps bytes big-endian within each register, which is universal; swaps only the word positions). Internal codec surface switched from (bytes, ModbusDataType) pairs to (bytes, ModbusTagDefinition) because the tag carries the ByteOrder + BitIndex + StringLength context the codec needs; RegisterCount similarly takes the tag so strings can compute ceil(StringLength/2). DriverDataType mapping in MapDataType extended to cover the new logical types — Int64/UInt64 widen to Int32 (PR 25 follow-up: extend DriverDataType enum with Int64 to avoid precision loss), Float64 maps to DriverDataType.Float64, String maps to DriverDataType.String, BitInRegister surfaces as Boolean, all other mappings preserved. BitInRegister writes throw a deliberate InvalidOperationException with a 'read-modify-write' hint — to atomically flip a single bit the driver needs to FC03 the register, OR/AND in the bit, then FC06 it back; that's a separate PR because the bit-modify atomicity story needs a per-register mutex and optional compare-and-write semantics. Everything else (decoder paths for both byte orders, Int64/UInt64/Float64 encode + decode, bit-index extraction across both register halves, String nul-truncation on decode, String nul-padding on encode) ships here. Tests (21 new ModbusDataTypeTests): RegisterCount_returns_correct_register_count_per_type theory (10 rows covering every numeric type); RegisterCount_for_String_rounds_up_to_register_pair theory (5 rows including the 0-char edge case that returns 0 registers); Int32_BigEndian_decodes_ABCD_layout + Int32_WordSwap_decodes_CDAB_layout + Float32_WordSwap_encode_decode_roundtrips (covers the two most-common 32-bit orderings); Int64_BigEndian_roundtrips + UInt64_WordSwap_reverses_four_words (word-swap on 64-bit reverses the four-word layout explicitly, with the test computing the expected wire shape by hand rather than trusting the implementation) + Float64_roundtrips_under_word_swap (3.14159265358979 survives the round-trip with 1e-12 tolerance); BitInRegister_extracts_bit_at_index theory (6 rows including LSB, MSB, and arbitrary bits in a multi-bit mask); BitInRegister_write_is_not_supported_in_PR24 (asserts the exception message steers the reader to the 'read-modify-write' follow-up); String_decodes_ASCII_packed_two_chars_per_register (decodes 'HELLO!' from 3 packed registers with the 'HELLO!'u8 test-only UTF-8 literal which happens to equal the ASCII bytes for this ASCII input); String_decode_truncates_at_first_nul ('Hi' padded with nuls reads back as 'Hi'); String_encode_nul_pads_remaining_bytes (short input writes remaining bytes as 0). Full solution: 0 errors, 217 unit + integration tests pass (22 + 30 new Modbus = 52 Modbus total, 165 pre-existing). ModbusDriver capability footprint now matches the most common industrial PLC workloads — Siemens S7 + Allen-Bradley + Modicon all supported via ByteOrder config without driver forks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:27:12 -04:00
8a692d4ba8 Merge pull request 'Phase 3 PR 23 — Modbus IHostConnectivityProbe' (#22) from phase-3-pr23-modbus-probe into v2 2026-04-18 12:23:04 -04:00
Joseph Doherty
268b12edec Phase 3 PR 23 — Modbus IHostConnectivityProbe. ModbusDriver now implements 6 of 8 capability interfaces (adds IHostConnectivityProbe alongside IDriver + ITagDiscovery + IReadable + IWritable + ISubscribable from the earlier PRs). Background probe loop kicks off in InitializeAsync when ModbusProbeOptions.Enabled is true, sends a cheap FC03 read-1-register at ProbeAddress (default 0) every Interval (default 5s) with a per-tick Timeout (default 2s), and tracks the single Modbus endpoint's state in the HostState machine. Initial state = Unknown; first successful probe transitions to Running; any transport/timeout failure transitions to Stopped; recovery transitions back to Running. OnHostStatusChanged fires exactly on transitions (not on repeat successes — prevents event-spam on a healthy connection). HostName format is 'host:port' so the Admin UI can display the endpoint uniformly with Galaxy platforms/engines in the fleet status dashboard. GetHostStatuses returns a single-item list with the current state + last-change timestamp (Modbus driver talks to exactly one endpoint per instance — operators spin up multiple driver instances for multi-PLC deployments). ShutdownAsync cancels the probe CTS before tearing down the transport so the loop can't log a spurious Stopped after intentional shutdown (OperationCanceledException caught separately from the 'real' transport errors). ModbusDriverOptions extended with ModbusProbeOptions sub-record (Enabled default true, Interval 5s, Timeout 2s, ProbeAddress ushort for PLCs that have register-0 policies; most PLCs tolerate an FC03 at 0 but some industrial gateways lock it). Tests (7 new ModbusProbeTests): Initial_state_is_Unknown_before_first_probe_tick (probe disabled, state stays Unknown, HostName formatted); First_successful_probe_transitions_to_Running (enabled, waits for probe count + event queue, asserts Unknown → Running with correct OldState/NewState); Transport_failure_transitions_to_Stopped (flip fake.Reachable = false mid-run, wait for state diff); Recovery_transitions_Stopped_back_to_Running (up → down → up, asserts ≥ 3 transitions); Repeated_successful_probes_do_not_generate_duplicate_Running_events (several hundred ms of stable probes, count stays at 1); Disabled_probe_stays_Unknown_and_fires_no_events (safety guard when operator wants to disable probing); Shutdown_stops_the_probe_loop (probe count captured at shutdown, delay 400ms, assert ≤ 1 extra to tolerate the narrow race where an in-flight tick completes after shutdown — the contract is 'no new ticks scheduled' not 'instantaneous freeze'). FlappyTransport fake exposes a volatile Reachable flag so tests can toggle the PLC availability mid-run, + ProbeCount counter so tests can assert the loop actually issued requests. WaitForStateAsync helper polls GetHostStatuses up to a deadline; tolerates scheduler jitter on slow CI runners. Full solution: 0 errors, 202 unit + integration tests pass (22 Modbus + 180 pre-existing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:12:00 -04:00
edce1be742 Merge pull request 'Phase 3 PR 22 — Modbus ISubscribable via polling overlay' (#21) from phase-3-pr22-modbus-subscribe into v2 2026-04-18 12:07:51 -04:00
Joseph Doherty
18b3e24710 Phase 3 PR 22 — Modbus ISubscribable via polling overlay. Modbus has no push model at the wire protocol (unlike MXAccess's OnDataChange callback or OPC UA's own Subscription service), so the driver layers a per-subscription polling loop on top of the existing IReadable path: SubscribeAsync returns an opaque ModbusSubscriptionHandle, starts a background Task.Run that sleeps for the requested publishing interval (floored to 100ms so a misconfigured sub-10ms request doesn't hammer the PLC), reads every subscribed tag through the same FC01/03/04 path the one-shot ReadAsync uses, diffs the returned DataValueSnapshot against the last known value per tag, and raises OnDataChange exactly when (a) it's the first poll (initial-data push per OPC UA Part 4 convention) or (b) boxed value changed or (c) StatusCode changed — stable values don't generate event traffic after the initial push, matching the v1 MXAccess OnDataChange shape. SubscriptionState record holds the handle + tag list + interval + per-subscription CancellationTokenSource + ConcurrentDictionary<string,DataValueSnapshot> LastValues; UnsubscribeAsync removes the state from _subscriptions and cancels the CTS, stopping the polling loop cleanly. Multiple overlapping subscriptions each get their own polling Task so a slow PLC on one subscription can't stall the others. ShutdownAsync cancels every active subscription CTS before tearing down the transport so the driver doesn't leave orphaned polling tasks pumping requests against a disposed socket. Transient poll errors are swallowed inside the loop (the loop continues to the next tick) — the driver's health surface reflects the last-known Degraded state from the underlying ReadAsync path. OperationCanceledException is caught separately to exit the loop silently on unsubscribe/shutdown. Tests (6 new ModbusSubscriptionTests): Initial_poll_raises_OnDataChange_for_every_subscribed_tag asserts the initial-data push fires once per tag in the subscribe call (2 tags → 2 events with FullReference='Level' and FullReference='Temp'); Unchanged_values_do_not_raise_after_initial_poll lets the loop run ~5 cycles at 100ms with a stable register value, asserts only the initial push fires (no event spam on stable tags); Value_change_between_polls_raises_OnDataChange mutates the fake register bank between poll ticks and asserts a second event fires with the new value (verified via e.Snapshot.Value.ShouldBe((short)200)); Unsubscribe_stops_the_polling_loop captures the event count right after UnsubscribeAsync, mutates a register that would have triggered a change if polling continued, asserts the count stays the same after 400ms; SubscribeAsync_floors_intervals_below_100ms passes a 10ms interval + asserts only 1 event fires across 300ms (if the floor weren't enforced we'd see 30 — the test asserts the floor semantically by counting events on stable data); Multiple_subscriptions_fire_independently creates two subs on different tags, unsubscribes only one, mutates the other's tag, asserts only the surviving sub emits while the unsubscribed one stays at its pre-unsubscribe count. FakeTransport in this test file is scoped to FC03 only since that's all the subscription path exercises — keeps the test doubles minimal and the failure modes obvious. WaitForCountAsync helper polls a ConcurrentQueue up to a deadline, makes the tests tolerant of scheduler jitter on slow CI runners. Full solution: 0 errors, 195 tests pass (6 new subscription + 9 existing Modbus + 180 pre-existing). ModbusDriver now implements IDriver + ITagDiscovery + IReadable + IWritable + ISubscribable — five of the eight capability interfaces. IAlarmSource + IHistoryProvider remain unimplemented because Modbus has no wire-level alarm or history semantics; IHostConnectivityProbe is a plausible future addition (treat transport disconnect as a Stopped signal) but needs the socket-level connection-state tracking plumbed through IModbusTransport which is its own PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:03:39 -04:00
f6a12dafe9 Merge pull request 'Phase 3 PR 21 — Modbus TCP driver (first native-protocol greenfield)' (#20) from phase-3-pr21-modbus-driver into v2 2026-04-18 11:58:20 -04:00
Joseph Doherty
058c3dddd3 Phase 3 PR 21 — Modbus TCP driver: first native-protocol greenfield for v2. New src/Driver.Modbus project with ModbusDriver implementing IDriver + ITagDiscovery + IReadable + IWritable. Validates the driver-agnostic abstractions (IAddressSpaceBuilder, DriverAttributeInfo, DataValueSnapshot, WriteRequest/WriteResult) generalize beyond Galaxy — nothing Galaxy-specific is used here. ModbusDriverOptions carries Host/Port/UnitId/Timeout + a pre-declared tag list (Modbus has no discovery protocol — tags are configuration). IModbusTransport abstracts the socket layer so tests swap in-memory fakes; concrete ModbusTcpTransport speaks the MBAP ADU (TxId + Protocol=0 + Length + UnitId + PDU) over TcpClient, serializes requests through a semaphore for single-flight in-order responses, validates the response TxId matches, surfaces server exception PDUs as ModbusException with function code + exception code. DiscoverAsync streams one folder per driver with a BaseDataVariable per tag + DriverAttributeInfo that flags writable tags as SecurityClassification.Operate vs ViewOnly for read-only regions. ReadAsync routes per-tag by ModbusRegion: FC01 for Coils, FC02 for DiscreteInputs, FC03 for HoldingRegisters, FC04 for InputRegisters; register values decoded through System.Buffers.Binary.BinaryPrimitives (BigEndian for single-register Int16/UInt16 + two-register Int32/UInt32/Float32 per standard modbus word-swap conventions). WriteAsync uses FC05 (Write Single Coil with 0xFF00/0x0000 encoding) for booleans, FC06 (Write Single Register) for 16-bit types, FC16 (Write Multiple Registers) for 32-bit types. Unknown tag → BadNodeIdUnknown; write to InputRegister or DiscreteInput or Writable=false tag → BadNotWritable; exception during transport → BadInternalError + driver health Degraded. Subscriptions + Historian + Alarms deliberately out of scope — Modbus has no push model (subscribe would be a polling overlay, additive PR) and no history/alarm semantics at the protocol level. Tests (9 new ModbusDriverTests): InitializeAsync connects + populates the tag map + sets health=Healthy; Read Int16 from HoldingRegister returns BigEndian value; Read Float32 spans two registers BigEndian (IEEE 754 single for 25.5f round-trips exactly); Read Coil returns boolean from the bit-packed response; unknown tag name returns BadNodeIdUnknown without an exception; Write UInt16 round-trips via FC06; Write Float32 uses FC16 (two-register write verified by decoding back through the fake register bank); Write to InputRegister returns BadNotWritable; Discover streams one folder + one variable per tag with correct DriverDataType mapping (Int16/Int32→Int32, UInt16/UInt32→Int32, Float32→Float32, Bool→Boolean). FakeTransport simulates a 256-register/256-coil bank + implements the 7 function codes the driver uses. slnx updated with the new src + tests entries. Full solution post-add: 0 errors, 189 tests pass (9 Modbus + 180 pre-existing). IDriver abstraction validated against a fundamentally different protocol — Modbus TCP has no AlarmExtension, no ScanState, no IPC boundary, no historian, no LDAP — and the same builder/reader/writer contract plugged straight in. Future PRs on this driver: ISubscribable via a polling loop, IHostConnectivityProbe for dead-device detection, PLC-specific data-type extensions (Int64/BCD/string-in-registers).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:55:21 -04:00
52791952dd Merge pull request 'Phase 3 PR 20 — lmx-followups.md' (#19) from phase-3-pr20-lmx-followups into v2 2026-04-18 11:50:38 -04:00
Joseph Doherty
860deb8e0d Phase 3 PR 20 — lmx-followups.md: track remaining Galaxy-bridge tasks after PR 19 (HistoryReadAtTime/Events Proxy wiring, write-gating by role, Admin UI cert trust, live-LDAP integration test, full-stack Galaxy smoke, multi-driver test, per-host dashboard). Documents what each item depends on, the shipped surface it builds on, and the minimal to-do so a future session can pick any one off in isolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 11:43:15 -04:00
f5e7173de3 Merge pull request 'Phase 3 PR 19 — LDAP user identity + Basic256Sha256 security profile' (#18) from phase-3-pr19-ldap-security into v2 2026-04-18 11:36:18 -04:00
Joseph Doherty
22d3b0d23c Phase 3 PR 19 — LDAP user identity + Basic256Sha256 security profile. Replaces the anonymous-only endpoint with a configurable security profile and an LDAP-backed UserName token validator. New IUserAuthenticator abstraction in Backend/Security/: LdapUserAuthenticator binds to the configured directory (reuses the pattern from Admin.Security.LdapAuthService without the cross-app dependency — Novell.Directory.Ldap.NETStandard 3.6.0 package ref added to Server alongside the existing OPCFoundation packages) and maps group membership to OPC UA roles via LdapOptions.GroupToRole (case-insensitive). DenyAllUserAuthenticator is the default when Ldap.Enabled=false so UserName token attempts return a clean BadUserAccessDenied rather than hanging on a localhost:3893 bind attempt. OpcUaSecurityProfile enum + LdapOptions nested record on OpcUaServerOptions. Profile=None keeps the PR 17 shape (SecurityPolicies.None + Anonymous token only) so existing integration tests stay green; Profile=Basic256Sha256SignAndEncrypt adds a second ServerSecurityPolicy (Basic256Sha256 + SignAndEncrypt) to the collection and, when Ldap.Enabled=true, adds a UserName token policy scoped to SecurityPolicies.Basic256Sha256 only — passwords must ride an encrypted channel, the stack rejects UserName over None. OtOpcUaServer.OnServerStarted hooks SessionManager.ImpersonateUser: AnonymousIdentityToken passes through; UserNameIdentityToken delegates to IUserAuthenticator.AuthenticateAsync — rejected identities throw ServiceResultException(BadUserAccessDenied); accepted identities get a RoleBasedIdentity that carries the resolved roles through session.Identity so future PRs can gate writes by role. OpcUaApplicationHost + OtOpcUaServer constructors take IUserAuthenticator as a dependency. Program.cs binds the new OpcUaServer:Ldap section from appsettings (Enabled defaults false, GroupToRole parsed as Dictionary<string,string>), registers IUserAuthenticator as LdapUserAuthenticator when enabled or DenyAllUserAuthenticator otherwise. PR 17 integration test updated to pass DenyAllUserAuthenticator so it keeps exercising the anonymous-only path unchanged. Tests — SecurityConfigurationTests (new, 13 cases): DenyAllAuthenticator rejects every credential; LdapAuthenticator rejects blank creds without hitting the server; rejects when Enabled=false; rejects plaintext when both UseTls=false AND AllowInsecureLdap=false (safety guard matching the Admin service); EscapeLdapFilter theory (4 rows: plain passthrough, parens/asterisk/backslash → hex escape) — regression guard against LDAP injection; ExtractOuSegment theory (3 rows: finds ou=, returns null when absent, handles multiple ou segments by returning first); ExtractFirstRdnValue theory (3 rows: strips cn= prefix, handles single-segment DN, returns plain string unchanged when no =). OpcUaServerOptions_default_is_anonymous_only asserts the default posture preserves PR 17 behavior. InternalsVisibleTo('ZB.MOM.WW.OtOpcUa.Server.Tests') added to Server csproj so ExtractOuSegment and siblings are reachable from the tests. Full solution: 0 errors, 180 tests pass (8 Core + 14 Proxy + 24 Configuration + 6 Shared + 91 Galaxy.Host + 19 Server (17 unit + 2 integration) + 18 Admin). Live-LDAP integration test (connect via Basic256Sha256 endpoint with a real user from GLAuth, assert the session.Identity carries the mapped role) is deferred to a follow-up — it requires the GLAuth dev instance to be running at localhost:3893 which is dev-machine-specific, and the test harness for that also needs a fresh client-side certificate provisioned by the live server's trusted store.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:49:46 -04:00
55696a8750 Merge pull request 'Phase 3 PR 18 — delete v1 archived projects' (#17) from phase-3-pr18-delete-v1 into v2 2026-04-18 08:41:56 -04:00
Joseph Doherty
dd3a449308 Phase 3 PR 18 — delete v1 archived projects. PR 2 archived via IsTestProject=false + PropertyGroup comment; PR 17 landed the full v2 OPC UA server runtime (ApplicationConfiguration + endpoint + client integration test); every v1 surface is now functionally superseded. This PR removes the archive: 154 files across 5 projects — src/OtOpcUa.Host (v1 server, 158 files), src/Historian.Aveva (v1 historian plugin, 4 files), tests/OtOpcUa.Tests.v1Archive (494 unit tests that were archived in PR 2 with IsTestProject=false), tests/Historian.Aveva.Tests (18 tests against the v1 plugin), tests/OtOpcUa.IntegrationTests (6 tests against the v1 Host). slnx trimmed to reflect the current set (12 src + 12 tests). Verified zero incoming references from live projects before deleting — no live csproj references .Host or .Historian.Aveva since PR 5 ported Historian into Driver.Galaxy.Host/Backend/Historian/ and PR 17 stood up the new OtOpcUa.Server. Full solution post-delete: 0 errors, 165 unit + integration tests pass (8 Core + 14 Proxy + 24 Configuration + 91 Galaxy.Host + 6 Shared + 4 Server + 18 Admin) — no regressions. Recovery path if a future PR needs to resurrect a specific v1 routine: git revert this commit or cherry-pick the specific file from pre-delete history; v1 is preserved in the full branch history, not lost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:35:22 -04:00
3c1dc334f9 Merge pull request 'Phase 3 PR 17 — complete OPC UA server startup + live integration test' (#16) from phase-3-pr17-server-startup into v2 2026-04-18 08:28:42 -04:00
Joseph Doherty
46834a43bd Phase 3 PR 17 — complete OPC UA server startup end-to-end + integration test. PR 16 shipped the materialization shape (DriverNodeManager / OtOpcUaServer) without the activation glue; this PR finishes the scope so an external OPC UA client can actually connect, browse, and read. New OpcUaServerOptions DTO bound from the OpcUaServer section of appsettings.json (EndpointUrl default opc.tcp://0.0.0.0:4840/OtOpcUa, ApplicationName, ApplicationUri, PkiStoreRoot default %ProgramData%\OtOpcUa\pki, AutoAcceptUntrustedClientCertificates default true for dev — production flips via config). OpcUaApplicationHost wraps Opc.Ua.Configuration.ApplicationInstance: BuildConfiguration constructs the ApplicationConfiguration programmatically (no external XML) with SecurityConfiguration pointing at <PkiStoreRoot>/own, /issuers, /trusted, /rejected directories — stack auto-creates the cert folders on first run and generates a self-signed application certificate via CheckApplicationInstanceCertificate, ServerConfiguration.BaseAddresses set to the endpoint URL + SecurityPolicies just None + UserTokenPolicies just Anonymous with PolicyId='Anonymous' + SecurityPolicyUri=None so the client's UserTokenPolicy lookup succeeds at OpenSession, TransportQuotas.OperationTimeout=15s + MinRequestThreadCount=5 / MaxRequestThreadCount=100 / MaxQueuedRequestCount=200, CertificateValidator auto-accepts untrusted when configured. StartAsync creates the OtOpcUaServer (passes DriverHost + ILoggerFactory so one DriverNodeManager is created per registered driver in CreateMasterNodeManager from PR 16), calls ApplicationInstance.Start(server) to bind the endpoint, then walks each DriverNodeManager and drives a fresh GenericDriverNodeManager.BuildAddressSpaceAsync against it so the driver's discovery streams into the address space that's already serving clients. Per-driver discovery is isolated per decision #12: a discovery exception marks the driver's subtree faulted but the server stays up serving the other drivers' subtrees. DriverHost.GetDriver(instanceId) public accessor added alongside the existing GetHealth so OtOpcUaServer can enumerate drivers during CreateMasterNodeManager. DriverNodeManager.Driver property made public so OpcUaApplicationHost can identify which driver each node manager wraps during the discovery loop. OpcUaServerService constructor takes OpcUaApplicationHost — ExecuteAsync sequence now: bootstrap.LoadCurrentGenerationAsync → applicationHost.StartAsync → infinite Task.Delay until stop. StopAsync disposes the application host (which stops the server via OtOpcUaServer.Stop) before disposing DriverHost. Program.cs binds OpcUaServerOptions from appsettings + registers OpcUaApplicationHost + OpcUaServerOptions as singletons. Integration test (OpcUaServerIntegrationTests, Category=Integration): IAsyncLifetime spins up the server on a random non-default port (48400+random for test isolation) with a per-test-run PKI store root (%temp%/otopcua-test-<guid>) + a FakeDriver registered in DriverHost that has ITagDiscovery + IReadable implementations — DiscoverAsync registers TestFolder>Var1, ReadAsync returns 42. Client_can_connect_and_browse_driver_subtree creates an in-process OPC UA client session via CoreClientUtils.SelectEndpoint (which talks to the running server's GetEndpoints and fetches the live EndpointDescription with the actual PolicyId), browses the fake driver's root, asserts TestFolder appears in the returned references. Client_can_read_a_driver_variable_through_the_node_manager constructs the variable NodeId using the namespace index the server registered (urn:OtOpcUa:fake), calls Session.ReadValue, asserts the DataValue.Value is 42 — the whole pipeline (client → server endpoint → DriverNodeManager.OnReadValue → FakeDriver.ReadAsync → back through the node manager → response to client) round-trips correctly. Dispose tears down the session, server, driver host, and PKI store directory. Full solution: 0 errors, 165 tests pass (8 Core unit + 14 Proxy unit + 24 Configuration unit + 6 Shared unit + 91 Galaxy.Host unit + 4 Server (2 unit NodeBootstrap + 2 new integration) + 18 Admin). End-to-end outcome: PR 14's GalaxyAlarmTracker alarm events now flow through PR 15's GenericDriverNodeManager event forwarder → PR 16's ConditionSink → OPC UA AlarmConditionState.ReportEvent → out to every OPC UA client subscribed to the alarm condition. The full alarm subsystem (driver-side subscription of the Galaxy 4-attribute quartet, Core-side routing by source node id, Server-side AlarmConditionState materialization with ReportEvent dispatch) is now complete and observable through any compliant OPC UA client. LDAP / security-profile wire-up (replacing the anonymous-only endpoint with BasicSignAndEncrypt + user identity mapping to NodePermissions role) is the next layer — it reuses the same ApplicationConfiguration plumbing this PR introduces but needs a deployment-policy source (central config DB) for the cert trust decisions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:18:37 -04:00
7683b94287 Merge pull request 'Phase 3 PR 16 — concrete OPC UA server scaffolding + AlarmConditionState materialization' (#15) from phase-3-pr16-opcua-server into v2 2026-04-18 08:10:44 -04:00
Joseph Doherty
f53c39a598 Phase 3 PR 16 — concrete OPC UA server scaffolding with AlarmConditionState materialization. Introduces the OPCFoundation.NetStandard.Opc.Ua.Server package (v1.5.374.126, same version the v1 stack already uses) and two new server-side classes: DriverNodeManager : CustomNodeManager2 is the concrete realization of PR 15's IAddressSpaceBuilder contract — Folder() creates FolderState nodes under an Organizes hierarchy rooted at ObjectsFolder > DriverInstanceId; Variable() creates BaseDataVariableState with DataType mapped from DriverDataType (Boolean/Int32/Float/Double/String/DateTime) + ValueRank (Scalar or OneDimension) + AccessLevel CurrentReadOrWrite; AddProperty() creates PropertyState with HasProperty reference. Read hook wires OnReadValue per variable to route to IReadable.ReadAsync; Write hook wires OnWriteValue to route to IWritable.WriteAsync and surface per-tag StatusCode. MarkAsAlarmCondition() materializes an OPC UA AlarmConditionState child of the variable, seeded from AlarmConditionInfo (SourceName, InitialSeverity → UA severity via Low=250/Medium=500/High=700/Critical=900, InitialDescription), initial state Enabled + Acknowledged + Inactive + Retain=false. Returns an IAlarmConditionSink whose OnTransition updates alarm.Severity/Time/Message and switches state per AlarmType string ('Active' → SetActiveState(true) + SetAcknowledgedState(false) + Retain=true; 'Acknowledged' → SetAcknowledgedState(true); 'Inactive' → SetActiveState(false) + Retain=false if already Acked) then calls alarm.ReportEvent to emit the OPC UA event to subscribed clients. Galaxy's GalaxyAlarmTracker (PR 14) now lands at a concrete AlarmConditionState node instead of just raising an unobserved C# event. OtOpcUaServer : StandardServer wires one DriverNodeManager per DriverHost.GetDriver during CreateMasterNodeManager — anonymous endpoint, no security profile (minimum-viable; LDAP + security-profile wire-up is the next PR). DriverHost gains public GetDriver(instanceId) so the server can enumerate drivers at startup. NestedBuilder inner class in DriverNodeManager implements IAddressSpaceBuilder by temporarily retargeting the parent's _currentFolder during each call so Folder→Variable→AddProperty land under the correct subtree — not thread-safe if discovery ran concurrently, but GenericDriverNodeManager.BuildAddressSpaceAsync is sequential per driver so this is safe by construction. NuGet audit suppress for GHSA-h958-fxgg-g7w3 (moderate-severity in OPCFoundation.NetStandard.Opc.Ua.Core 1.5.374.126; v1 stack already accepts this risk on the same package version). PR 16 is scoped as scaffolding — the actual server startup (ApplicationInstance, certificate config, endpoint binding, session management wiring into OpcUaServerService.ExecuteAsync) is deferred to a follow-up PR because it needs ApplicationConfiguration XML + optional-cert-store logic that depends on per-deployment policy decisions. The materialization shape is complete: a subsequent PR adds 100 LOC to start the server and all the already-written IAddressSpaceBuilder + alarm-condition + read/write wire-up activates end-to-end. Full solution: 0 errors, 152 unit tests pass (no new tests this PR — DriverNodeManager unit testing needs an IServerInternal mock which is heavyweight; live-endpoint integration tests land alongside the server-startup PR).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:00:36 -04:00
d569c39f30 Merge pull request 'Phase 3 PR 15 — alarm-condition contract in abstract layer' (#14) from phase-3-pr15-alarm-contract into v2 2026-04-18 07:54:30 -04:00
Joseph Doherty
190d09cdeb Phase 3 PR 15 — alarm-condition contract in IAddressSpaceBuilder + wire OnAlarmEvent through GenericDriverNodeManager. IAddressSpaceBuilder.IVariableHandle gains MarkAsAlarmCondition(AlarmConditionInfo) which returns an IAlarmConditionSink. AlarmConditionInfo carries SourceName/InitialSeverity/InitialDescription. Concrete address-space builders (the upcoming PR 16 OPC UA server backend) materialize a sibling AlarmConditionState node on the first call; the sink receives every lifecycle transition the generic node manager forwards. GenericDriverNodeManager gains a CapturingBuilder wrapper that transparently wraps every Folder/Variable call — the wrapper observes MarkAsAlarmCondition calls without participating in materialization, captures the resulting IAlarmConditionSink into an internal source-node-id → sink ConcurrentDictionary keyed by IVariableHandle.FullReference. After DiscoverAsync completes, if the driver implements IAlarmSource the node manager subscribes to OnAlarmEvent and routes every AlarmEventArgs to the sink registered for args.SourceNodeId — unknown source ids are dropped silently (may belong to another driver or to a variable the builder chose not to flag). Dispose unsubscribes the forwarder to prevent dangling invocation-list references across node-manager rebuilds. GalaxyProxyDriver.DiscoverAsync now calls handle.MarkAsAlarmCondition(new AlarmConditionInfo(fullName, AlarmSeverity.Medium, null)) on every attr.IsAlarm=true variable — severity seed is Medium because the live Priority byte arrives through the subsequent GalaxyAlarmEvent stream (which PR 14's GalaxyAlarmTracker now emits); the Admin UI sees the severity update on the first transition. RecordingAddressSpaceBuilder in Driver.Galaxy.E2E gains a RecordedAlarmCondition list + a RecordingSink implementation that captures AlarmEventArgs for test assertion — the E2E parity suite can now verify alarm-condition registration shape in addition to folder/variable shape. Tests (4 new GenericDriverNodeManagerTests): Alarm_events_are_routed_to_the_sink_registered_for_the_matching_source_node_id — 2 alarms registered (Tank.HiHi + Heater.OverTemp), driver raises an event for Tank.HiHi, the Tank.HiHi sink captures the payload, the Heater.OverTemp sink does not (tag-scoped fan-out, not broadcast); Non_alarm_variables_do_not_register_sinks — plain Tank.Level in the same discover is not in TrackedAlarmSources; Unknown_source_node_id_is_dropped_silently — a transition for Unknown.Source doesn't reach any sink + no exception; Dispose_unsubscribes_from_OnAlarmEvent — post-dispose, a transition for a previously-registered tag is no-op because the forwarder detached. InternalsVisibleTo('ZB.MOM.WW.OtOpcUa.Core.Tests') added to Core csproj so TrackedAlarmSources internal property is visible to the test. Full solution: 0 errors, 152 unit tests pass (8 Core + 14 Proxy + 14 Admin + 24 Configuration + 6 Shared + 84 Galaxy.Host + 2 Server). PR 16 will implement the concrete OPC UA address-space builder that materializes AlarmConditionState from this contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 07:51:35 -04:00
4e0040e670 Merge pull request 'Phase 2 PR 14 — alarm subsystem (subscribe to alarm attribute quartet + raise GalaxyAlarmEvent)' (#13) from phase-2-pr14-alarm-subsystem into v2 2026-04-18 07:37:49 -04:00
91cb2a1355 Merge pull request 'Phase 2 PR 13 — port GalaxyRuntimeProbeManager + per-platform ScanState probing' (#12) from phase-2-pr13-runtime-probe into v2 2026-04-18 07:37:41 -04:00
Joseph Doherty
c14624f012 Phase 2 PR 14 — alarm subsystem wire-up. Per IsAlarm=true attribute (PR 9 added the discovery flag), GalaxyAlarmTracker in Backend/Alarms/ advises the four Galaxy alarm-state attributes: .InAlarm (boolean alarm active), .Priority (int severity), .DescAttrName (human-readable description), .Acked (boolean acknowledged). Runs the OPC UA Part 9 alarm lifecycle state machine simplified for the Galaxy AlarmExtension model and raises AlarmTransition events on transitions operators must react to — Active (InAlarm false→true, default Unacknowledged), Acknowledged (Acked false→true while InAlarm still true), Inactive (InAlarm true→false). MxAccessGalaxyBackend instantiates the tracker in its constructor with delegate-based subscribe/unsubscribe/write pointers to MxAccessClient, hooks TransitionRaised to forward each transition through the existing OnAlarmEvent IPC event that PR 4 ConnectionSink wires into MessageKind.AlarmEvent frames — no new contract messages required since GalaxyAlarmEvent already exists in Shared.Contracts. Field mapping: EventId = fresh Guid.ToString('N') per transition, ObjectTagName = alarm attribute full reference, AlarmName = alarm attribute full reference, Severity = tracked Priority, StateTransition = 'Active'|'Acknowledged'|'Inactive', Message = DescAttrName or tag fallback, UtcUnixMs = transition time. DiscoverAsync caches every IsAlarm=true attribute's full reference (tag.attribute) into _discoveredAlarmTags (ConcurrentBag cleared-then-filled on every re-Discover to track Galaxy redeploys). SubscribeAlarmsAsync iterates the cache and advises each via GalaxyAlarmTracker.TrackAsync; best-effort per-alarm — a subscribe failure on one alarm doesn't abort the whole call since operators prefer partial alarm coverage to none. Tracker is internally idempotent on repeat Track calls (second invocation for same alarm tag is a no-op; already-subscribed check short-circuits before the 4 MXAccess sub calls). Subscribe-failure rollback inside TrackAsync removes the alarm state + unadvises any of the 4 that did succeed so a partial advise can't leak a phantom tracking entry. AcknowledgeAlarmAsync routes to tracker.AcknowledgeAsync which writes the operator comment to <tag>.AckMsg via MxAccessClient.WriteAsync — writes use the existing MXAccess OnWriteComplete TCS-by-handle path (PR 4 Medium 4) so a runtime-refused ack bubbles up as Success=false rather than false-positive. State-machine quirks preserved from v1: (1) initial Acked=true on subscribe does NOT fire Acknowledged (alarm at rest, pre-acknowledged — default state is Acked=true so the first subscribe callback is a no-op transition), (2) Acked false→true only fires Acknowledged when InAlarm is currently true (acking a latched-inactive alarm is not a user-visible transition), (3) Active transition clears the Acked flag in-state so the next Acked callback correctly fires Acknowledged (v1 had this buried in the ConditionState logic; we track it on the AlarmState struct directly). Priority value handled as int/short/long via type pattern match with int.MaxValue guard — Galaxy attribute category returns varying CLR types (Int32 is canonical but some older templates use Int16), and a long overflow cast to int would silently corrupt the severity. Dispose cascade in MxAccessGalaxyBackend.Dispose: alarm-tracker unsubscribe→dispose, probe-manager unsubscribe→dispose, mx.ConnectionStateChanged detach, historian dispose — same discipline PR 6 / PR 8 / PR 13 established so dangling invocation-list refs don't survive a backend recycle. #pragma warning disable CS0067 around OnAlarmEvent removed since the event is now raised. Tests (9 new, GalaxyAlarmTrackerTests): four-attribute subscribe per alarm, idempotent repeat-track, InAlarm false→true fires Active with Priority + Desc, InAlarm true→false fires Inactive, Acked false→true while InAlarm fires Acknowledged, Acked transition while InAlarm=false does not fire, AckMsg write path carries the comment, snapshot reports latest four fields, foreign probe callback for a non-tracked tag is silently dropped. Full Galaxy.Host.Tests Unit suite 84 pass / 0 fail (9 new alarm + 12 PR 13 probe + 21 PR 12 quality + 42 pre-existing). Galaxy.Host builds clean (0/0). Branches off phase-2-pr13-runtime-probe so the MxAccessGalaxyBackend constructor/Dispose chain gets the probe-manager + alarm-tracker wire-up in a coherent order; fast-forwards if PR 13 merges first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 07:34:13 -04:00
241 changed files with 9575 additions and 24805 deletions

View File

@@ -8,8 +8,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
@@ -22,11 +21,11 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>

View File

@@ -348,6 +348,44 @@ The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP s
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
### Active Directory configuration
Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: `Server`, `Port`, `UserNameAttribute`, and `ServiceAccountDn`. The same `GroupToRole` mechanism works — map your AD security groups to OPC UA roles.
```json
{
"OpcUaServer": {
"Ldap": {
"Enabled": true,
"Server": "dc01.corp.example.com",
"Port": 636,
"UseTls": true,
"AllowInsecureLdap": false,
"SearchBase": "DC=corp,DC=example,DC=com",
"ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
"ServiceAccountPassword": "<from your secret store>",
"DisplayNameAttribute": "displayName",
"GroupAttribute": "memberOf",
"UserNameAttribute": "sAMAccountName",
"GroupToRole": {
"OPCUA-Operators": "WriteOperate",
"OPCUA-Engineers": "WriteConfigure",
"OPCUA-AlarmAck": "AlarmAck",
"OPCUA-Tuners": "WriteTune"
}
}
}
}
```
Notes:
- `UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries, so the user-DN lookup returns no results without it. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form.
- `Port: 636` + `UseTls: true` is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set `AllowInsecureLdap: false` to refuse fallback.
- `ServiceAccountDn` should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
- `memberOf` values come back as full DNs like `CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com`. The authenticator strips the leading `CN=` RDN value so operators configure `GroupToRole` with readable group common-names.
- Nested group membership is **not** expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. `LDAP_MATCHING_RULE_IN_CHAIN` / `tokenGroups` expansion is an authenticator enhancement, not a config change.
### Security Considerations
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.

154
docs/v2/lmx-followups.md Normal file
View File

@@ -0,0 +1,154 @@
# LMX Galaxy bridge — remaining follow-ups
State after PR 19: the Galaxy driver is functionally at v1 parity through the
`IDriver` abstraction; the OPC UA server runs with LDAP-authenticated
Basic256Sha256 endpoints and alarms are observable through
`AlarmConditionState.ReportEvent`. The items below are what remains LMX-
specific before the stack can fully replace the v1 deployment, in
rough priority order.
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents`
**Status**: Capability surface complete (PR 35). OPC UA HistoryRead service-handler
wiring in `DriverNodeManager` remains as the next step; integration-test still
pending.
PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync`
(default throwing implementations so existing impls keep compiling), added the
`HistoricalEvent` + `HistoricalEventsResult` records to
`Core.Abstractions`, and implemented both methods in `GalaxyProxyDriver` on top
of the PR 10 / PR 11 IPC messages. Wire-to-domain mapping (`ToHistoricalEvent`)
is unit-tested for field fidelity, null-preservation, and `DateTimeKind.Utc`.
**Remaining**:
- `DriverNodeManager` wires the new capability methods onto `HistoryRead`
`AtTime` + `Events` service handlers.
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
## 2. Write-gating by role — **DONE (PR 26)**
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
`SecurityClassification` → required role (`FreeAccess` → no role required,
`Operate`/`SecuredWrite``WriteOperate`, `Tune``WriteTune`,
`Configure`/`VerifiedWrite``WriteConfigure`, `ViewOnly` → deny regardless).
`DriverNodeManager` caches the classification per variable during discovery and
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
can't write a `Tune` attribute unless it also carries `WriteTune`.
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
that authz stays at the server layer and never delegates to driver-specific auth.
## 3. Admin UI client-cert trust management — **DONE (PR 28)**
PR 28 shipped `/certificates` in the Admin UI. `CertTrustService` reads the OPC
UA server's PKI store root (`OpcUaServerOptions.PkiStoreRoot` — default
`%ProgramData%\OtOpcUa\pki`) and lists rejected + trusted certs by parsing the
`.der` files directly, so it has no `Opc.Ua` dependency and runs on any
Admin host that can reach the shared PKI directory.
Operator actions: Trust (moves `rejected/certs/*.der``trusted/certs/*.der`),
Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on
each new client handshake, so no explicit reload signal is needed —
operators retry the rejected client's connection after trusting.
Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
deployment default. That's a production-hardening config change, not a code
gap — the Admin UI is now ready to be the trust gate.
## 4. Live-LDAP integration test — **DONE (PR 31)**
PR 31 shipped `Server.Tests/LdapUserAuthenticatorLiveTests.cs` — 6 live-bind
tests against the dev GLAuth instance at `localhost:3893`, skipped cleanly
when the port is unreachable. Covers: valid bind, wrong password, unknown
user, empty credentials, single-group → WriteOperate mapping, multi-group
admin user surfacing all mapped roles.
Also added `UserNameAttribute` to `LdapOptions` (default `uid` for RFC 2307
compat) so Active Directory deployments can configure `sAMAccountName` /
`userPrincipalName` without code changes. `LdapUserAuthenticatorAdCompatTests`
(5 unit guards) pins the AD-shape DN parsing + filter escape behaviors. See
`docs/security.md` §"Active Directory configuration" for the AD appsettings
snippet.
Deferred: asserting `session.Identity` end-to-end on the server side (i.e.
drive a full OPC UA session with username/password, then read an
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
That needs a test-only address-space node and is a separate PR.
## 5. Full Galaxy live-service smoke test against the merged v2 stack — **IN PROGRESS (PRs 36 + 37)**
PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes
every dependency a live smoke test needs and produces actionable skip
messages.
PR 37 shipped the live-stack smoke test project structure:
`tests/Driver.Galaxy.Proxy.Tests/LiveStack/` with `LiveStackFixture` (connects
to the *already-running* `OtOpcUaGalaxyHost` Windows service via named pipe;
never spawns the Host process) and `LiveStackSmokeTests` covering:
- Fixture initializes successfully (IPC handshake succeeds end-to-end).
- Driver reports `DriverState.Healthy` post-handshake.
- `DiscoverAsync` returns at least one variable from the live Galaxy.
- `GetHostStatuses` reports at least one Platform/AppEngine host.
- `ReadAsync` on a discovered variable round-trips through
Proxy → Host pipe → MXAccess → back without a BadInternalError.
Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` /
`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's
registry-stored Environment values (requires elevated test host).
**Remaining**:
- Install + run the `OtOpcUaGalaxyHost` + `OtOpcUa` services on the dev box
(`scripts/install/Install-Services.ps1`) so the skip-on-unready tests
actually execute and the smoke PR lands green.
- Subscribe-and-receive-data-change fact (needs a known tag that actually
ticks; deferred until operators confirm a scratch tag exists).
- Write-and-roundtrip fact (needs a test-only UDA or agreed scratch tag
so we can't accidentally mutate a process-critical value).
## 6. Second driver instance on the same server — **DONE (PR 32)**
`Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two
drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the
full OPC UA server, and asserts three behaviors: (1) each driver's namespace
URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's
NamespaceUris, (2) browsing one subtree returns that driver's folder and
does NOT leak the other driver's folder, (3) reads route to the correct
driver — the alpha instance returns 42 while beta returns 99, so a misroute
would surface at the assertion layer.
Deferred: the alarm-event multi-driver parity case (two drivers each raising
a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's
condition node). Alarm tracking already has its own integration test
(`AlarmSubscription*`); the multi-driver alarm case would need a stub
`IAlarmSource` that's worth its own focused PR.
## 7. Host-status per-AppEngine granularity → Admin UI dashboard — **DONE (PRs 33 + 34)**
**PR 33** landed the data layer: `DriverHostStatus` entity + migration with
composite key `(NodeId, DriverInstanceId, HostName)` and two query-supporting
indexes (per-cluster drill-down on `NodeId`, stale-row detection on
`LastSeenUtc`).
**PR 34** wired the publisher + consumer. `HostStatusPublisher` is a
`BackgroundService` in the Server process that walks every registered
`IHostConnectivityProbe`-capable driver every 10s, calls
`GetHostStatuses()`, and upserts rows (`LastSeenUtc` advances each tick;
`State` + `StateChangedUtc` update on transitions). Admin UI `/hosts` page
groups by cluster, shows four summary cards (Hosts / Running / Stale /
Faulted), and flags rows whose `LastSeenUtc` is older than 30s as Stale so
operators see crashed Servers without waiting for a state change.
Deferred as follow-ups:
- Event-driven push (subscribe to `OnHostStatusChanged` per driver for
sub-heartbeat latency). Adds DriverHost lifecycle-event plumbing;
10s polling is fine for operator-scale use.
- Failure-count column — needs the publisher to track a transition history
per host, not just current-state.
- SignalR fan-out to the Admin page (currently the page polls the DB, not
a hub). The DB-polled version is fine at current cadence but a hub push
would eliminate the 10s race where a new row sits in the DB before the
Admin page notices.

108
docs/v2/modbus-test-plan.md Normal file
View File

@@ -0,0 +1,108 @@
# Modbus driver — test plan + device-quirk catalog
The Modbus TCP driver unit tests (PRs 2124) cover the protocol surface against an
in-memory fake transport. They validate the codec, state machine, and function-code
routing against a textbook Modbus server. That's necessary but not sufficient: real PLC
populations disagree with the spec in small, device-specific ways, and a driver that
passes textbook tests can still misbehave against actual equipment.
This doc is the harness-and-quirks playbook. The project it describes lives at
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` — scaffolded in PR 30 with
the simulator fixture, DL205 profile stub, and one write/read smoke test. Each
confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
## Harness
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale:
- Scriptable enough to mimic device-specific behaviors (non-standard register
layouts, custom exception codes, intentional response delays).
- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured
simulator endpoint) isn't reachable.
- Free + long-maintained — physical PLC bench is unavailable in most dev
environments, and renting cloud PLCs isn't worth the per-test cost.
**Setup pattern** (not yet codified in a script — will land alongside the integration
test project):
1. Install ModbusPal, load the per-device `.xmpp` profile from
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory).
2. Start the simulator listening on `localhost:502` (or override via
`MODBUS_SIM_ENDPOINT` env var).
3. `dotnet test` the integration project — tests auto-skip when the endpoint is
unreachable, so forgetting to start the simulator doesn't wedge CI.
## Per-device quirk catalog
### AutomationDirect DL205
First known target device. Quirks to document and cover with named tests (to be
filled in when user validates each behavior in ModbusPal with a DL205 profile):
- **Word order for 32-bit values**: _pending_ — confirm whether DL205 uses ABCD
(Modbus TCP standard) or CDAB (Siemens-style word-swap) for Int32/UInt32/Float32.
Test name: `DL205_Float32_word_order_is_CDAB` (or `ABCD`, whichever proves out).
- **Register-zero access**: _pending_ — some DL205 configurations reject FC03 at
register 0 with exception code 02 (illegal data address). If confirmed, the
integration test suite verifies `ModbusProbeOptions.ProbeAddress` default of 0
triggers the rejection and operators must override; test name:
`DL205_FC03_at_register_0_returns_IllegalDataAddress`.
- **Coil addressing base**: _pending_ — DL205 documentation sometimes uses 1-based
coil addresses; verify the driver's zero-based addressing matches the physical
PLC without an off-by-one adjustment.
- **Maximum registers per FC03**: _pending_ — Modbus spec caps at 125; some DL205
models enforce a lower limit (e.g., 64). Test name:
`DL205_FC03_beyond_max_registers_returns_IllegalDataValue`.
- **Response framing under sustained load**: _pending_ — the driver's
single-flight semaphore assumes the server pairs requests/responses by
transaction id; at least one DL205 firmware revision is reported to drop the
TxId under load. If reproduced in ModbusPal we add a retry + log-and-continue
path to `ModbusTcpTransport`.
- **Exception code on coil write to a protected bit**: _pending_ — some DL205
setups protect internal coils; the driver should surface the PLC's exception
PDU as `BadNotWritable` rather than `BadInternalError`.
_User action item_: as each quirk is validated in ModbusPal, replace the _pending_
marker with the confirmed behavior and file a named test in the integration suite.
### Future devices
One section per device class, same shape as DL205. Quirks that apply across
multiple devices (e.g., "all AB PLCs use CDAB") can be noted in the cross-device
patterns section below once we have enough data points.
## Cross-device patterns
Once multiple device catalogs accumulate, quirks that recur across two or more
vendors get promoted into driver defaults or opt-in options:
- _(empty — filled in as catalogs grow)_
## Test conventions
- **One named test per quirk.** `DL205_word_order_is_CDAB_for_Float32` is easier to
diagnose on failure than a generic `Float32_roundtrip`. The `DL205_` prefix makes
filtering by device class trivial (`--filter "DisplayName~DL205"`).
- **Skip with a clear SkipReason.** Follow the pattern from
`GalaxyRepositoryLiveSmokeTests`: check reachability in the fixture, capture
a `SkipReason` string, and have each test call `Assert.Skip(SkipReason)` when
it's set. Don't throw — skipped tests read cleanly in CI logs.
- **Use the real `ModbusTcpTransport`.** Integration tests exercise the wire
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
deliberately not used here — its value is speed + determinism, which doesn't
help reproduce device-specific issues.
- **Don't depend on ModbusPal state between tests.** Each test resets the
simulator's register bank or uses a unique address range. Avoid relying on
"previous test left value at register 10" setups that flake when tests run in
parallel or re-order.
## Next concrete PRs
- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**.
Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
`ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one
writable holding register at address 100), and `DL205/DL205SmokeTests.cs`
(write-then-read round-trip). `ModbusPal/` directory holds the README
pointing at the to-be-committed `DL205.xmpp` profile.
- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop
the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR.

View File

@@ -5,15 +5,18 @@
<h5 class="mb-4">OtOpcUa Admin</h5>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/hosts">Host status</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
</ul>
<div class="mt-5">
<AuthorizeView>
<Authorized>
<div class="small text-light">
Signed in as <strong>@context.User.Identity?.Name</strong>
Signed in as <a class="text-light" href="/account"><strong>@context.User.Identity?.Name</strong></a>
</div>
<div class="small text-muted">
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))

View File

@@ -0,0 +1,129 @@
@page "/account"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using System.Security.Claims
@using ZB.MOM.WW.OtOpcUa.Admin.Services
<h1 class="mb-4">My account</h1>
<AuthorizeView>
<Authorized>
@{
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—";
var displayName = context.User.Identity?.Name ?? "—";
var roles = context.User.Claims
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
var ldapGroups = context.User.Claims
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
}
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Identity</h5>
<dl class="row mb-0">
<dt class="col-sm-4">Username</dt><dd class="col-sm-8"><code>@username</code></dd>
<dt class="col-sm-4">Display name</dt><dd class="col-sm-8">@displayName</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Admin roles</h5>
@if (roles.Count == 0)
{
<p class="text-muted mb-0">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</p>
}
else
{
<div class="mb-2">
@foreach (var r in roles)
{
<span class="badge bg-primary me-1">@r</span>
}
</div>
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small>
}
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Capabilities</h5>
<p class="text-muted small">
Each Admin role grants a fixed capability set per <code>admin-ui.md</code> §Admin Roles.
Pages below reflect what this session can access; the route's <code>[Authorize]</code> guard
is the ground truth — this table mirrors it for readability.
</p>
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Capability</th>
<th>Required role(s)</th>
<th class="text-end">You have it?</th>
</tr>
</thead>
<tbody>
@foreach (var cap in Capabilities)
{
var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
<tr>
<td>@cap.Name<br /><small class="text-muted">@cap.Description</small></td>
<td>@string.Join(" or ", cap.RequiredRoles)</td>
<td class="text-end">
@if (has)
{
<span class="badge bg-success">Yes</span>
}
else
{
<span class="badge bg-secondary">No</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mt-4">
<form method="post" action="/auth/logout">
<button class="btn btn-outline-danger" type="submit">Sign out</button>
</form>
</div>
</Authorized>
</AuthorizeView>
@code {
private sealed record Capability(string Name, string Description, string[] RequiredRoles);
// Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute.
// When a new page or policy is added, extend this list so operators can self-service check
// whether their session has access without trial-and-error navigation.
private static readonly IReadOnlyList<Capability> Capabilities =
[
new("View clusters + fleet status",
"Read-only access to the cluster list, fleet dashboard, and generation history.",
[AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
new("Edit configuration drafts",
"Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.",
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
new("Publish generations",
"Promote a draft to Published — triggers node roll-out. CanPublish policy.",
[AdminRoles.FleetAdmin]),
new("Manage certificate trust",
"Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.",
[AdminRoles.FleetAdmin]),
new("Manage external-ID reservations",
"Reserve / release external IDs that map into Galaxy contained names.",
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
];
}

View File

@@ -0,0 +1,154 @@
@page "/certificates"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)]
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@inject CertTrustService Certs
@inject AuthenticationStateProvider AuthState
@inject ILogger<Certificates> Log
<h1 class="mb-4">Certificate trust</h1>
<div class="alert alert-info small mb-4">
PKI store root <code>@Certs.PkiStoreRoot</code>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
</div>
@if (_status is not null)
{
<div class="alert alert-@_statusKind alert-dismissible">
@_status
<button type="button" class="btn-close" @onclick="ClearStatus"></button>
</div>
}
<h2 class="h4">Rejected (@_rejected.Count)</h2>
@if (_rejected.Count == 0)
{
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
}
else
{
<table class="table table-sm align-middle">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<tbody>
@foreach (var c in _rejected)
{
<tr>
<td>@c.Subject</td>
<td>@c.Issuer</td>
<td><code class="small">@c.Thumbprint</code></td>
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
<td class="text-end">
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRejectedAsync(c)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2>
@if (_trusted.Count == 0)
{
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p>
}
else
{
<table class="table table-sm align-middle">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<tbody>
@foreach (var c in _trusted)
{
<tr>
<td>@c.Subject</td>
<td>@c.Issuer</td>
<td><code class="small">@c.Thumbprint</code></td>
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
</td>
</tr>
}
</tbody>
</table>
}
@code {
private IReadOnlyList<CertInfo> _rejected = [];
private IReadOnlyList<CertInfo> _trusted = [];
private string? _status;
private string _statusKind = "success";
protected override void OnInitialized() => Reload();
private void Reload()
{
_rejected = Certs.ListRejected();
_trusted = Certs.ListTrusted();
}
private async Task TrustAsync(CertInfo c)
{
if (Certs.TrustRejected(c.Thumbprint))
{
await LogActionAsync("cert.trust", c);
Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
}
else
{
Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning");
}
Reload();
}
private async Task DeleteRejectedAsync(CertInfo c)
{
if (Certs.DeleteRejected(c.Thumbprint))
{
await LogActionAsync("cert.delete.rejected", c);
Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
}
else
{
Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning");
}
Reload();
}
private async Task UntrustAsync(CertInfo c)
{
if (Certs.UntrustCert(c.Thumbprint))
{
await LogActionAsync("cert.untrust", c);
Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success");
}
else
{
Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning");
}
Reload();
}
private async Task LogActionAsync(string action, CertInfo c)
{
// Cert trust changes are operator-initiated and security-sensitive — Serilog captures the
// user + thumbprint trail. CertTrustService also logs at Information on each filesystem
// move/delete; this line ties the action to the authenticated admin user so the two logs
// correlate. DB-level ConfigAuditLog persistence is deferred — its schema is
// cluster-scoped and cert actions are cluster-agnostic.
var state = await AuthState.GetAuthenticationStateAsync();
var user = state.User.Identity?.Name ?? "(anonymous)";
Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}",
user, action, c.Thumbprint, c.Subject);
}
private void Set(string message, string kind)
{
_status = message;
_statusKind = kind;
}
private void ClearStatus() => _status = null;
private static string Short(string thumbprint) =>
thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint;
}

View File

@@ -0,0 +1,172 @@
@page "/fleet"
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable
<h1 class="mb-4">Fleet status</h1>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
Refresh
</button>
<span class="text-muted small">
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
</span>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else if (_rows.Count == 0)
{
<div class="alert alert-info">
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
this list is empty, either no nodes have been registered or the poller hasn't run yet.
</div>
}
else
{
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card"><div class="card-body">
<h6 class="text-muted mb-1">Nodes</h6>
<div class="fs-3">@_rows.Count</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Applied</h6>
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-warning"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-danger"><div class="card-body">
<h6 class="text-muted mb-1">Failed</h6>
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
</div></div>
</div>
</div>
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Node</th>
<th>Cluster</th>
<th>Generation</th>
<th>Status</th>
<th>Last applied</th>
<th>Last seen</th>
<th>Error</th>
</tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr class="@RowClass(r)">
<td><code>@r.NodeId</code></td>
<td>@r.ClusterId</td>
<td>@(r.GenerationId?.ToString() ?? "—")</td>
<td>
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
</td>
<td>@FormatAge(r.AppliedAt)</td>
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
</tr>
}
</tbody>
</table>
}
@code {
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
// the most recent published state without polling ahead of the broadcaster.
private const int RefreshIntervalSeconds = 5;
private List<FleetNodeRow>? _rows;
private bool _refreshing;
private DateTime? _lastRefreshUtc;
private Timer? _timer;
protected override async Task OnInitializedAsync()
{
await RefreshAsync();
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
state: null,
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
}
private async Task RefreshAsync()
{
if (_refreshing) return;
_refreshing = true;
try
{
using var scope = ScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
s.NodeId, n.ClusterId, s.CurrentGenerationId,
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
.OrderBy(r => r.ClusterId)
.ThenBy(r => r.NodeId)
.ToListAsync();
_rows = rows;
_lastRefreshUtc = DateTime.UtcNow;
}
finally
{
_refreshing = false;
StateHasChanged();
}
}
private static bool IsStale(FleetNodeRow r)
{
if (r.SeenAt is null) return true;
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
}
private static string RowClass(FleetNodeRow r) => r.Status switch
{
"Failed" => "table-danger",
_ when IsStale(r) => "table-warning",
_ => "",
};
private static string StatusBadge(string? status) => status switch
{
"Applied" => "bg-success",
"Failed" => "bg-danger",
"Applying" => "bg-info",
_ => "bg-secondary",
};
private static string FormatAge(DateTime? t)
{
if (t is null) return "—";
var age = DateTime.UtcNow - t.Value;
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
}
public void Dispose() => _timer?.Dispose();
internal sealed record FleetNodeRow(
string NodeId, string ClusterId, long? GenerationId,
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
}

View File

@@ -0,0 +1,160 @@
@page "/hosts"
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable
<h1 class="mb-4">Driver host status</h1>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
Refresh
</button>
<span class="text-muted small">
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
</span>
</div>
<div class="alert alert-info small mb-4">
Each row is one host reported by a driver instance on a server node. Galaxy drivers report
per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out
of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than
30s are flagged Stale, which usually means the owning Server process has crashed or lost
its DB connection.
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else if (_rows.Count == 0)
{
<div class="alert alert-secondary">
No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements <code>IHostConnectivityProbe</code>.
</div>
}
else
{
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card"><div class="card-body">
<h6 class="text-muted mb-1">Hosts</h6>
<div class="fs-3">@_rows.Count</div>
</div></div></div>
<div class="col-md-3"><div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Running</h6>
<div class="fs-3 text-success">@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))</div>
</div></div></div>
<div class="col-md-3"><div class="card border-warning"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 text-warning">@_rows.Count(HostStatusService.IsStale)</div>
</div></div></div>
<div class="col-md-3"><div class="card border-danger"><div class="card-body">
<h6 class="text-muted mb-1">Faulted</h6>
<div class="fs-3 text-danger">@_rows.Count(r => r.State == DriverHostState.Faulted)</div>
</div></div></div>
</div>
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
{
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
<table class="table table-sm table-hover align-middle">
<thead>
<tr>
<th>Node</th>
<th>Driver</th>
<th>Host</th>
<th>State</th>
<th>Last transition</th>
<th>Last seen</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
@foreach (var r in cluster)
{
<tr class="@RowClass(r)">
<td><code>@r.NodeId</code></td>
<td><code>@r.DriverInstanceId</code></td>
<td>@r.HostName</td>
<td>
<span class="badge @StateBadge(r.State)">@r.State</span>
@if (HostStatusService.IsStale(r))
{
<span class="badge bg-warning text-dark ms-1">Stale</span>
}
</td>
<td class="small">@FormatAge(r.StateChangedUtc)</td>
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
<td class="text-truncate small" style="max-width: 320px;" title="@r.Detail">@r.Detail</td>
</tr>
}
</tbody>
</table>
}
}
@code {
// Mirrors HostStatusPublisher.HeartbeatInterval — polling ahead of the broadcaster
// produces stale-looking rows mid-cycle.
private const int RefreshIntervalSeconds = 10;
private List<HostStatusRow>? _rows;
private bool _refreshing;
private DateTime? _lastRefreshUtc;
private Timer? _timer;
protected override async Task OnInitializedAsync()
{
await RefreshAsync();
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
state: null,
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
}
private async Task RefreshAsync()
{
if (_refreshing) return;
_refreshing = true;
try
{
using var scope = ScopeFactory.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<HostStatusService>();
_rows = (await svc.ListAsync()).ToList();
_lastRefreshUtc = DateTime.UtcNow;
}
finally
{
_refreshing = false;
StateHasChanged();
}
}
private static string RowClass(HostStatusRow r) => r.State switch
{
DriverHostState.Faulted => "table-danger",
_ when HostStatusService.IsStale(r) => "table-warning",
_ => "",
};
private static string StateBadge(DriverHostState s) => s switch
{
DriverHostState.Running => "bg-success",
DriverHostState.Stopped => "bg-secondary",
DriverHostState.Faulted => "bg-danger",
_ => "bg-secondary",
};
private static string FormatAge(DateTime t)
{
var age = DateTime.UtcNow - t;
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
}
public void Dispose() => _timer?.Dispose();
}

View File

@@ -47,6 +47,13 @@ builder.Services.AddScoped<NodeAclService>();
builder.Services.AddScoped<ReservationService>();
builder.Services.AddScoped<DraftValidationService>();
builder.Services.AddScoped<AuditLogService>();
builder.Services.AddScoped<HostStatusService>();
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
// filesystem operations.
builder.Services.Configure<CertTrustOptions>(builder.Configuration.GetSection(CertTrustOptions.SectionName));
builder.Services.AddSingleton<CertTrustService>();
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
builder.Services.Configure<LdapOptions>(

View File

@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Points the Admin UI at the OPC UA Server's PKI store root so
/// <see cref="CertTrustService"/> can list and move certs between the
/// <c>rejected/</c> and <c>trusted/</c> directories the server maintains. Must match the
/// <c>OpcUaServer:PkiStoreRoot</c> the Server process is configured with.
/// </summary>
public sealed class CertTrustOptions
{
public const string SectionName = "CertTrust";
/// <summary>
/// Absolute path to the PKI root. Defaults to
/// <c>%ProgramData%\OtOpcUa\pki</c> — matches <c>OpcUaServerOptions.PkiStoreRoot</c>'s
/// default so a standard side-by-side install needs no override.
/// </summary>
public string PkiStoreRoot { get; init; } =
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"OtOpcUa", "pki");
}

View File

@@ -0,0 +1,135 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
/// <see cref="FilePath"/> is the absolute path of the DER/CRT file the stack created when it
/// rejected the cert (for <see cref="CertStoreKind.Rejected"/>) or when an operator trusted
/// it (for <see cref="CertStoreKind.Trusted"/>).
/// </summary>
public sealed record CertInfo(
string Thumbprint,
string Subject,
string Issuer,
DateTime NotBefore,
DateTime NotAfter,
string FilePath,
CertStoreKind Store);
public enum CertStoreKind
{
Rejected,
Trusted,
}
/// <summary>
/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
/// Directory-typed store — each cert is a <c>.der</c> file under <c>{root}/{store}/certs/</c>
/// with a filename derived from subject + thumbprint. This service exposes operators for the
/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
/// </summary>
/// <remarks>
/// The Admin process is separate from the Server process; this service deliberately has no
/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
/// host even when the Server isn't installed locally, as long as the PKI root is reachable
/// (typical deployment has Admin + Server side-by-side on the same machine).
///
/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
/// the Directory store on each new incoming connection, so there's no explicit signal
/// needed — the next client handshake picks up the change. Operators should retry the
/// rejected client's connection after trusting.
/// </remarks>
public sealed class CertTrustService
{
private readonly CertTrustOptions _options;
private readonly ILogger<CertTrustService> _logger;
public CertTrustService(IOptions<CertTrustOptions> options, ILogger<CertTrustService> logger)
{
_options = options.Value;
_logger = logger;
}
public string PkiStoreRoot => _options.PkiStoreRoot;
public IReadOnlyList<CertInfo> ListRejected() => ListStore(CertStoreKind.Rejected);
public IReadOnlyList<CertInfo> ListTrusted() => ListStore(CertStoreKind.Trusted);
/// <summary>
/// Move the cert with <paramref name="thumbprint"/> from the rejected store to the
/// trusted store. No-op returns false if the rejected file doesn't exist (already moved
/// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
/// silently — idempotent.
/// </summary>
public bool TrustRejected(string thumbprint)
{
var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
if (cert is null) return false;
var trustedDir = CertsDir(CertStoreKind.Trusted);
Directory.CreateDirectory(trustedDir);
var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
File.Move(cert.FilePath, destPath, overwrite: true);
_logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
return true;
}
public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
private bool DeleteFromStore(CertStoreKind store, string thumbprint)
{
var cert = FindInStore(store, thumbprint);
if (cert is null) return false;
File.Delete(cert.FilePath);
_logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
cert.Thumbprint, cert.Subject, store);
return true;
}
private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
ListStore(store).FirstOrDefault(c =>
string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
private IReadOnlyList<CertInfo> ListStore(CertStoreKind store)
{
var dir = CertsDir(store);
if (!Directory.Exists(dir)) return [];
var results = new List<CertInfo>();
foreach (var path in Directory.EnumerateFiles(dir))
{
// Skip CRL sidecars + private-key files — trust operations only concern public certs.
var ext = Path.GetExtension(path);
if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
!ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
!ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
var cert = X509CertificateLoader.LoadCertificateFromFile(path);
results.Add(new CertInfo(
cert.Thumbprint, cert.Subject, cert.Issuer,
cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
path, store));
}
catch (Exception ex)
{
// A malformed file in the store shouldn't take down the page. Surface it in logs
// but skip — operators see the other certs and can clean the bad file manually.
_logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
}
}
return results;
}
private string CertsDir(CertStoreKind store) =>
Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
}

View File

@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
/// <c>ClusterNode.ClusterId</c> when available (left-join). The Admin <c>/hosts</c> page
/// groups by cluster and renders a per-node → per-driver → per-host tree.
/// </summary>
public sealed record HostStatusRow(
string NodeId,
string? ClusterId,
string DriverInstanceId,
string HostName,
DriverHostState State,
DateTime StateChangedUtc,
DateTime LastSeenUtc,
string? Detail);
/// <summary>
/// Read-side service for the Admin UI's per-host drill-down. Loads
/// <see cref="DriverHostStatus"/> rows (written by the Server process's
/// <c>HostStatusPublisher</c>) and left-joins <c>ClusterNode</c> so each row knows which
/// cluster it belongs to — the Admin UI groups by cluster for the fleet-wide view.
/// </summary>
/// <remarks>
/// The publisher heartbeat is 10s (<c>HostStatusPublisher.HeartbeatInterval</c>). The
/// Admin page also polls every ~10s and treats rows with <c>LastSeenUtc</c> older than
/// <c>StaleThreshold</c> (30s) as stale — covers a missed heartbeat tolerance plus
/// a generous buffer for clock skew and publisher GC pauses.
/// </remarks>
public sealed class HostStatusService(OtOpcUaConfigDbContext db)
{
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
{
// LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
// been created yet (first-boot bootstrap case — keeps the UI from losing sight of
// the reporting server).
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
join n in db.ClusterNodes.AsNoTracking()
on s.NodeId equals n.NodeId into nodeJoin
from n in nodeJoin.DefaultIfEmpty()
orderby s.NodeId, s.DriverInstanceId, s.HostName
select new HostStatusRow(
s.NodeId,
n != null ? n.ClusterId : null,
s.DriverInstanceId,
s.HostName,
s.State,
s.StateChangedUtc,
s.LastSeenUtc,
s.Detail)).ToListAsync(ct);
return rows;
}
public static bool IsStale(HostStatusRow row) =>
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
}

View File

@@ -0,0 +1,61 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per-host connectivity snapshot the Server publishes for each driver's
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
/// rows, not 3, because each server node owns its own runtime view.
/// </summary>
/// <remarks>
/// <para>
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
/// drill-down). The publisher hosted service on the Server side subscribes to every
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
/// </para>
/// <para>
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
/// rather keep the status row than drop it. The Admin-side service left-joins on
/// NodeId when presenting rows.
/// </para>
/// </remarks>
public sealed class DriverHostStatus
{
/// <summary>Server node that's running the driver.</summary>
public required string NodeId { get; set; }
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
public required string DriverInstanceId { get; set; }
/// <summary>
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
/// a display string.
/// </summary>
public required string HostName { get; set; }
public DriverHostState State { get; set; } = DriverHostState.Unknown;
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
public DateTime StateChangedUtc { get; set; }
/// <summary>
/// Advances on every publisher heartbeat — the Admin UI uses
/// <c>now - LastSeenUtc &gt; threshold</c> to flag rows whose owning Server has
/// stopped reporting (crashed, network-partitioned, etc.), independent of
/// <see cref="State"/>.
/// </summary>
public DateTime LastSeenUtc { get; set; }
/// <summary>
/// Optional human-readable detail populated when <see cref="State"/> is
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
/// driver's probe. Null for Running / Stopped / Unknown transitions.
/// </summary>
public string? Detail { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>
/// Persisted mirror of <c>Core.Abstractions.HostState</c> — the lifecycle state each
/// <c>IHostConnectivityProbe</c>-capable driver reports for its per-host topology
/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams).
/// Defined here instead of re-using <c>Core.Abstractions.HostState</c> so the
/// Configuration project stays free of driver-runtime dependencies.
/// </summary>
/// <remarks>
/// The server-side publisher (follow-up PR) translates
/// <c>HostStatusChangedEventArgs.NewState</c> to this enum on every transition and
/// upserts into <see cref="Entities.DriverHostStatus"/>. Admin UI reads from the DB.
/// </remarks>
public enum DriverHostState
{
Unknown,
Running,
Stopped,
Faulted,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverHostStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DriverHostStatus",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
});
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_LastSeen",
table: "DriverHostStatus",
column: "LastSeenUtc");
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_Node",
table: "DriverHostStatus",
column: "NodeId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DriverHostStatus");
}
}
}

View File

@@ -332,6 +332,46 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b =>
{
b.Property<string>("NodeId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DriverInstanceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("HostName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Detail")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<DateTime>("LastSeenUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<DateTime>("StateChangedUtc")
.HasColumnType("datetime2(3)");
b.HasKey("NodeId", "DriverInstanceId", "HostName");
b.HasIndex("LastSeenUtc")
.HasDatabaseName("IX_DriverHostStatus_LastSeen");
b.HasIndex("NodeId")
.HasDatabaseName("IX_DriverHostStatus_Node");
b.ToTable("DriverHostStatus", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
{
b.Property<Guid>("DriverInstanceRowId")

View File

@@ -27,6 +27,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -47,6 +48,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureClusterNodeGenerationState(modelBuilder);
ConfigureConfigAuditLog(modelBuilder);
ConfigureExternalIdReservation(modelBuilder);
ConfigureDriverHostStatus(modelBuilder);
}
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
@@ -484,4 +486,30 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
});
}
private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DriverHostStatus>(e =>
{
e.ToTable("DriverHostStatus");
// Composite key — one row per (server node, driver instance, probe-reported host).
// A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
// 6 rows because each server node owns its own runtime view; the composite key is
// what lets both views coexist without shadowing each other.
e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.HostName).HasMaxLength(256);
e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
e.Property(x => x.Detail).HasMaxLength(1024);
// NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
// statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
// LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
});
}
}

View File

@@ -42,4 +42,39 @@ public interface IVariableHandle
{
/// <summary>Driver-side full reference for read/write addressing.</summary>
string FullReference { get; }
/// <summary>
/// Annotate this variable with an OPC UA <c>AlarmConditionState</c>. Drivers with
/// <see cref="DriverAttributeInfo.IsAlarm"/> = true call this during discovery so the
/// concrete address-space builder can materialize a sibling condition node. The returned
/// sink receives lifecycle transitions raised through <see cref="IAlarmSource.OnAlarmEvent"/>
/// — the generic node manager wires the subscription; the concrete builder decides how
/// to surface the state (e.g. OPC UA <c>AlarmConditionState.Activate</c>,
/// <c>Acknowledge</c>, <c>Deactivate</c>).
/// </summary>
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
}
/// <summary>
/// Metadata used to materialize an OPC UA <c>AlarmConditionState</c> sibling for a variable.
/// Populated by the driver's discovery step; concrete builders decide how to surface it.
/// </summary>
/// <param name="SourceName">Human-readable alarm name used for the <c>SourceName</c> event field.</param>
/// <param name="InitialSeverity">Severity at address-space build time; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
/// <param name="InitialDescription">Initial description; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
public sealed record AlarmConditionInfo(
string SourceName,
AlarmSeverity InitialSeverity,
string? InitialDescription);
/// <summary>
/// Sink a concrete address-space builder returns from <see cref="IVariableHandle.MarkAsAlarmCondition"/>.
/// The generic node manager routes per-alarm <see cref="IAlarmSource.OnAlarmEvent"/> payloads here —
/// the sink translates the transition into an OPC UA condition state change or whatever the
/// concrete builder's backing address space supports.
/// </summary>
public interface IAlarmConditionSink
{
/// <summary>Push an alarm transition (Active / Acknowledged / Inactive) for this condition.</summary>
void OnTransition(AlarmEventArgs args);
}

View File

@@ -30,6 +30,52 @@ public interface IHistoryProvider
TimeSpan interval,
HistoryAggregateType aggregate,
CancellationToken cancellationToken);
/// <summary>
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The
/// driver interpolates (or returns the prior-boundary sample) when no exact match
/// exists. Optional; drivers that can't interpolate throw <see cref="NotSupportedException"/>.
/// </summary>
/// <remarks>
/// Default implementation throws. Drivers opt in by overriding; keeps existing
/// <c>IHistoryProvider</c> implementations compiling without forcing a ReadAtTime path
/// they may not have a backend for.
/// </remarks>
Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken)
=> throw new NotSupportedException(
$"{GetType().Name} does not implement ReadAtTimeAsync. " +
"Drivers whose backends support at-time reads override this method.");
/// <summary>
/// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct
/// from the live event stream — historical rows come from an event historian (Galaxy's
/// Alarm Provider history log, etc.) rather than the driver's active subscription.
/// </summary>
/// <param name="sourceName">
/// Optional filter: null means "all sources", otherwise restrict to events from that
/// source-object name. Drivers may ignore the filter if the backend doesn't support it.
/// </param>
/// <param name="startUtc">Inclusive lower bound on <c>EventTimeUtc</c>.</param>
/// <param name="endUtc">Exclusive upper bound on <c>EventTimeUtc</c>.</param>
/// <param name="maxEvents">Upper cap on returned events — the driver's backend enforces this.</param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <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
/// with the default and let callers see <c>BadHistoryOperationUnsupported</c>.
/// </remarks>
Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
DateTime endUtc,
int maxEvents,
CancellationToken cancellationToken)
=> throw new NotSupportedException(
$"{GetType().Name} does not implement ReadEventsAsync. " +
"Drivers whose backends have an event historian override this method.");
}
/// <summary>Result of a HistoryRead call.</summary>
@@ -48,3 +94,29 @@ public enum HistoryAggregateType
Total,
Count,
}
/// <summary>
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
/// </summary>
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
/// <param name="Message">Human-readable message text.</param>
/// <param name="Severity">OPC UA severity (1-1000). Drivers map their native priority scale onto this range.</param>
public sealed record HistoricalEvent(
string EventId,
string? SourceName,
DateTime EventTimeUtc,
DateTime ReceivedTimeUtc,
string? Message,
ushort Severity);
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
public sealed record HistoricalEventsResult(
IReadOnlyList<HistoricalEvent> Events,
byte[]? ContinuationPoint);

View File

@@ -24,6 +24,17 @@ public sealed class DriverHost : IAsyncDisposable
return _drivers.TryGetValue(driverInstanceId, out var d) ? d.GetHealth() : null;
}
/// <summary>
/// Look up a registered driver by instance id. Used by the OPC UA server runtime
/// (<c>OtOpcUaServer</c>) to instantiate one <c>DriverNodeManager</c> per driver at
/// startup. Returns null when the driver is not registered.
/// </summary>
public IDriver? GetDriver(string driverInstanceId)
{
lock (_lock)
return _drivers.TryGetValue(driverInstanceId, out var d) ? d : null;
}
/// <summary>
/// Registers the driver and calls <see cref="IDriver.InitializeAsync"/>. If initialization
/// throws, the driver is kept in the registry so the operator can retry; quality on its

View File

@@ -1,27 +1,41 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// <summary>
/// Generic, driver-agnostic backbone for populating the OPC UA address space from an
/// <see cref="IDriver"/>. The Galaxy-specific subclass (<c>GalaxyNodeManager</c>) is deferred
/// to Phase 2 per decision #62 — this class is the foundation that Phase 2 ports the v1
/// <c>LmxNodeManager</c> logic into.
/// <see cref="IDriver"/>. Walks the driver's discovery, wires the alarm + data-change +
/// rediscovery subscription events, and hands each variable to the supplied
/// <see cref="IAddressSpaceBuilder"/>. Concrete OPC UA server implementations provide the
/// builder — see the Server project's <c>OpcUaAddressSpaceBuilder</c> for the materialization
/// against <c>CustomNodeManager2</c>.
/// </summary>
/// <remarks>
/// Phase 1 status: scaffold only. The v1 <c>LmxNodeManager</c> in the legacy Host is unchanged
/// so IntegrationTests continue to pass. Phase 2 will lift-and-shift its logic here, swapping
/// <c>IMxAccessClient</c> for <see cref="IDriver"/> and <c>GalaxyAttributeInfo</c> for
/// <see cref="DriverAttributeInfo"/>.
/// Per <c>docs/v2/plan.md</c> decision #52 + #62 — Core owns the node tree, drivers stream
/// <c>Folder</c>/<c>Variable</c> calls, alarm-bearing variables are annotated via
/// <see cref="IVariableHandle.MarkAsAlarmCondition"/> and subsequent
/// <see cref="IAlarmSource.OnAlarmEvent"/> payloads route to the sink the builder returned.
/// </remarks>
public abstract class GenericDriverNodeManager(IDriver driver)
public class GenericDriverNodeManager(IDriver driver) : IDisposable
{
protected IDriver Driver { get; } = driver ?? throw new ArgumentNullException(nameof(driver));
public string DriverInstanceId => Driver.DriverInstanceId;
// Source tag (DriverAttributeInfo.FullName) → alarm-condition sink. Populated during
// BuildAddressSpaceAsync by a recording IAddressSpaceBuilder implementation that captures the
// IVariableHandle per attr.IsAlarm=true variable and calls MarkAsAlarmCondition.
private readonly ConcurrentDictionary<string, IAlarmConditionSink> _alarmSinks =
new(StringComparer.OrdinalIgnoreCase);
private EventHandler<AlarmEventArgs>? _alarmForwarder;
private bool _disposed;
/// <summary>
/// Populates the address space by streaming nodes from the driver into the supplied builder.
/// Populates the address space by streaming nodes from the driver into the supplied builder,
/// wraps the builder so alarm-condition sinks are captured, subscribes to the driver's
/// alarm event stream, and routes each transition to the matching sink by <c>SourceNodeId</c>.
/// Driver exceptions are isolated per decision #12 — the driver's subtree is marked Faulted,
/// but other drivers remain available.
/// </summary>
@@ -32,6 +46,73 @@ public abstract class GenericDriverNodeManager(IDriver driver)
if (Driver is not ITagDiscovery discovery)
throw new NotSupportedException($"Driver '{Driver.DriverInstanceId}' does not implement ITagDiscovery.");
await discovery.DiscoverAsync(builder, ct);
var capturing = new CapturingBuilder(builder, _alarmSinks);
await discovery.DiscoverAsync(capturing, ct);
if (Driver is IAlarmSource alarmSource)
{
_alarmForwarder = (_, e) =>
{
// Route the alarm to the sink registered for the originating variable, if any.
// Unknown source ids are dropped silently — they may belong to another driver or
// to a variable the builder chose not to flag as an alarm condition.
if (_alarmSinks.TryGetValue(e.SourceNodeId, out var sink))
sink.OnTransition(e);
};
alarmSource.OnAlarmEvent += _alarmForwarder;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_alarmForwarder is not null && Driver is IAlarmSource alarmSource)
{
alarmSource.OnAlarmEvent -= _alarmForwarder;
}
_alarmSinks.Clear();
}
/// <summary>
/// Snapshot the current alarm-sink registry by source node id. Diagnostic + test hook;
/// not part of the hot path.
/// </summary>
internal IReadOnlyCollection<string> TrackedAlarmSources => _alarmSinks.Keys.ToList();
/// <summary>
/// Wraps the caller-supplied <see cref="IAddressSpaceBuilder"/> so every
/// <see cref="IVariableHandle.MarkAsAlarmCondition"/> call registers the returned sink in
/// the node manager's source-node-id map. The builder itself drives materialization;
/// this wrapper only observes.
/// </summary>
private sealed class CapturingBuilder(
IAddressSpaceBuilder inner,
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IAddressSpaceBuilder
{
public IAddressSpaceBuilder Folder(string browseName, string displayName)
=> new CapturingBuilder(inner.Folder(browseName, displayName), sinks);
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> new CapturingHandle(inner.Variable(browseName, displayName, attributeInfo), sinks);
public void AddProperty(string browseName, DriverDataType dataType, object? value)
=> inner.AddProperty(browseName, dataType, value);
}
private sealed class CapturingHandle(
IVariableHandle inner,
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IVariableHandle
{
public string FullReference => inner.FullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
var sink = inner.MarkAsAlarmCondition(info);
// Register by the driver-side full reference so the alarm forwarder can look it up
// using AlarmEventArgs.SourceNodeId (which the driver populates with the same tag).
sinks[inner.FullReference] = sink;
return sink;
}
}
}

View File

@@ -16,6 +16,10 @@
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>

View File

@@ -0,0 +1,260 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
/// <summary>
/// Subscribes to the four Galaxy alarm attributes (<c>.InAlarm</c>, <c>.Priority</c>,
/// <c>.DescAttrName</c>, <c>.Acked</c>) per alarm-bearing attribute discovered during
/// <c>DiscoverAsync</c>. Maintains one <see cref="AlarmState"/> per alarm, raises
/// <see cref="AlarmTransition"/> on lifecycle transitions (Active / Unacknowledged /
/// Acknowledged / Inactive). Ack path writes <c>.AckMsg</c>. Pure-logic state machine
/// with delegate-based subscribe/write so it's testable against in-memory fakes.
/// </summary>
/// <remarks>
/// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model):
/// <list type="bullet">
/// <item><c>Active</c> — InAlarm false → true. Default to Unacknowledged.</item>
/// <item><c>Acknowledged</c> — Acked false → true while InAlarm is still true.</item>
/// <item><c>Inactive</c> — InAlarm true → false. If still unacknowledged the alarm
/// is marked latched-inactive-unack; next Ack transitions straight to Inactive.</item>
/// </list>
/// </remarks>
public sealed class GalaxyAlarmTracker : IDisposable
{
public const string InAlarmAttr = ".InAlarm";
public const string PriorityAttr = ".Priority";
public const string DescAttrNameAttr = ".DescAttrName";
public const string AckedAttr = ".Acked";
public const string AckMsgAttr = ".AckMsg";
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
private readonly Func<string, Task> _unsubscribe;
private readonly Func<string, object, Task<bool>> _write;
private readonly Func<DateTime> _clock;
// Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state.
private readonly ConcurrentDictionary<string, AlarmState> _alarms =
new(StringComparer.OrdinalIgnoreCase);
// Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag.
private readonly ConcurrentDictionary<string, (string AlarmTag, AlarmField Field)> _probeToAlarm =
new(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
public event EventHandler<AlarmTransition>? TransitionRaised;
public GalaxyAlarmTracker(
Func<string, Action<string, Vtq>, Task> subscribe,
Func<string, Task> unsubscribe,
Func<string, object, Task<bool>> write)
: this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { }
internal GalaxyAlarmTracker(
Func<string, Action<string, Vtq>, Task> subscribe,
Func<string, Task> unsubscribe,
Func<string, object, Task<bool>> write,
Func<DateTime> clock)
{
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
_write = write ?? throw new ArgumentNullException(nameof(write));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public int TrackedAlarmCount => _alarms.Count;
/// <summary>
/// Advise the four alarm attributes for <paramref name="alarmTag"/>. Idempotent —
/// repeat calls for the same alarm tag are a no-op. Subscribe failure for any of the
/// four rolls back the alarm entry so a stale callback cannot promote a phantom.
/// </summary>
public async Task TrackAsync(string alarmTag)
{
if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) return;
if (_alarms.ContainsKey(alarmTag)) return;
var state = new AlarmState { AlarmTag = alarmTag };
if (!_alarms.TryAdd(alarmTag, state)) return;
var probes = new[]
{
(Tag: alarmTag + InAlarmAttr, Field: AlarmField.InAlarm),
(Tag: alarmTag + PriorityAttr, Field: AlarmField.Priority),
(Tag: alarmTag + DescAttrNameAttr, Field: AlarmField.DescAttrName),
(Tag: alarmTag + AckedAttr, Field: AlarmField.Acked),
};
foreach (var p in probes)
{
_probeToAlarm[p.Tag] = (alarmTag, p.Field);
}
try
{
foreach (var p in probes)
{
await _subscribe(p.Tag, OnProbeCallback).ConfigureAwait(false);
}
}
catch
{
// Rollback so a partial advise doesn't leak state.
_alarms.TryRemove(alarmTag, out _);
foreach (var p in probes)
{
_probeToAlarm.TryRemove(p.Tag, out _);
try { await _unsubscribe(p.Tag).ConfigureAwait(false); } catch { }
}
throw;
}
}
/// <summary>
/// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort.
/// </summary>
public async Task ClearAsync()
{
_alarms.Clear();
foreach (var kv in _probeToAlarm.ToList())
{
_probeToAlarm.TryRemove(kv.Key, out _);
try { await _unsubscribe(kv.Key).ConfigureAwait(false); } catch { }
}
}
/// <summary>
/// Operator ack — write the comment text into <c>&lt;alarmTag&gt;.AckMsg</c>.
/// Returns false when the runtime reports the write failed.
/// </summary>
public Task<bool> AcknowledgeAsync(string alarmTag, string comment)
{
if (_disposed || string.IsNullOrWhiteSpace(alarmTag))
return Task.FromResult(false);
return _write(alarmTag + AckMsgAttr, comment ?? string.Empty);
}
/// <summary>
/// Subscription callback entry point. Exposed for tests and for the Backend to route
/// fan-out callbacks through. Runs the state machine and fires TransitionRaised
/// outside the lock.
/// </summary>
public void OnProbeCallback(string probeTag, Vtq vtq)
{
if (_disposed) return;
if (!_probeToAlarm.TryGetValue(probeTag, out var link)) return;
if (!_alarms.TryGetValue(link.AlarmTag, out var state)) return;
AlarmTransition? transition = null;
var now = _clock();
lock (state.Lock)
{
switch (link.Field)
{
case AlarmField.InAlarm:
{
var wasActive = state.InAlarm;
var isActive = vtq.Value is bool b && b;
state.InAlarm = isActive;
state.LastUpdateUtc = now;
if (!wasActive && isActive)
{
state.Acked = false;
state.LastTransitionUtc = now;
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Active, state.Priority, state.DescAttrName, now);
}
else if (wasActive && !isActive)
{
state.LastTransitionUtc = now;
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Inactive, state.Priority, state.DescAttrName, now);
}
break;
}
case AlarmField.Priority:
if (vtq.Value is int pi) state.Priority = pi;
else if (vtq.Value is short ps) state.Priority = ps;
else if (vtq.Value is long pl && pl <= int.MaxValue) state.Priority = (int)pl;
state.LastUpdateUtc = now;
break;
case AlarmField.DescAttrName:
state.DescAttrName = vtq.Value as string;
state.LastUpdateUtc = now;
break;
case AlarmField.Acked:
{
var wasAcked = state.Acked;
var isAcked = vtq.Value is bool b && b;
state.Acked = isAcked;
state.LastUpdateUtc = now;
// Fire Acknowledged only when transitioning false→true. Don't fire on initial
// subscribe callback (wasAcked==isAcked in that case because the state starts
// with Acked=false and the initial probe is usually true for an un-active alarm).
if (!wasAcked && isAcked && state.InAlarm)
{
state.LastTransitionUtc = now;
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Acknowledged, state.Priority, state.DescAttrName, now);
}
break;
}
}
}
if (transition is { } t)
{
TransitionRaised?.Invoke(this, t);
}
}
public IReadOnlyList<AlarmSnapshot> SnapshotStates()
{
return _alarms.Values.Select(s =>
{
lock (s.Lock)
return new AlarmSnapshot(s.AlarmTag, s.InAlarm, s.Acked, s.Priority, s.DescAttrName);
}).ToList();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_alarms.Clear();
_probeToAlarm.Clear();
}
private sealed class AlarmState
{
public readonly object Lock = new();
public string AlarmTag = "";
public bool InAlarm;
public bool Acked = true; // default ack'd so first false→true on subscribe doesn't misfire
public int Priority;
public string? DescAttrName;
public DateTime LastUpdateUtc;
public DateTime LastTransitionUtc;
}
private enum AlarmField { InAlarm, Priority, DescAttrName, Acked }
}
public enum AlarmStateTransition { Active, Acknowledged, Inactive }
public sealed record AlarmTransition(
string AlarmTag,
AlarmStateTransition Transition,
int Priority,
string? DescAttrName,
DateTime AtUtc);
public sealed record AlarmSnapshot(
string AlarmTag,
bool InAlarm,
bool Acked,
int Priority,
string? DescAttrName);

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
@@ -35,14 +36,18 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
_refToSubs = new(System.StringComparer.OrdinalIgnoreCase);
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
#pragma warning disable CS0067 // alarm wire-up deferred to PR 9
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
#pragma warning restore CS0067
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
private readonly System.EventHandler<bool> _onConnectionStateChanged;
private readonly GalaxyRuntimeProbeManager _probeManager;
private readonly System.EventHandler<HostStateTransition> _onProbeStateChanged;
private readonly GalaxyAlarmTracker _alarmTracker;
private readonly System.EventHandler<AlarmTransition> _onAlarmTransition;
// Cached during DiscoverAsync so SubscribeAlarmsAsync knows which attributes to advise.
// One entry per IsAlarm=true attribute in the last discovered hierarchy.
private readonly System.Collections.Concurrent.ConcurrentBag<string> _discoveredAlarmTags = new();
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
{
@@ -89,6 +94,32 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
});
};
_probeManager.StateChanged += _onProbeStateChanged;
// PR 14: alarm subsystem. Per IsAlarm=true attribute discovered, subscribe to the four
// alarm-state attributes (.InAlarm/.Priority/.DescAttrName/.Acked), track lifecycle,
// and raise GalaxyAlarmEvent on transitions — forwarded through the existing
// OnAlarmEvent IPC event that the PR 4 ConnectionSink already wires into AlarmEvent frames.
_alarmTracker = new GalaxyAlarmTracker(
subscribe: (tag, cb) => _mx.SubscribeAsync(tag, cb),
unsubscribe: tag => _mx.UnsubscribeAsync(tag),
write: (tag, v) => _mx.WriteAsync(tag, v));
_onAlarmTransition = (_, t) => OnAlarmEvent?.Invoke(this, new GalaxyAlarmEvent
{
EventId = Guid.NewGuid().ToString("N"),
ObjectTagName = t.AlarmTag,
AlarmName = t.AlarmTag,
Severity = t.Priority,
StateTransition = t.Transition switch
{
AlarmStateTransition.Active => "Active",
AlarmStateTransition.Acknowledged => "Acknowledged",
AlarmStateTransition.Inactive => "Inactive",
_ => "Unknown",
},
Message = t.DescAttrName ?? t.AlarmTag,
UtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
});
_alarmTracker.TransitionRaised += _onAlarmTransition;
}
/// <summary>
@@ -137,6 +168,19 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty<GalaxyAttributeInfo>(),
}).ToArray();
// PR 14: cache alarm-bearing attribute full refs so SubscribeAlarmsAsync can advise
// them on demand. Format matches the Galaxy reference grammar <tag>.<attr>.
var freshAlarmTags = attributes
.Where(a => a.IsAlarm)
.Select(a => nameByGobject.TryGetValue(a.GobjectId, out var tn)
? tn + "." + a.AttributeName
: null)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Cast<string>()
.ToArray();
while (_discoveredAlarmTags.TryTake(out _)) { }
foreach (var t in freshAlarmTags) _discoveredAlarmTags.Add(t);
// PR 13: Sync the per-platform probe manager against the just-discovered hierarchy
// so ScanState subscriptions track the current runtime set. Best-effort — probe
// failures don't block Discover from returning, since the gateway-level signal from
@@ -289,8 +333,40 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
}
}
public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
/// <summary>
/// PR 14: advise every alarm-bearing attribute's 4-attr quartet. Best-effort per-alarm —
/// a subscribe failure on one alarm doesn't abort the whole call, since operators prefer
/// partial alarm coverage to none. Idempotent on repeat calls (tracker internally
/// skips already-tracked alarms).
/// </summary>
public async Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct)
{
foreach (var tag in _discoveredAlarmTags)
{
try { await _alarmTracker.TrackAsync(tag).ConfigureAwait(false); }
catch { /* swallow per-alarm — tracker rolls back its own state on failure */ }
}
}
/// <summary>
/// PR 14: route operator ack through the tracker's AckMsg write path. EventId on the
/// incoming request maps directly to the alarm full reference (Proxy-side naming
/// convention from GalaxyProxyDriver.RaiseAlarmEvent → ev.EventId).
/// </summary>
public async Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct)
{
// EventId carries a per-transition Guid.ToString("N"); there's no reverse map from
// event id to alarm tag yet, so v1's convention (ack targets the condition) is matched
// by reading the alarm name from the Comment envelope: v1 packed "<tag>|<comment>".
// Until the Proxy is updated to send the alarm tag separately, fall back to treating
// the EventId as the alarm tag — Client CLI passes it through unchanged.
var tag = req.EventId;
if (!string.IsNullOrWhiteSpace(tag))
{
try { await _alarmTracker.AcknowledgeAsync(tag, req.Comment ?? string.Empty).ConfigureAwait(false); }
catch { /* swallow — ack failures surface via MxAccessClient.WriteAsync logs */ }
}
}
public async Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
{
@@ -454,6 +530,8 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
public void Dispose()
{
_alarmTracker.TransitionRaised -= _onAlarmTransition;
_alarmTracker.Dispose();
_probeManager.StateChanged -= _onProbeStateChanged;
_probeManager.Dispose();
_mx.ConnectionStateChanged -= _onConnectionStateChanged;

View File

@@ -114,17 +114,31 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
var folder = builder.Folder(obj.ContainedName, obj.ContainedName);
foreach (var attr in obj.Attributes)
{
folder.Variable(
var fullName = $"{obj.TagName}.{attr.AttributeName}";
var handle = folder.Variable(
attr.AttributeName,
attr.AttributeName,
new DriverAttributeInfo(
FullName: $"{obj.TagName}.{attr.AttributeName}",
FullName: fullName,
DriverDataType: MapDataType(attr.MxDataType),
IsArray: attr.IsArray,
ArrayDim: attr.ArrayDim,
SecurityClass: MapSecurity(attr.SecurityClassification),
IsHistorized: attr.IsHistorized,
IsAlarm: attr.IsAlarm));
// PR 15: when Galaxy flags the attribute as alarm-bearing (AlarmExtension
// primitive), register an alarm-condition sink so the generic node manager
// can route OnAlarmEvent payloads for this tag to the concrete address-space
// builder. Severity default Medium — the live severity arrives through
// AlarmEventArgs once MxAccessGalaxyBackend's tracker starts firing.
if (attr.IsAlarm)
{
handle.MarkAsAlarmCondition(new AlarmConditionInfo(
SourceName: fullName,
InitialSeverity: AlarmSeverity.Medium,
InitialDescription: null));
}
}
}
}
@@ -325,6 +339,64 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
return new HistoryReadResult(samples, ContinuationPoint: null);
}
public async Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
var client = RequireClient();
var resp = await client.CallAsync<HistoryReadAtTimeRequest, HistoryReadAtTimeResponse>(
MessageKind.HistoryReadAtTimeRequest,
new HistoryReadAtTimeRequest
{
SessionId = _sessionId,
TagReference = fullReference,
TimestampsUtcUnixMs = [.. timestampsUtc.Select(t => new DateTimeOffset(t, TimeSpan.Zero).ToUnixTimeMilliseconds())],
},
MessageKind.HistoryReadAtTimeResponse,
cancellationToken);
if (!resp.Success)
throw new InvalidOperationException($"Galaxy.Host HistoryReadAtTime failed: {resp.Error}");
// ReadAtTime returns one sample per requested timestamp in the same order — the Host
// pads with bad-quality snapshots when a timestamp can't be interpolated, so response
// length matches request length exactly. We trust that contract rather than
// re-aligning here, because the Host is the source-of-truth for interpolation policy.
IReadOnlyList<DataValueSnapshot> samples = [.. resp.Values.Select(ToSnapshot)];
return new HistoryReadResult(samples, ContinuationPoint: null);
}
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
{
var client = RequireClient();
var resp = await client.CallAsync<HistoryReadEventsRequest, HistoryReadEventsResponse>(
MessageKind.HistoryReadEventsRequest,
new HistoryReadEventsRequest
{
SessionId = _sessionId,
SourceName = sourceName,
StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
MaxEvents = maxEvents,
},
MessageKind.HistoryReadEventsResponse,
cancellationToken);
if (!resp.Success)
throw new InvalidOperationException($"Galaxy.Host HistoryReadEvents failed: {resp.Error}");
IReadOnlyList<HistoricalEvent> events = [.. resp.Events.Select(ToHistoricalEvent)];
return new HistoricalEventsResult(events, ContinuationPoint: null);
}
internal static HistoricalEvent ToHistoricalEvent(GalaxyHistoricalEvent wire) => new(
EventId: wire.EventId,
SourceName: wire.SourceName,
EventTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.EventTimeUtcUnixMs).UtcDateTime,
ReceivedTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.ReceivedTimeUtcUnixMs).UtcDateTime,
Message: wire.DisplayText,
Severity: wire.Severity);
/// <summary>
/// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian
/// AnalogSummaryQuery column names consumed by <c>HistorianDataSource.ReadAggregateAsync</c>.

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Abstraction over the Modbus TCP socket. Takes a <c>PDU</c> (function code + data, excluding
/// the 7-byte MBAP header) and returns the response PDU — the transport owns transaction-id
/// pairing, framing, and socket I/O. Tests supply in-memory fakes.
/// </summary>
public interface IModbusTransport : IAsyncDisposable
{
Task ConnectAsync(CancellationToken ct);
/// <summary>
/// Send a Modbus PDU (function code + function-specific data) and read the response PDU.
/// Throws <see cref="ModbusException"/> when the server returns an exception PDU
/// (function code + 0x80 + exception code).
/// </summary>
Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct);
}
public sealed class ModbusException(byte functionCode, byte exceptionCode, string message)
: Exception(message)
{
public byte FunctionCode { get; } = functionCode;
public byte ExceptionCode { get; } = exceptionCode;
}

View File

@@ -0,0 +1,583 @@
using System.Buffers.Binary;
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
/// <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
/// driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
/// </summary>
/// <remarks>
/// Scope limits: synchronous Read/Write only, no subscriptions (Modbus has no push model;
/// subscriptions would need a polling loop over the declared tags — additive PR). Historian
/// + alarm capabilities are out of scope (the protocol doesn't express them).
/// </remarks>
public sealed class ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
// Active polling subscriptions. Each subscription owns a background Task that polls the
// tags at its configured interval, diffs against _lastKnownValues, and fires OnDataChange
// per changed tag. UnsubscribeAsync cancels the task via the CTS stored on the handle.
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
private long _nextSubscriptionId;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
// display the PLC endpoint uniformly with Galaxy platforms/engines.
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
private readonly ModbusDriverOptions _options = options;
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout));
private IModbusTransport? _transport;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
public string DriverInstanceId => driverInstanceId;
public string DriverType => "Modbus";
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
_transport = _transportFactory(_options);
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
// PR 23: kick off the probe loop once the transport is up. Initial state stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
// Running transition before any register round-trip has happened.
if (_options.Probe.Enabled)
{
_probeCts = new CancellationTokenSource();
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
}
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken);
await InitializeAsync(driverConfigJson, cancellationToken);
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
try { _probeCts?.Cancel(); } catch { }
_probeCts?.Dispose();
_probeCts = null;
foreach (var state in _subscriptions.Values)
{
try { state.Cts.Cancel(); } catch { }
state.Cts.Dispose();
}
_subscriptions.Clear();
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
_transport = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
public DriverHealth GetHealth() => _health;
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("Modbus", "Modbus");
foreach (var t in _options.Tags)
{
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false));
}
return Task.CompletedTask;
}
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var transport = RequireTransport();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
if (!_tagsByName.TryGetValue(fullReferences[i], out var tag))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue;
}
try
{
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
return results;
}
private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
{
switch (tag.Region)
{
case ModbusRegion.Coils:
{
var pdu = new byte[] { 0x01, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
return (resp[2] & 0x01) == 1;
}
case ModbusRegion.DiscreteInputs:
{
var pdu = new byte[] { 0x02, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
return (resp[2] & 0x01) == 1;
}
case ModbusRegion.HoldingRegisters:
case ModbusRegion.InputRegisters:
{
var quantity = RegisterCount(tag);
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
// resp = [fc][byte-count][data...]
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
return DecodeRegister(data, tag);
}
default:
throw new InvalidOperationException($"Unknown region {tag.Region}");
}
}
// ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
var transport = RequireTransport();
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
var w = writes[i];
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
{
results[i] = new WriteResult(StatusBadNodeIdUnknown);
continue;
}
if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
{
results[i] = new WriteResult(StatusBadNotWritable);
continue;
}
try
{
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(0u);
}
catch (Exception)
{
results[i] = new WriteResult(StatusBadInternalError);
}
}
return results;
}
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
{
switch (tag.Region)
{
case ModbusRegion.Coils:
{
var on = Convert.ToBoolean(value);
var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
on ? (byte)0xFF : (byte)0x00, 0x00 };
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
return;
}
case ModbusRegion.HoldingRegisters:
{
var bytes = EncodeRegister(value, tag);
if (bytes.Length == 2)
{
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
bytes[0], bytes[1] };
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
}
else
{
// FC 16 (Write Multiple Registers) for 32-bit types
var qty = (ushort)(bytes.Length / 2);
var pdu = new byte[6 + 1 + bytes.Length];
pdu[0] = 0x10;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
pdu[5] = (byte)bytes.Length;
Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
}
return;
}
default:
throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
}
}
// ---- ISubscribable (polling overlay) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextSubscriptionId);
var cts = new CancellationTokenSource();
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
? TimeSpan.FromMilliseconds(100) // floor — Modbus can't sustain < 100ms polling reliably
: publishingInterval;
var handle = new ModbusSubscriptionHandle(id);
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
_subscriptions[id] = state;
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
return Task.FromResult<ISubscriptionHandle>(handle);
}
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is ModbusSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
{
state.Cts.Cancel();
state.Cts.Dispose();
}
return Task.CompletedTask;
}
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
{
// Initial-data push: read every tag once at subscribe time so OPC UA clients see the
// current value per Part 4 convention, even if the value never changes thereafter.
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* first-read error — polling continues */ }
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* transient polling error — loop continues, health surface reflects it */ }
}
}
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
{
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
for (var i = 0; i < state.TagReferences.Count; i++)
{
var tagRef = state.TagReferences[i];
var current = snapshots[i];
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
// Raise on first read (forceRaise) OR when the boxed value differs from last-known.
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
{
state.LastValues[tagRef] = current;
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
}
}
}
private sealed record SubscriptionState(
ModbusSubscriptionHandle Handle,
IReadOnlyList<string> TagReferences,
TimeSpan Interval,
CancellationTokenSource Cts)
{
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
= new(StringComparer.OrdinalIgnoreCase);
}
private sealed record ModbusSubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"modbus-sub-{Id}";
}
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
/// <summary>
/// Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
/// Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
/// by endpoint without needing the driver-instance-id in the Admin dashboard.
/// </summary>
public string HostName => $"{_options.Host}:{_options.Port}";
private async Task ProbeLoopAsync(CancellationToken ct)
{
var transport = _transport; // captured reference; disposal tears the loop down via ct
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
var pdu = new byte[] { 0x03,
(byte)(_options.Probe.ProbeAddress >> 8),
(byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
_ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
success = true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch
{
// transport / timeout / exception PDU — treated as Stopped below
}
TransitionTo(success ? HostState.Running : HostState.Stopped);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
private void TransitionTo(HostState newState)
{
HostState old;
lock (_probeLock)
{
old = _hostState;
if (old == newState) return;
_hostState = newState;
_hostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
}
// ---- codec ----
/// <summary>
/// How many 16-bit registers a given tag occupies. Accounts for multi-register logical
/// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
/// from 2 chars per register).
/// </summary>
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
{
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1,
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
};
/// <summary>
/// Word-swap the input into the big-endian layout the decoders expect. For 2-register
/// types this reverses the two words; for 4-register types it reverses the four words
/// (PLC stored [hi-mid, low-mid, hi-high, low-high] → memory [hi-high, low-high, hi-mid, low-mid]).
/// </summary>
private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
{
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
var result = new byte[data.Length];
for (var word = 0; word < data.Length / 2; word++)
{
var srcWord = data.Length / 2 - 1 - word;
result[word * 2] = data[srcWord * 2];
result[word * 2 + 1] = data[srcWord * 2 + 1];
}
return result;
}
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
{
switch (tag.DataType)
{
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
case ModbusDataType.BitInRegister:
{
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
return (raw & (1 << tag.BitIndex)) != 0;
}
case ModbusDataType.Int32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadInt32BigEndian(b);
}
case ModbusDataType.UInt32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadUInt32BigEndian(b);
}
case ModbusDataType.Float32:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadSingleBigEndian(b);
}
case ModbusDataType.Int64:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadInt64BigEndian(b);
}
case ModbusDataType.UInt64:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadUInt64BigEndian(b);
}
case ModbusDataType.Float64:
{
var b = NormalizeWordOrder(data, tag.ByteOrder);
return BinaryPrimitives.ReadDoubleBigEndian(b);
}
case ModbusDataType.String:
{
// ASCII, 2 chars per register, packed high byte = first char.
// Respect the caller's StringLength (truncate nul-padded regions).
var chars = new char[tag.StringLength];
for (var i = 0; i < tag.StringLength; i++)
{
var b = data[i];
if (b == 0) { return new string(chars, 0, i); }
chars[i] = (char)b;
}
return new string(chars);
}
default:
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
}
}
internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
{
switch (tag.DataType)
{
case ModbusDataType.Int16:
{
var v = Convert.ToInt16(value);
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
}
case ModbusDataType.UInt16:
{
var v = Convert.ToUInt16(value);
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
}
case ModbusDataType.Int32:
{
var v = Convert.ToInt32(value);
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.UInt32:
{
var v = Convert.ToUInt32(value);
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Float32:
{
var v = Convert.ToSingle(value);
var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Int64:
{
var v = Convert.ToInt64(value);
var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.UInt64:
{
var v = Convert.ToUInt64(value);
var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.Float64:
{
var v = Convert.ToDouble(value);
var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
return NormalizeWordOrder(b, tag.ByteOrder);
}
case ModbusDataType.String:
{
var s = Convert.ToString(value) ?? string.Empty;
var regs = (tag.StringLength + 1) / 2;
var b = new byte[regs * 2];
for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i];
// remaining bytes stay 0 — nul-padded per PLC convention
return b;
}
case ModbusDataType.BitInRegister:
throw new InvalidOperationException(
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
default:
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
}
}
private static DriverDataType MapDataType(ModbusDataType t) => t switch
{
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType
ModbusDataType.Float32 => DriverDataType.Float32,
ModbusDataType.Float64 => DriverDataType.Float64,
ModbusDataType.String => DriverDataType.String,
_ => DriverDataType.Int32,
};
private IModbusTransport RequireTransport() =>
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadNodeIdUnknown = 0x80340000u;
private const uint StatusBadNotWritable = 0x803B0000u;
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
_transport = null;
}
}

View File

@@ -0,0 +1,97 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus TCP driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
/// <c>DriverHost.RegisterAsync</c>. Every register the driver exposes appears in
/// <see cref="Tags"/>; names become the OPC UA browse name + full reference.
/// </summary>
public sealed class ModbusDriverOptions
{
public string Host { get; init; } = "127.0.0.1";
public int Port { get; init; } = 502;
public byte UnitId { get; init; } = 1;
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these.</summary>
public IReadOnlyList<ModbusTagDefinition> Tags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When <see cref="ModbusProbeOptions.Enabled"/>
/// is true the driver runs a tick loop that issues a cheap FC03 at register 0 every
/// <see cref="ModbusProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
/// Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through
/// <see cref="IHostConnectivityProbe"/>.
/// </summary>
public ModbusProbeOptions Probe { get; init; } = new();
}
public sealed class ModbusProbeOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Register to read for the probe. Zero is usually safe; override for PLCs that lock register 0.</summary>
public ushort ProbeAddress { get; init; } = 0;
}
/// <summary>
/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
/// the documentation's 1-based coil/register conventions). Multi-register types
/// (Int32/UInt32/Float32 = 2 regs; Int64/UInt64/Float64 = 4 regs) respect the
/// <see cref="ByteOrder"/> field — real-world PLCs disagree on word ordering.
/// </summary>
/// <param name="Name">
/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
/// unique within the driver.
/// </param>
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
/// <param name="Address">Zero-based address within the region.</param>
/// <param name="DataType">
/// Logical data type. See <see cref="ModbusDataType"/> for the register count each encodes.
/// </param>
/// <param name="Writable">When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here.</param>
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
public sealed record ModbusTagDefinition(
string Name,
ModbusRegion Region,
ushort Address,
ModbusDataType DataType,
bool Writable = true,
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
byte BitIndex = 0,
ushort StringLength = 0);
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
public enum ModbusDataType
{
Bool,
Int16,
UInt16,
Int32,
UInt32,
Int64,
UInt64,
Float32,
Float64,
/// <summary>Single bit within a holding register. <see cref="ModbusTagDefinition.BitIndex"/> selects 0-15 LSB-first.</summary>
BitInRegister,
/// <summary>ASCII string packed 2 chars per register, <see cref="ModbusTagDefinition.StringLength"/> characters long.</summary>
String,
}
/// <summary>
/// Word ordering for multi-register types. Modbus TCP standard is <see cref="BigEndian"/>
/// (ABCD for 32-bit: high word at the lower address). Many PLCs — Siemens S7, several
/// Allen-Bradley series, some Modicon families — use <see cref="WordSwap"/> (CDAB), which
/// keeps bytes big-endian within each register but reverses the word pair(s).
/// </summary>
public enum ModbusByteOrder
{
BigEndian,
WordSwap,
}

View File

@@ -0,0 +1,113 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
/// support concurrent transactions, but the single-flight model keeps the wire trace
/// easy to diagnose and avoids interleaved-response correlation bugs.
/// </summary>
public sealed class ModbusTcpTransport : IModbusTransport
{
private readonly string _host;
private readonly int _port;
private readonly TimeSpan _timeout;
private readonly SemaphoreSlim _gate = new(1, 1);
private TcpClient? _client;
private NetworkStream? _stream;
private ushort _nextTx;
private bool _disposed;
public ModbusTcpTransport(string host, int port, TimeSpan timeout)
{
_host = host;
_port = port;
_timeout = timeout;
}
public async Task ConnectAsync(CancellationToken ct)
{
_client = new TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
}
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
if (_stream is null) throw new InvalidOperationException("Transport not connected");
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
var txId = ++_nextTx;
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
var adu = new byte[7 + pdu.Length];
adu[0] = (byte)(txId >> 8);
adu[1] = (byte)(txId & 0xFF);
// protocol id already zero
var len = (ushort)(1 + pdu.Length); // unit id + pdu
adu[4] = (byte)(len >> 8);
adu[5] = (byte)(len & 0xFF);
adu[6] = unitId;
Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
var header = new byte[7];
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
var respTxId = (ushort)((header[0] << 8) | header[1]);
if (respTxId != txId)
throw new InvalidDataException($"Modbus TxId mismatch: expected {txId} got {respTxId}");
var respLen = (ushort)((header[4] << 8) | header[5]);
if (respLen < 1) throw new InvalidDataException($"Modbus response length too small: {respLen}");
var respPdu = new byte[respLen - 1];
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
// Exception PDU: function code has high bit set.
if ((respPdu[0] & 0x80) != 0)
{
var fc = (byte)(respPdu[0] & 0x7F);
var ex = respPdu[1];
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
}
return respPdu;
}
finally
{
_gate.Release();
}
}
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
{
var read = 0;
while (read < buf.Length)
{
var n = await s.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
if (n == 0) throw new EndOfStreamException("Modbus socket closed mid-response");
read += n;
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try
{
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
}
catch { /* best-effort */ }
_client?.Dispose();
_gate.Dispose();
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -1,15 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Historian;
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
{
/// <summary>
/// Reflection entry point invoked by <c>HistorianPluginLoader</c> in the Host. Kept
/// deliberately simple so the plugin contract is a single static factory method.
/// </summary>
public static class AvevaHistorianPluginEntry
{
public static IHistorianDataSource Create(HistorianConfiguration config)
=> new HistorianDataSource(config);
}
}

View File

@@ -1,181 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Historian;
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
{
/// <summary>
/// Thread-safe, pure-logic endpoint picker for the Wonderware Historian cluster. Tracks which
/// configured nodes are healthy, places failed nodes in a time-bounded cooldown, and hands
/// out an ordered list of eligible candidates for the data source to try in sequence.
/// </summary>
/// <remarks>
/// Design notes:
/// <list type="bullet">
/// <item>No SDK dependency — fully unit-testable with an injected clock.</item>
/// <item>Per-node state is guarded by a single lock; operations are microsecond-scale
/// so contention is a non-issue.</item>
/// <item>Cooldown is purely passive: a node re-enters the healthy pool the next time
/// it is queried after its cooldown window elapses. There is no background probe.</item>
/// <item>Nodes are returned in configuration order so operators can express a
/// preference (primary first, fallback second).</item>
/// <item>When <see cref="HistorianConfiguration.ServerNames"/> is empty, the picker is
/// initialized with a single entry from <see cref="HistorianConfiguration.ServerName"/>
/// so legacy deployments continue to work unchanged.</item>
/// </list>
/// </remarks>
internal sealed class HistorianClusterEndpointPicker
{
private readonly Func<DateTime> _clock;
private readonly TimeSpan _cooldown;
private readonly object _lock = new object();
private readonly List<NodeEntry> _nodes;
public HistorianClusterEndpointPicker(HistorianConfiguration config)
: this(config, () => DateTime.UtcNow) { }
internal HistorianClusterEndpointPicker(HistorianConfiguration config, Func<DateTime> clock)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_cooldown = TimeSpan.FromSeconds(Math.Max(0, config.FailureCooldownSeconds));
var names = (config.ServerNames != null && config.ServerNames.Count > 0)
? config.ServerNames
: new List<string> { config.ServerName };
_nodes = names
.Where(n => !string.IsNullOrWhiteSpace(n))
.Select(n => n.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(n => new NodeEntry { Name = n })
.ToList();
}
/// <summary>
/// Gets the total number of configured cluster nodes. Stable — nodes are never added
/// or removed after construction.
/// </summary>
public int NodeCount
{
get
{
lock (_lock)
return _nodes.Count;
}
}
/// <summary>
/// Returns an ordered snapshot of nodes currently eligible for a connection attempt,
/// with any node whose cooldown has elapsed automatically restored to the pool.
/// An empty list means all nodes are in active cooldown.
/// </summary>
public IReadOnlyList<string> GetHealthyNodes()
{
lock (_lock)
{
var now = _clock();
return _nodes
.Where(n => IsHealthyAt(n, now))
.Select(n => n.Name)
.ToList();
}
}
/// <summary>
/// Gets the count of nodes currently eligible for a connection attempt (i.e., not in cooldown).
/// </summary>
public int HealthyNodeCount
{
get
{
lock (_lock)
{
var now = _clock();
return _nodes.Count(n => IsHealthyAt(n, now));
}
}
}
/// <summary>
/// Places <paramref name="node"/> into cooldown starting at the current clock time.
/// Increments the node's failure counter and stores the latest error message for
/// surfacing on the dashboard. Unknown node names are ignored.
/// </summary>
public void MarkFailed(string node, string? error)
{
lock (_lock)
{
var entry = FindEntry(node);
if (entry == null)
return;
var now = _clock();
entry.FailureCount++;
entry.LastError = error;
entry.LastFailureTime = now;
entry.CooldownUntil = _cooldown.TotalMilliseconds > 0 ? now + _cooldown : (DateTime?)null;
}
}
/// <summary>
/// Marks <paramref name="node"/> as healthy immediately — clears any active cooldown but
/// leaves the cumulative failure counter intact for operator diagnostics. Unknown node
/// names are ignored.
/// </summary>
public void MarkHealthy(string node)
{
lock (_lock)
{
var entry = FindEntry(node);
if (entry == null)
return;
entry.CooldownUntil = null;
}
}
/// <summary>
/// Captures the current per-node state for the health dashboard. Freshly computed from
/// <see cref="_clock"/> so recently-expired cooldowns are reported as healthy.
/// </summary>
public List<HistorianClusterNodeState> SnapshotNodeStates()
{
lock (_lock)
{
var now = _clock();
return _nodes.Select(n => new HistorianClusterNodeState
{
Name = n.Name,
IsHealthy = IsHealthyAt(n, now),
CooldownUntil = IsHealthyAt(n, now) ? null : n.CooldownUntil,
FailureCount = n.FailureCount,
LastError = n.LastError,
LastFailureTime = n.LastFailureTime
}).ToList();
}
}
private static bool IsHealthyAt(NodeEntry entry, DateTime now)
{
return entry.CooldownUntil == null || entry.CooldownUntil <= now;
}
private NodeEntry? FindEntry(string node)
{
for (var i = 0; i < _nodes.Count; i++)
if (string.Equals(_nodes[i].Name, node, StringComparison.OrdinalIgnoreCase))
return _nodes[i];
return null;
}
private sealed class NodeEntry
{
public string Name { get; set; } = "";
public DateTime? CooldownUntil { get; set; }
public int FailureCount { get; set; }
public string? LastError { get; set; }
public DateTime? LastFailureTime { get; set; }
}
}
}

View File

@@ -1,704 +0,0 @@
using System;
using System.Collections.Generic;
using StringCollection = System.Collections.Specialized.StringCollection;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.Historian;
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
{
/// <summary>
/// Reads historical data from the Wonderware Historian via the aahClientManaged SDK.
/// </summary>
public sealed class HistorianDataSource : IHistorianDataSource
{
private static readonly ILogger Log = Serilog.Log.ForContext<HistorianDataSource>();
private readonly HistorianConfiguration _config;
private readonly object _connectionLock = new object();
private readonly object _eventConnectionLock = new object();
private readonly IHistorianConnectionFactory _factory;
private HistorianAccess? _connection;
private HistorianAccess? _eventConnection;
private bool _disposed;
// Runtime query health state. Guarded by _healthLock — updated on every read
// method exit (success or failure) so the dashboard can distinguish "plugin
// loaded but never queried" from "plugin loaded and queries are failing".
private readonly object _healthLock = new object();
private long _totalSuccesses;
private long _totalFailures;
private int _consecutiveFailures;
private DateTime? _lastSuccessTime;
private DateTime? _lastFailureTime;
private string? _lastError;
private string? _activeProcessNode;
private string? _activeEventNode;
// Cluster endpoint picker — shared across process + event paths so a node that
// fails on one silo is skipped on the other. Initialized from config at construction.
private readonly HistorianClusterEndpointPicker _picker;
/// <summary>
/// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian SDK queries.
/// </summary>
/// <param name="config">The Historian SDK connection settings used for runtime history lookups.</param>
public HistorianDataSource(HistorianConfiguration config)
: this(config, new SdkHistorianConnectionFactory(), null) { }
/// <summary>
/// Initializes a Historian reader with a custom connection factory for testing. When
/// <paramref name="picker"/> is <see langword="null"/> a new picker is built from
/// <paramref name="config"/>, preserving backward compatibility with existing tests.
/// </summary>
internal HistorianDataSource(
HistorianConfiguration config,
IHistorianConnectionFactory factory,
HistorianClusterEndpointPicker? picker = null)
{
_config = config;
_factory = factory;
_picker = picker ?? new HistorianClusterEndpointPicker(config);
}
/// <summary>
/// Iterates the picker's healthy node list, cloning the configuration per attempt and
/// handing it to the factory. Marks each tried node as healthy on success or failed on
/// exception. Returns the winning connection + node name; throws when no nodes succeed.
/// </summary>
private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode(HistorianConnectionType type)
{
var candidates = _picker.GetHealthyNodes();
if (candidates.Count == 0)
{
var total = _picker.NodeCount;
throw new InvalidOperationException(
total == 0
? "No historian nodes configured"
: $"All {total} historian nodes are in cooldown — no healthy endpoints to connect to");
}
Exception? lastException = null;
foreach (var node in candidates)
{
var attemptConfig = CloneConfigWithServerName(node);
try
{
var conn = _factory.CreateAndConnect(attemptConfig, type);
_picker.MarkHealthy(node);
return (conn, node);
}
catch (Exception ex)
{
_picker.MarkFailed(node, ex.Message);
lastException = ex;
Log.Warning(ex,
"Historian node {Node} failed during connect attempt; trying next candidate", node);
}
}
var inner = lastException?.Message ?? "(no detail)";
throw new InvalidOperationException(
$"All {candidates.Count} healthy historian candidate(s) failed during connect: {inner}",
lastException);
}
private HistorianConfiguration CloneConfigWithServerName(string serverName)
{
return new HistorianConfiguration
{
Enabled = _config.Enabled,
ServerName = serverName,
ServerNames = _config.ServerNames,
FailureCooldownSeconds = _config.FailureCooldownSeconds,
IntegratedSecurity = _config.IntegratedSecurity,
UserName = _config.UserName,
Password = _config.Password,
Port = _config.Port,
CommandTimeoutSeconds = _config.CommandTimeoutSeconds,
MaxValuesPerRead = _config.MaxValuesPerRead
};
}
/// <inheritdoc />
public HistorianHealthSnapshot GetHealthSnapshot()
{
var nodeStates = _picker.SnapshotNodeStates();
var healthyCount = 0;
foreach (var n in nodeStates)
if (n.IsHealthy)
healthyCount++;
lock (_healthLock)
{
return new HistorianHealthSnapshot
{
TotalQueries = _totalSuccesses + _totalFailures,
TotalSuccesses = _totalSuccesses,
TotalFailures = _totalFailures,
ConsecutiveFailures = _consecutiveFailures,
LastSuccessTime = _lastSuccessTime,
LastFailureTime = _lastFailureTime,
LastError = _lastError,
ProcessConnectionOpen = Volatile.Read(ref _connection) != null,
EventConnectionOpen = Volatile.Read(ref _eventConnection) != null,
ActiveProcessNode = _activeProcessNode,
ActiveEventNode = _activeEventNode,
NodeCount = nodeStates.Count,
HealthyNodeCount = healthyCount,
Nodes = nodeStates
};
}
}
private void RecordSuccess()
{
lock (_healthLock)
{
_totalSuccesses++;
_lastSuccessTime = DateTime.UtcNow;
_consecutiveFailures = 0;
_lastError = null;
}
}
private void RecordFailure(string error)
{
lock (_healthLock)
{
_totalFailures++;
_lastFailureTime = DateTime.UtcNow;
_consecutiveFailures++;
_lastError = error;
}
}
private void EnsureConnected()
{
if (_disposed)
throw new ObjectDisposedException(nameof(HistorianDataSource));
// Fast path: already connected (no lock needed)
if (Volatile.Read(ref _connection) != null)
return;
// Create and wait for connection outside the lock so concurrent history
// requests are not serialized behind a slow Historian handshake. The cluster
// picker iterates configured nodes and returns the first that successfully connects.
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Process);
lock (_connectionLock)
{
if (_disposed)
{
conn.CloseConnection(out _);
conn.Dispose();
throw new ObjectDisposedException(nameof(HistorianDataSource));
}
if (_connection != null)
{
// Another thread connected while we were waiting
conn.CloseConnection(out _);
conn.Dispose();
return;
}
_connection = conn;
lock (_healthLock)
_activeProcessNode = winningNode;
Log.Information("Historian SDK connection opened to {Server}:{Port}", winningNode, _config.Port);
}
}
private void HandleConnectionError(Exception? ex = null)
{
lock (_connectionLock)
{
if (_connection == null)
return;
try
{
_connection.CloseConnection(out _);
_connection.Dispose();
}
catch (Exception disposeEx)
{
Log.Debug(disposeEx, "Error disposing Historian SDK connection during error recovery");
}
_connection = null;
string? failedNode;
lock (_healthLock)
{
failedNode = _activeProcessNode;
_activeProcessNode = null;
}
if (failedNode != null)
_picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
Log.Warning(ex, "Historian SDK connection reset (node={Node}) — will reconnect on next request",
failedNode ?? "(unknown)");
}
}
private void EnsureEventConnected()
{
if (_disposed)
throw new ObjectDisposedException(nameof(HistorianDataSource));
if (Volatile.Read(ref _eventConnection) != null)
return;
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Event);
lock (_eventConnectionLock)
{
if (_disposed)
{
conn.CloseConnection(out _);
conn.Dispose();
throw new ObjectDisposedException(nameof(HistorianDataSource));
}
if (_eventConnection != null)
{
conn.CloseConnection(out _);
conn.Dispose();
return;
}
_eventConnection = conn;
lock (_healthLock)
_activeEventNode = winningNode;
Log.Information("Historian SDK event connection opened to {Server}:{Port}",
winningNode, _config.Port);
}
}
private void HandleEventConnectionError(Exception? ex = null)
{
lock (_eventConnectionLock)
{
if (_eventConnection == null)
return;
try
{
_eventConnection.CloseConnection(out _);
_eventConnection.Dispose();
}
catch (Exception disposeEx)
{
Log.Debug(disposeEx, "Error disposing Historian SDK event connection during error recovery");
}
_eventConnection = null;
string? failedNode;
lock (_healthLock)
{
failedNode = _activeEventNode;
_activeEventNode = null;
}
if (failedNode != null)
_picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
Log.Warning(ex, "Historian SDK event connection reset (node={Node}) — will reconnect on next request",
failedNode ?? "(unknown)");
}
}
/// <inheritdoc />
public Task<List<DataValue>> ReadRawAsync(
string tagName, DateTime startTime, DateTime endTime, int maxValues,
CancellationToken ct = default)
{
var results = new List<DataValue>();
try
{
EnsureConnected();
using var query = _connection!.CreateHistoryQuery();
var args = new HistoryQueryArgs
{
TagNames = new StringCollection { tagName },
StartDateTime = startTime,
EndDateTime = endTime,
RetrievalMode = HistorianRetrievalMode.Full
};
if (maxValues > 0)
args.BatchSize = (uint)maxValues;
else if (_config.MaxValuesPerRead > 0)
args.BatchSize = (uint)_config.MaxValuesPerRead;
if (!query.StartQuery(args, out var error))
{
Log.Warning("Historian SDK raw query start failed for {Tag}: {Error}", tagName, error.ErrorCode);
RecordFailure($"raw StartQuery: {error.ErrorCode}");
HandleConnectionError();
return Task.FromResult(results);
}
var count = 0;
var limit = maxValues > 0 ? maxValues : _config.MaxValuesPerRead;
while (query.MoveNext(out error))
{
ct.ThrowIfCancellationRequested();
var result = query.QueryResult;
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
object? value;
if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
value = result.StringValue;
else
value = result.Value;
var quality = (byte)(result.OpcQuality & 0xFF);
results.Add(new DataValue
{
Value = new Variant(value),
SourceTimestamp = timestamp,
ServerTimestamp = timestamp,
StatusCode = QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality))
});
count++;
if (limit > 0 && count >= limit)
break;
}
query.EndQuery(out _);
RecordSuccess();
}
catch (OperationCanceledException)
{
throw;
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead raw failed for {Tag}", tagName);
RecordFailure($"raw: {ex.Message}");
HandleConnectionError(ex);
}
Log.Debug("HistoryRead raw: {Tag} returned {Count} values ({Start} to {End})",
tagName, results.Count, startTime, endTime);
return Task.FromResult(results);
}
/// <inheritdoc />
public Task<List<DataValue>> ReadAggregateAsync(
string tagName, DateTime startTime, DateTime endTime,
double intervalMs, string aggregateColumn,
CancellationToken ct = default)
{
var results = new List<DataValue>();
try
{
EnsureConnected();
using var query = _connection!.CreateAnalogSummaryQuery();
var args = new AnalogSummaryQueryArgs
{
TagNames = new StringCollection { tagName },
StartDateTime = startTime,
EndDateTime = endTime,
Resolution = (ulong)intervalMs
};
if (!query.StartQuery(args, out var error))
{
Log.Warning("Historian SDK aggregate query start failed for {Tag}: {Error}", tagName,
error.ErrorCode);
RecordFailure($"aggregate StartQuery: {error.ErrorCode}");
HandleConnectionError();
return Task.FromResult(results);
}
while (query.MoveNext(out error))
{
ct.ThrowIfCancellationRequested();
var result = query.QueryResult;
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
var value = ExtractAggregateValue(result, aggregateColumn);
results.Add(new DataValue
{
Value = new Variant(value),
SourceTimestamp = timestamp,
ServerTimestamp = timestamp,
StatusCode = value != null ? StatusCodes.Good : StatusCodes.BadNoData
});
}
query.EndQuery(out _);
RecordSuccess();
}
catch (OperationCanceledException)
{
throw;
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead aggregate failed for {Tag}", tagName);
RecordFailure($"aggregate: {ex.Message}");
HandleConnectionError(ex);
}
Log.Debug("HistoryRead aggregate ({Aggregate}): {Tag} returned {Count} values",
aggregateColumn, tagName, results.Count);
return Task.FromResult(results);
}
/// <inheritdoc />
public Task<List<DataValue>> ReadAtTimeAsync(
string tagName, DateTime[] timestamps,
CancellationToken ct = default)
{
var results = new List<DataValue>();
if (timestamps == null || timestamps.Length == 0)
return Task.FromResult(results);
try
{
EnsureConnected();
foreach (var timestamp in timestamps)
{
ct.ThrowIfCancellationRequested();
using var query = _connection!.CreateHistoryQuery();
var args = new HistoryQueryArgs
{
TagNames = new StringCollection { tagName },
StartDateTime = timestamp,
EndDateTime = timestamp,
RetrievalMode = HistorianRetrievalMode.Interpolated,
BatchSize = 1
};
if (!query.StartQuery(args, out var error))
{
results.Add(new DataValue
{
Value = Variant.Null,
SourceTimestamp = timestamp,
ServerTimestamp = timestamp,
StatusCode = StatusCodes.BadNoData
});
continue;
}
if (query.MoveNext(out error))
{
var result = query.QueryResult;
object? value;
if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
value = result.StringValue;
else
value = result.Value;
var quality = (byte)(result.OpcQuality & 0xFF);
results.Add(new DataValue
{
Value = new Variant(value),
SourceTimestamp = timestamp,
ServerTimestamp = timestamp,
StatusCode = QualityMapper.MapToOpcUaStatusCode(
QualityMapper.MapFromMxAccessQuality(quality))
});
}
else
{
results.Add(new DataValue
{
Value = Variant.Null,
SourceTimestamp = timestamp,
ServerTimestamp = timestamp,
StatusCode = StatusCodes.BadNoData
});
}
query.EndQuery(out _);
}
RecordSuccess();
}
catch (OperationCanceledException)
{
throw;
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead at-time failed for {Tag}", tagName);
RecordFailure($"at-time: {ex.Message}");
HandleConnectionError(ex);
}
Log.Debug("HistoryRead at-time: {Tag} returned {Count} values for {Timestamps} timestamps",
tagName, results.Count, timestamps.Length);
return Task.FromResult(results);
}
/// <inheritdoc />
public Task<List<HistorianEventDto>> ReadEventsAsync(
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
CancellationToken ct = default)
{
var results = new List<HistorianEventDto>();
try
{
EnsureEventConnected();
using var query = _eventConnection!.CreateEventQuery();
var args = new EventQueryArgs
{
StartDateTime = startTime,
EndDateTime = endTime,
EventCount = maxEvents > 0 ? (uint)maxEvents : (uint)_config.MaxValuesPerRead,
QueryType = HistorianEventQueryType.Events,
EventOrder = HistorianEventOrder.Ascending
};
if (!string.IsNullOrEmpty(sourceName))
{
query.AddEventFilter("Source", HistorianComparisionType.Equal, sourceName, out _);
}
if (!query.StartQuery(args, out var error))
{
Log.Warning("Historian SDK event query start failed: {Error}", error.ErrorCode);
RecordFailure($"events StartQuery: {error.ErrorCode}");
HandleEventConnectionError();
return Task.FromResult(results);
}
var count = 0;
while (query.MoveNext(out error))
{
ct.ThrowIfCancellationRequested();
results.Add(ToDto(query.QueryResult));
count++;
if (maxEvents > 0 && count >= maxEvents)
break;
}
query.EndQuery(out _);
RecordSuccess();
}
catch (OperationCanceledException)
{
throw;
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception ex)
{
Log.Warning(ex, "HistoryRead events failed for source {Source}", sourceName ?? "(all)");
RecordFailure($"events: {ex.Message}");
HandleEventConnectionError(ex);
}
Log.Debug("HistoryRead events: source={Source} returned {Count} events ({Start} to {End})",
sourceName ?? "(all)", results.Count, startTime, endTime);
return Task.FromResult(results);
}
private static HistorianEventDto ToDto(HistorianEvent evt)
{
return new HistorianEventDto
{
Id = evt.Id,
Source = evt.Source,
EventTime = evt.EventTime,
ReceivedTime = evt.ReceivedTime,
DisplayText = evt.DisplayText,
Severity = (ushort)evt.Severity
};
}
/// <summary>
/// Extracts the requested aggregate value from an <see cref="AnalogSummaryQueryResult"/> by column name.
/// </summary>
internal static double? ExtractAggregateValue(AnalogSummaryQueryResult result, string column)
{
switch (column)
{
case "Average": return result.Average;
case "Minimum": return result.Minimum;
case "Maximum": return result.Maximum;
case "ValueCount": return result.ValueCount;
case "First": return result.First;
case "Last": return result.Last;
case "StdDev": return result.StdDev;
default: return null;
}
}
/// <summary>
/// Closes the Historian SDK connection and releases resources.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
try
{
_connection?.CloseConnection(out _);
_connection?.Dispose();
}
catch (Exception ex)
{
Log.Warning(ex, "Error closing Historian SDK connection");
}
try
{
_eventConnection?.CloseConnection(out _);
_eventConnection?.Dispose();
}
catch (Exception ex)
{
Log.Warning(ex, "Error closing Historian SDK event connection");
}
_connection = null;
_eventConnection = null;
}
}
}

View File

@@ -1,81 +0,0 @@
using System;
using System.Threading;
using ArchestrA;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
{
/// <summary>
/// Creates and opens Historian SDK connections. Extracted so tests can inject
/// fakes that control connection success, failure, and timeout behavior.
/// </summary>
internal interface IHistorianConnectionFactory
{
/// <summary>
/// Creates a new Historian SDK connection, opens it, and waits until it is ready.
/// Throws on connection failure or timeout.
/// </summary>
HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type);
}
/// <summary>
/// Production implementation that creates real Historian SDK connections.
/// </summary>
internal sealed class SdkHistorianConnectionFactory : IHistorianConnectionFactory
{
public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type)
{
var conn = new HistorianAccess();
var args = new HistorianConnectionArgs
{
ServerName = config.ServerName,
TcpPort = (ushort)config.Port,
IntegratedSecurity = config.IntegratedSecurity,
UseArchestrAUser = config.IntegratedSecurity,
ConnectionType = type,
ReadOnly = true,
PacketTimeout = (uint)(config.CommandTimeoutSeconds * 1000)
};
if (!config.IntegratedSecurity)
{
args.UserName = config.UserName ?? string.Empty;
args.Password = config.Password ?? string.Empty;
}
if (!conn.OpenConnection(args, out var error))
{
conn.Dispose();
throw new InvalidOperationException(
$"Failed to open Historian SDK connection to {config.ServerName}:{config.Port}: {error.ErrorCode}");
}
// The SDK connects asynchronously — poll until the connection is ready
var timeoutMs = config.CommandTimeoutSeconds * 1000;
var elapsed = 0;
while (elapsed < timeoutMs)
{
var status = new HistorianConnectionStatus();
conn.GetConnectionStatus(ref status);
if (status.ConnectedToServer)
return conn;
if (status.ErrorOccurred)
{
conn.Dispose();
throw new InvalidOperationException(
$"Historian SDK connection failed: {status.Error}");
}
Thread.Sleep(250);
elapsed += 250;
}
conn.Dispose();
throw new TimeoutException(
$"Historian SDK connection to {config.ServerName}:{config.Port} timed out after {config.CommandTimeoutSeconds}s");
}
}
}

View File

@@ -1,93 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Historian.Aveva</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Historian.Aveva</AssemblyName>
<!-- Plugin is loaded at runtime via Assembly.LoadFrom; never copy it as a CopyLocal dep. -->
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
<!-- Deploy next to Host.exe under bin/<cfg>/Historian/ so F5 works without a manual copy. -->
<HistorianPluginOutputDir>$(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\</HistorianPluginOutputDir>
<!--
Phase 2 Stream D — V1 ARCHIVE. Plugs into the legacy in-process Host's
Wonderware Historian loader. Will be ported into Driver.Galaxy.Host's
Backend/Historian/ subtree when MxAccessGalaxyBackend.HistoryReadAsync is
wired (Task B.1.h follow-up). See docs/v2/V1_ARCHIVE_STATUS.md.
-->
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests"/>
</ItemGroup>
<ItemGroup>
<!-- Logging -->
<PackageReference Include="Serilog" Version="2.10.0"/>
<!-- OPC UA (for DataValue/StatusCodes used by the IHistorianDataSource surface) -->
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
</ItemGroup>
<ItemGroup>
<!-- Private=false: the plugin binds to Host types at compile time but Host.exe must not be
copied into the plugin's output folder (it is already in the process). -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj">
<Private>false</Private>
<ReferenceOutputAssembly>true</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<!-- Wonderware Historian SDK -->
<Reference Include="aahClientManaged">
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="aahClientCommon">
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- Historian SDK native dependencies — copied beside the plugin DLL so the AssemblyResolve
handler in HistorianPluginLoader can find them when the plugin first JITs. -->
<None Include="..\..\lib\aahClient.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\aahClientCommon.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\aahClientManaged.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\Historian.CBE.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\Historian.DPAPI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\ArchestrA.CloudHistorian.Contract.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="StageHistorianPluginForHost" AfterTargets="Build">
<ItemGroup>
<_HistorianStageFiles Include="$(OutDir)aahClient.dll"/>
<_HistorianStageFiles Include="$(OutDir)aahClientCommon.dll"/>
<_HistorianStageFiles Include="$(OutDir)aahClientManaged.dll"/>
<_HistorianStageFiles Include="$(OutDir)Historian.CBE.dll"/>
<_HistorianStageFiles Include="$(OutDir)Historian.DPAPI.dll"/>
<_HistorianStageFiles Include="$(OutDir)ArchestrA.CloudHistorian.Contract.dll"/>
<_HistorianStageFiles Include="$(OutDir)$(AssemblyName).dll"/>
<_HistorianStageFiles Include="$(OutDir)$(AssemblyName).pdb" Condition="Exists('$(OutDir)$(AssemblyName).pdb')"/>
</ItemGroup>
<MakeDir Directories="$(HistorianPluginOutputDir)"/>
<Copy SourceFiles="@(_HistorianStageFiles)" DestinationFolder="$(HistorianPluginOutputDir)" SkipUnchangedFiles="true"/>
</Target>
</Project>

View File

@@ -1,27 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Configures the template-based alarm object filter under <c>OpcUa.AlarmFilter</c>.
/// </summary>
/// <remarks>
/// Each entry in <see cref="ObjectFilters"/> is a wildcard pattern matched against the template
/// derivation chain of every Galaxy object. Supported wildcard: <c>*</c>. Matching is case-insensitive
/// and the leading <c>$</c> used by Galaxy template tag_names is normalized away, so operators can
/// write <c>TestMachine*</c> instead of <c>$TestMachine*</c>. An entry may itself contain comma-separated
/// patterns for convenience (e.g., <c>"TestMachine*, Pump_*"</c>). An empty list disables the filter,
/// restoring current behavior: all alarm-bearing objects are monitored when
/// <see cref="OpcUaConfiguration.AlarmTrackingEnabled"/> is <see langword="true"/>.
/// </remarks>
public class AlarmFilterConfiguration
{
/// <summary>
/// Gets or sets the wildcard patterns that select which Galaxy objects contribute alarm conditions.
/// An object is included when any template in its derivation chain matches any pattern, and the
/// inclusion propagates to all descendants in the containment hierarchy. Each object is evaluated
/// once: overlapping matches never create duplicate alarm subscriptions.
/// </summary>
public List<string> ObjectFilters { get; set; } = new();
}
}

View File

@@ -1,48 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Top-level configuration holder binding all sections from appsettings.json. (SVC-003)
/// </summary>
public class AppConfiguration
{
/// <summary>
/// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space.
/// </summary>
public OpcUaConfiguration OpcUa { get; set; } = new();
/// <summary>
/// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes.
/// </summary>
public MxAccessConfiguration MxAccess { get; set; } = new();
/// <summary>
/// Gets or sets the repository settings used to query Galaxy metadata for address-space construction.
/// </summary>
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new();
/// <summary>
/// Gets or sets the embedded dashboard settings used to surface service health to operators.
/// </summary>
public DashboardConfiguration Dashboard { get; set; } = new();
/// <summary>
/// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data.
/// </summary>
public HistorianConfiguration Historian { get; set; } = new();
/// <summary>
/// Gets or sets the authentication and role-based access control settings.
/// </summary>
public AuthenticationConfiguration Authentication { get; set; } = new();
/// <summary>
/// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
/// </summary>
public SecurityProfileConfiguration Security { get; set; } = new();
/// <summary>
/// Gets or sets the redundancy settings that control how this server participates in a redundant pair.
/// </summary>
public RedundancyConfiguration Redundancy { get; set; } = new();
}
}

View File

@@ -1,25 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Authentication and role-based access control settings for the OPC UA server.
/// </summary>
public class AuthenticationConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether anonymous OPC UA connections are accepted.
/// </summary>
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether anonymous users can write tag values.
/// When false, only authenticated users can write. Existing security classification restrictions still apply.
/// </summary>
public bool AnonymousCanWrite { get; set; } = true;
/// <summary>
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
/// credentials are validated against the LDAP server and group membership determines permissions.
/// </summary>
public LdapConfiguration Ldap { get; set; } = new();
}
}

View File

@@ -1,314 +0,0 @@
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;
using Opc.Ua;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Validates and logs effective configuration at startup. (SVC-003, SVC-005)
/// </summary>
public static class ConfigurationValidator
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
/// <summary>
/// Validates the effective host configuration and writes the resolved values to the startup log before service
/// initialization continues.
/// </summary>
/// <param name="config">
/// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries,
/// and dashboard behavior.
/// </param>
/// <returns>
/// <see langword="true" /> when the required settings are present and within supported bounds; otherwise,
/// <see langword="false" />.
/// </returns>
public static bool ValidateAndLog(AppConfiguration config)
{
var valid = true;
Log.Information("=== Effective Configuration ===");
// OPC UA
Log.Information(
"OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName,
config.OpcUa.GalaxyName);
Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}",
config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes);
if (config.OpcUa.Port < 1 || config.OpcUa.Port > 65535)
{
Log.Error("OpcUa.Port must be between 1 and 65535");
valid = false;
}
if (string.IsNullOrWhiteSpace(config.OpcUa.GalaxyName))
{
Log.Error("OpcUa.GalaxyName must not be empty");
valid = false;
}
// Alarm filter
var alarmFilterCount = config.OpcUa.AlarmFilter?.ObjectFilters?.Count ?? 0;
Log.Information(
"OpcUa.AlarmTrackingEnabled={AlarmEnabled}, AlarmFilter.ObjectFilters=[{Filters}]",
config.OpcUa.AlarmTrackingEnabled,
alarmFilterCount == 0 ? "(none)" : string.Join(", ", config.OpcUa.AlarmFilter!.ObjectFilters));
if (alarmFilterCount > 0 && !config.OpcUa.AlarmTrackingEnabled)
Log.Warning(
"OpcUa.AlarmFilter.ObjectFilters has {Count} patterns but OpcUa.AlarmTrackingEnabled is false — filter will have no effect",
alarmFilterCount);
// MxAccess
Log.Information(
"MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}",
config.MxAccess.ClientName, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds,
config.MxAccess.MaxConcurrentOperations);
Log.Information(
"MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s",
config.MxAccess.MonitorIntervalSeconds, config.MxAccess.AutoReconnect,
config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds);
Log.Information(
"MxAccess.RuntimeStatusProbesEnabled={Enabled}, RuntimeStatusUnknownTimeoutSeconds={Timeout}s, RequestTimeoutSeconds={RequestTimeout}s",
config.MxAccess.RuntimeStatusProbesEnabled, config.MxAccess.RuntimeStatusUnknownTimeoutSeconds,
config.MxAccess.RequestTimeoutSeconds);
if (string.IsNullOrWhiteSpace(config.MxAccess.ClientName))
{
Log.Error("MxAccess.ClientName must not be empty");
valid = false;
}
if (config.MxAccess.RuntimeStatusUnknownTimeoutSeconds < 5)
Log.Warning(
"MxAccess.RuntimeStatusUnknownTimeoutSeconds={Timeout} is below the recommended floor of 5s; initial probe resolution may time out before MxAccess has delivered the first callback",
config.MxAccess.RuntimeStatusUnknownTimeoutSeconds);
if (config.MxAccess.RequestTimeoutSeconds < 1)
{
Log.Error("MxAccess.RequestTimeoutSeconds must be at least 1");
valid = false;
}
else if (config.MxAccess.RequestTimeoutSeconds <
Math.Max(config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds))
{
Log.Warning(
"MxAccess.RequestTimeoutSeconds={RequestTimeout} is below Read/Write inner timeouts ({Read}s/{Write}s); outer safety bound may fire before the inner client completes its own error path",
config.MxAccess.RequestTimeoutSeconds,
config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds);
}
// Galaxy Repository
Log.Information(
"GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}",
SanitizeConnectionString(config.GalaxyRepository.ConnectionString), config.GalaxyRepository.ChangeDetectionIntervalSeconds,
config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes);
var effectivePlatformName = string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName)
? Environment.MachineName
: config.GalaxyRepository.PlatformName;
Log.Information(
"GalaxyRepository.Scope={Scope}, PlatformName={PlatformName}",
config.GalaxyRepository.Scope,
config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform
? effectivePlatformName
: "(n/a)");
if (config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform &&
string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName))
Log.Information(
"GalaxyRepository.PlatformName not set — using Environment.MachineName '{MachineName}'",
Environment.MachineName);
if (string.IsNullOrWhiteSpace(config.GalaxyRepository.ConnectionString))
{
Log.Error("GalaxyRepository.ConnectionString must not be empty");
valid = false;
}
// Dashboard
Log.Information("Dashboard.Enabled={Enabled}, Port={Port}, RefreshInterval={Refresh}s",
config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds);
// Security
Log.Information(
"Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}",
string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates,
config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize);
Log.Information("Security.PkiRootPath={PkiRootPath}", config.Security.PkiRootPath ?? "(default)");
Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject ?? "(default)");
Log.Information("Security.CertificateLifetimeMonths={Months}", config.Security.CertificateLifetimeMonths);
var unknownProfiles = config.Security.Profiles
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase))
.ToList();
if (unknownProfiles.Count > 0)
Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
if (config.Security.MinimumCertificateKeySize < 2048)
{
Log.Error("Security.MinimumCertificateKeySize must be at least 2048");
valid = false;
}
if (config.Security.AutoAcceptClientCertificates)
Log.Warning(
"Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
if (config.Security.Profiles.Count == 1 &&
config.Security.Profiles[0].Equals("None", StringComparison.OrdinalIgnoreCase))
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
// Historian
var clusterNodes = config.Historian.ServerNames ?? new List<string>();
var effectiveNodes = clusterNodes.Count > 0
? string.Join(",", clusterNodes)
: config.Historian.ServerName;
Log.Information(
"Historian.Enabled={Enabled}, Nodes=[{Nodes}], IntegratedSecurity={IntegratedSecurity}, Port={Port}",
config.Historian.Enabled, effectiveNodes, config.Historian.IntegratedSecurity,
config.Historian.Port);
Log.Information(
"Historian.CommandTimeoutSeconds={Timeout}, MaxValuesPerRead={MaxValues}, FailureCooldownSeconds={Cooldown}, RequestTimeoutSeconds={RequestTimeout}",
config.Historian.CommandTimeoutSeconds, config.Historian.MaxValuesPerRead,
config.Historian.FailureCooldownSeconds, config.Historian.RequestTimeoutSeconds);
if (config.Historian.Enabled)
{
if (clusterNodes.Count == 0 && string.IsNullOrWhiteSpace(config.Historian.ServerName))
{
Log.Error("Historian.ServerName (or ServerNames) must not be empty when Historian is enabled");
valid = false;
}
if (config.Historian.FailureCooldownSeconds < 0)
{
Log.Error("Historian.FailureCooldownSeconds must be zero or positive");
valid = false;
}
if (config.Historian.RequestTimeoutSeconds < 1)
{
Log.Error("Historian.RequestTimeoutSeconds must be at least 1");
valid = false;
}
else if (config.Historian.RequestTimeoutSeconds < config.Historian.CommandTimeoutSeconds)
{
Log.Warning(
"Historian.RequestTimeoutSeconds={RequestTimeout} is below CommandTimeoutSeconds={CmdTimeout}; outer safety bound may fire before the inner SDK completes its own error path",
config.Historian.RequestTimeoutSeconds, config.Historian.CommandTimeoutSeconds);
}
if (clusterNodes.Count > 0 && !string.IsNullOrWhiteSpace(config.Historian.ServerName)
&& config.Historian.ServerName != "localhost")
Log.Warning(
"Historian.ServerName='{ServerName}' is ignored because Historian.ServerNames has {Count} entries",
config.Historian.ServerName, clusterNodes.Count);
if (config.Historian.Port < 1 || config.Historian.Port > 65535)
{
Log.Error("Historian.Port must be between 1 and 65535");
valid = false;
}
if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.UserName))
{
Log.Error("Historian.UserName must not be empty when IntegratedSecurity is disabled");
valid = false;
}
if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.Password))
Log.Warning("Historian.Password is empty — authentication may fail");
}
// Authentication
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite);
if (config.Authentication.Ldap.Enabled)
{
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port,
config.Authentication.Ldap.BaseDN);
Log.Information(
"Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup,
config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
config.Authentication.Ldap.AlarmAckGroup);
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
}
// Redundancy
if (config.OpcUa.ApplicationUri != null)
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
Log.Information(
"Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}",
config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role,
config.Redundancy.ServiceLevelBase);
if (config.Redundancy.ServerUris.Count > 0)
Log.Information("Redundancy.ServerUris=[{ServerUris}]",
string.Join(", ", config.Redundancy.ServerUris));
if (config.Redundancy.Enabled)
{
if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri))
{
Log.Error(
"OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity");
valid = false;
}
if (config.Redundancy.ServerUris.Count < 2)
Log.Warning(
"Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers");
if (config.OpcUa.ApplicationUri != null &&
!config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri))
Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris",
config.OpcUa.ApplicationUri);
var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true);
if (mode == RedundancySupport.None)
Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None",
config.Redundancy.Mode);
}
if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255)
{
Log.Error("Redundancy.ServiceLevelBase must be between 1 and 255");
valid = false;
}
Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
return valid;
}
private static string SanitizeConnectionString(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
return "(empty)";
try
{
var builder = new SqlConnectionStringBuilder(connectionString);
if (!string.IsNullOrEmpty(builder.Password))
builder.Password = "********";
return builder.ConnectionString;
}
catch
{
return "(unparseable)";
}
}
}
}

View File

@@ -1,23 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Status dashboard configuration. (SVC-003, DASH-001)
/// </summary>
public class DashboardConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state.
/// </summary>
public int Port { get; set; } = 8081;
/// <summary>
/// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot.
/// </summary>
public int RefreshIntervalSeconds { get; set; } = 10;
}
}

View File

@@ -1,42 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Galaxy repository database configuration. (SVC-003, GR-005)
/// </summary>
public class GalaxyRepositoryConfiguration
{
/// <summary>
/// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata.
/// </summary>
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
/// <summary>
/// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space
/// rebuild.
/// </summary>
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model.
/// </summary>
public bool ExtendedAttributes { get; set; } = false;
/// <summary>
/// Gets or sets the scope of Galaxy objects loaded into the OPC UA address space.
/// <c>Galaxy</c> loads all deployed objects (default). <c>LocalPlatform</c> loads only
/// objects hosted by the platform deployed on this machine.
/// </summary>
public GalaxyScope Scope { get; set; } = GalaxyScope.Galaxy;
/// <summary>
/// Gets or sets an explicit platform node name for <see cref="GalaxyScope.LocalPlatform" /> filtering.
/// When <see langword="null" />, the local machine name (<c>Environment.MachineName</c>) is used.
/// </summary>
public string? PlatformName { get; set; }
}
}

View File

@@ -1,18 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Controls how much of the Galaxy object hierarchy is loaded into the OPC UA address space.
/// </summary>
public enum GalaxyScope
{
/// <summary>
/// Load all deployed objects from the entire Galaxy (default, backward-compatible behavior).
/// </summary>
Galaxy,
/// <summary>
/// Load only objects hosted by the local platform and the structural areas needed to reach them.
/// </summary>
LocalPlatform
}
}

View File

@@ -1,76 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Wonderware Historian SDK configuration for OPC UA historical data access.
/// </summary>
public class HistorianConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether OPC UA historical data access is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the single Historian server hostname used when <see cref="ServerNames"/>
/// is empty. Preserved for backward compatibility with pre-cluster deployments.
/// </summary>
public string ServerName { get; set; } = "localhost";
/// <summary>
/// Gets or sets the ordered list of Historian cluster nodes. When non-empty, this list
/// supersedes <see cref="ServerName"/>: the data source attempts each node in order on
/// connect, falling through to the next on failure. A failed node is placed in cooldown
/// for <see cref="FailureCooldownSeconds"/> before being re-eligible.
/// </summary>
public List<string> ServerNames { get; set; } = new();
/// <summary>
/// Gets or sets the cooldown window, in seconds, that a historian node is skipped after
/// a connection failure. A value of zero retries the node on every request. Default 60s.
/// </summary>
public int FailureCooldownSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether Windows Integrated Security is used.
/// When false, <see cref="UserName"/> and <see cref="Password"/> are used instead.
/// </summary>
public bool IntegratedSecurity { get; set; } = true;
/// <summary>
/// Gets or sets the username for Historian authentication when <see cref="IntegratedSecurity"/> is false.
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// Gets or sets the password for Historian authentication when <see cref="IntegratedSecurity"/> is false.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Gets or sets the Historian server TCP port.
/// </summary>
public int Port { get; set; } = 32568;
/// <summary>
/// Gets or sets the packet timeout in seconds for Historian SDK operations.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the maximum number of values returned per HistoryRead request.
/// </summary>
public int MaxValuesPerRead { get; set; } = 10000;
/// <summary>
/// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async Historian
/// operations invoked from the OPC UA stack thread (HistoryReadRaw, HistoryReadProcessed,
/// HistoryReadAtTime, HistoryReadEvents). This is a backstop for the case where a
/// historian query hangs outside <see cref="CommandTimeoutSeconds"/> — e.g., a slow SDK
/// reconnect or mid-failover cluster node. Must be comfortably larger than
/// <see cref="CommandTimeoutSeconds"/> so normal operation is never affected. Default 60s.
/// </summary>
public int RequestTimeoutSeconds { get; set; } = 60;
}
}

View File

@@ -1,75 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// LDAP authentication and group-to-role mapping settings.
/// </summary>
public class LdapConfiguration
{
/// <summary>
/// Gets or sets whether LDAP authentication is enabled.
/// When true, user credentials are validated against the configured LDAP server
/// and group membership determines OPC UA permissions.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the LDAP server hostname or IP address.
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gets or sets the LDAP server port.
/// </summary>
public int Port { get; set; } = 3893;
/// <summary>
/// Gets or sets the base DN for LDAP operations.
/// </summary>
public string BaseDN { get; set; } = "dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the bind DN template. Use {username} as a placeholder.
/// </summary>
public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the service account DN used for LDAP searches (group lookups).
/// </summary>
public string ServiceAccountDn { get; set; } = "";
/// <summary>
/// Gets or sets the service account password.
/// </summary>
public string ServiceAccountPassword { get; set; } = "";
/// <summary>
/// Gets or sets the LDAP connection timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the LDAP group name that grants read-only access.
/// </summary>
public string ReadOnlyGroup { get; set; } = "ReadOnly";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes.
/// </summary>
public string WriteOperateGroup { get; set; } = "WriteOperate";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Tune attributes.
/// </summary>
public string WriteTuneGroup { get; set; } = "WriteTune";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Configure attributes.
/// </summary>
public string WriteConfigureGroup { get; set; } = "WriteConfigure";
/// <summary>
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.
/// </summary>
public string AlarmAckGroup { get; set; } = "AlarmAck";
}
}

View File

@@ -1,86 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// MXAccess client configuration. (SVC-003, MXA-008, MXA-009)
/// </summary>
public class MxAccessConfiguration
{
/// <summary>
/// Gets or sets the client name registered with the MXAccess runtime for this bridge instance.
/// </summary>
public string ClientName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node.
/// </summary>
public string? NodeName { get; set; }
/// <summary>
/// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics.
/// </summary>
public string? GalaxyName { get; set; }
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete.
/// </summary>
public int ReadTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime.
/// </summary>
public int WriteTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async MxAccess
/// operations invoked from the OPC UA stack thread (Read, Write, address-space rebuild probe
/// sync). This is a backstop for the case where an async path hangs outside the inner
/// <see cref="ReadTimeoutSeconds"/> / <see cref="WriteTimeoutSeconds"/> bounds — e.g., a
/// slow reconnect or a scheduler stall. Must be comfortably larger than the inner timeouts
/// so normal operation is never affected. Default 30s.
/// </summary>
public int RequestTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime.
/// </summary>
public int MaxConcurrentOperations { get; set; } = 10;
/// <summary>
/// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection.
/// </summary>
public int MonitorIntervalSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess
/// session.
/// </summary>
public bool AutoReconnect { get; set; } = true;
/// <summary>
/// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data.
/// </summary>
public string? ProbeTag { get; set; }
/// <summary>
/// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale.
/// </summary>
public int ProbeStaleThresholdSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether the bridge advises <c>&lt;ObjectName&gt;.ScanState</c> for every
/// deployed <c>$WinPlatform</c> and <c>$AppEngine</c>, reporting per-host runtime state on the status
/// dashboard and proactively invalidating OPC UA variable quality when a host transitions to Stopped.
/// Enabled by default. Disable to return to legacy behavior where host runtime state is invisible and
/// MxAccess's per-tag bad-quality fan-out is the only stop signal.
/// </summary>
public bool RuntimeStatusProbesEnabled { get; set; } = true;
/// <summary>
/// Gets or sets the maximum seconds to wait for the initial probe callback before marking a host as
/// Stopped. Only applies to the Unknown → Stopped transition. Because <c>ScanState</c> is delivered
/// on-change only, a stably Running host does not time out — no starvation check runs on Running
/// entries. Default 15s.
/// </summary>
public int RuntimeStatusUnknownTimeoutSeconds { get; set; } = 15;
}
}

View File

@@ -1,64 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013)
/// </summary>
public class OpcUaConfiguration
{
/// <summary>
/// Gets or sets the IP address or hostname the OPC UA server binds to.
/// Defaults to <c>0.0.0.0</c> (all interfaces). Set to a specific IP or hostname to restrict listening.
/// </summary>
public string BindAddress { get; set; } = "0.0.0.0";
/// <summary>
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
/// </summary>
public int Port { get; set; } = 4840;
/// <summary>
/// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server.
/// </summary>
public string EndpointPath { get; set; } = "/LmxOpcUa";
/// <summary>
/// Gets or sets the server name presented to OPC UA clients and used in diagnostics.
/// </summary>
public string ServerName { get; set; } = "LmxOpcUa";
/// <summary>
/// Gets or sets the Galaxy name represented by the published OPC UA namespace.
/// </summary>
public string GalaxyName { get; set; } = "ZB";
/// <summary>
/// Gets or sets the explicit application URI for this server instance.
/// When <see langword="null" />, defaults to <c>urn:{GalaxyName}:LmxOpcUa</c>.
/// Must be set to a unique value per instance when redundancy is enabled.
/// </summary>
public string? ApplicationUri { get; set; }
/// <summary>
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
/// </summary>
public int MaxSessions { get; set; } = 100;
/// <summary>
/// Gets or sets the session timeout, in minutes, before idle client sessions are closed.
/// </summary>
public int SessionTimeoutMinutes { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether alarm tracking is enabled.
/// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored.
/// </summary>
public bool AlarmTrackingEnabled { get; set; } = false;
/// <summary>
/// Gets or sets the template-based alarm object filter. When <see cref="AlarmFilterConfiguration.ObjectFilters"/>
/// is empty, all alarm-bearing objects are monitored (current behavior). When patterns are supplied, only
/// objects whose template derivation chain matches a pattern (and their descendants) have alarms monitored.
/// </summary>
public AlarmFilterConfiguration AlarmFilter { get; set; } = new();
}
}

View File

@@ -1,41 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Non-transparent redundancy settings that control how the server advertises itself
/// within a redundant pair and computes its dynamic ServiceLevel.
/// </summary>
public class RedundancyConfiguration
{
/// <summary>
/// Gets or sets whether redundancy is enabled. When <see langword="false" /> (default),
/// the server reports <c>RedundancySupport.None</c> and <c>ServiceLevel = 255</c>.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the redundancy mode. Valid values: <c>Warm</c>, <c>Hot</c>.
/// </summary>
public string Mode { get; set; } = "Warm";
/// <summary>
/// Gets or sets the role of this instance. Valid values: <c>Primary</c>, <c>Secondary</c>.
/// The primary advertises a higher ServiceLevel than the secondary when both are healthy.
/// </summary>
public string Role { get; set; } = "Primary";
/// <summary>
/// Gets or sets the ApplicationUri values for all servers in the redundant set.
/// Must include this instance's own <c>OpcUa.ApplicationUri</c>.
/// </summary>
public List<string> ServerUris { get; set; } = new();
/// <summary>
/// Gets or sets the base ServiceLevel when the server is fully healthy.
/// The secondary automatically receives <c>ServiceLevelBase - 50</c>.
/// Valid range: 1-255.
/// </summary>
public int ServiceLevelBase { get; set; } = 200;
}
}

View File

@@ -1,52 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
{
/// <summary>
/// Transport security settings that control which OPC UA security profiles the server exposes and how client
/// certificates are handled.
/// </summary>
public class SecurityProfileConfiguration
{
/// <summary>
/// Gets or sets the list of security profile names to expose as server endpoints.
/// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
/// Defaults to ["None"] for backward compatibility.
/// </summary>
public List<string> Profiles { get; set; } = new() { "None" };
/// <summary>
/// Gets or sets a value indicating whether the server automatically accepts client certificates
/// that are not in the trusted store. Should be <see langword="false" /> in production.
/// </summary>
public bool AutoAcceptClientCertificates { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected.
/// </summary>
public bool RejectSHA1Certificates { get; set; } = true;
/// <summary>
/// Gets or sets the minimum RSA key size required for client certificates.
/// </summary>
public int MinimumCertificateKeySize { get; set; } = 2048;
/// <summary>
/// Gets or sets an optional override for the PKI root directory.
/// When <see langword="null" />, defaults to <c>%LOCALAPPDATA%\OPC Foundation\pki</c>.
/// </summary>
public string? PkiRootPath { get; set; }
/// <summary>
/// Gets or sets an optional override for the server certificate subject name.
/// When <see langword="null" />, defaults to <c>CN={ServerName}, O=ZB MOM, DC=localhost</c>.
/// </summary>
public string? CertificateSubject { get; set; }
/// <summary>
/// Gets or sets the lifetime of the auto-generated server certificate in months.
/// Defaults to 60 months (5 years).
/// </summary>
public int CertificateLifetimeMonths { get; set; } = 60;
}
}

View File

@@ -1,215 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Compiles and applies wildcard template patterns against Galaxy objects to decide which
/// objects should contribute alarm conditions. The filter is pure data — no OPC UA, no DB —
/// so it is fully unit-testable with synthetic hierarchies.
/// </summary>
/// <remarks>
/// <para>Matching rules:</para>
/// <list type="bullet">
/// <item>An object is included when any template name in its derivation chain matches
/// any configured pattern.</item>
/// <item>Matching is case-insensitive and ignores the Galaxy leading <c>$</c> prefix on
/// both the chain entry and the user pattern, so <c>TestMachine*</c> matches the stored
/// <c>$TestMachine</c>.</item>
/// <item>Inclusion propagates to every descendant of a matched object (containment subtree).</item>
/// <item>Each object is evaluated once — overlapping matches never produce duplicate
/// inclusions (set semantics).</item>
/// </list>
/// <para>Pattern syntax: literal text plus <c>*</c> wildcards (zero or more characters).
/// Other regex metacharacters in the raw pattern are escaped and treated literally.</para>
/// </remarks>
public class AlarmObjectFilter
{
private static readonly ILogger Log = Serilog.Log.ForContext<AlarmObjectFilter>();
private readonly List<Regex> _patterns;
private readonly List<string> _rawPatterns;
private readonly HashSet<string> _matchedRawPatterns;
/// <summary>
/// Initializes a new alarm object filter from the supplied configuration section.
/// </summary>
/// <param name="config">The alarm filter configuration whose <see cref="AlarmFilterConfiguration.ObjectFilters"/>
/// entries are parsed into regular expressions. Entries may themselves contain comma-separated patterns.</param>
public AlarmObjectFilter(AlarmFilterConfiguration? config)
{
_patterns = new List<Regex>();
_rawPatterns = new List<string>();
_matchedRawPatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (config?.ObjectFilters == null)
return;
foreach (var entry in config.ObjectFilters)
{
if (string.IsNullOrWhiteSpace(entry))
continue;
foreach (var piece in entry.Split(','))
{
var trimmed = piece.Trim();
if (trimmed.Length == 0)
continue;
try
{
var normalized = Normalize(trimmed);
var regex = GlobToRegex(normalized);
_patterns.Add(regex);
_rawPatterns.Add(trimmed);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to compile alarm filter pattern {Pattern} — skipping", trimmed);
}
}
}
}
/// <summary>
/// Gets a value indicating whether the filter has any compiled patterns. When <see langword="false"/>,
/// callers should treat alarm tracking as unfiltered (current behavior preserved).
/// </summary>
public bool Enabled => _patterns.Count > 0;
/// <summary>
/// Gets the number of compiled patterns the filter will evaluate against each object.
/// </summary>
public int PatternCount => _patterns.Count;
/// <summary>
/// Gets the raw pattern strings that did not match any object in the most recent call to
/// <see cref="ResolveIncludedObjects"/>. Useful for startup warnings about operator typos.
/// </summary>
public IReadOnlyList<string> UnmatchedPatterns =>
_rawPatterns.Where(p => !_matchedRawPatterns.Contains(p)).ToList();
/// <summary>
/// Gets the raw pattern strings exactly as supplied by the operator after comma-splitting
/// and trimming. Surfaced on the status dashboard so operators can confirm the active filter.
/// </summary>
public IReadOnlyList<string> RawPatterns => _rawPatterns;
/// <summary>
/// Returns <see langword="true"/> when any template name in <paramref name="chain"/> matches any
/// compiled pattern. An empty chain never matches unless the operator explicitly supplied a pattern
/// equal to <c>*</c> (which collapses to an empty-matching regex after normalization).
/// </summary>
/// <param name="chain">The template derivation chain to test (own template first, ancestors after).</param>
public bool MatchesTemplateChain(IReadOnlyList<string>? chain)
{
if (chain == null || chain.Count == 0 || _patterns.Count == 0)
return false;
for (var i = 0; i < _patterns.Count; i++)
{
var regex = _patterns[i];
for (var j = 0; j < chain.Count; j++)
{
var entry = chain[j];
if (string.IsNullOrEmpty(entry))
continue;
if (regex.IsMatch(Normalize(entry)))
{
_matchedRawPatterns.Add(_rawPatterns[i]);
return true;
}
}
}
return false;
}
/// <summary>
/// Walks the hierarchy top-down from each root and returns the set of gobject IDs whose alarms
/// should be monitored, honoring both template matching and descendant propagation. Returns
/// <see langword="null"/> when the filter is disabled so callers can skip the containment check
/// entirely.
/// </summary>
/// <param name="hierarchy">The full deployed Galaxy hierarchy, as returned by the repository service.</param>
/// <returns>The set of included gobject IDs, or <see langword="null"/> when filtering is disabled.</returns>
public HashSet<int>? ResolveIncludedObjects(IReadOnlyList<GalaxyObjectInfo>? hierarchy)
{
if (!Enabled)
return null;
_matchedRawPatterns.Clear();
var included = new HashSet<int>();
if (hierarchy == null || hierarchy.Count == 0)
return included;
var byId = new Dictionary<int, GalaxyObjectInfo>(hierarchy.Count);
foreach (var obj in hierarchy)
byId[obj.GobjectId] = obj;
var childrenByParent = new Dictionary<int, List<int>>();
foreach (var obj in hierarchy)
{
var parentId = obj.ParentGobjectId;
if (parentId != 0 && !byId.ContainsKey(parentId))
parentId = 0; // orphan → treat as root
if (!childrenByParent.TryGetValue(parentId, out var list))
{
list = new List<int>();
childrenByParent[parentId] = list;
}
list.Add(obj.GobjectId);
}
var roots = childrenByParent.TryGetValue(0, out var rootList)
? rootList
: new List<int>();
var visited = new HashSet<int>();
var queue = new Queue<(int Id, bool ParentIncluded)>();
foreach (var rootId in roots)
queue.Enqueue((rootId, false));
while (queue.Count > 0)
{
var (id, parentIncluded) = queue.Dequeue();
if (!visited.Add(id))
continue; // cycle defense
if (!byId.TryGetValue(id, out var obj))
continue;
var nodeIncluded = parentIncluded || MatchesTemplateChain(obj.TemplateChain);
if (nodeIncluded)
included.Add(id);
if (childrenByParent.TryGetValue(id, out var children))
foreach (var childId in children)
queue.Enqueue((childId, nodeIncluded));
}
return included;
}
private static Regex GlobToRegex(string normalized)
{
var segments = normalized.Split('*');
var parts = segments.Select(Regex.Escape);
var body = string.Join(".*", parts);
return new Regex("^" + body + "$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
}
private static string Normalize(string value)
{
var trimmed = value.Trim();
if (trimmed.StartsWith("$", StringComparison.Ordinal))
return trimmed.Substring(1);
return trimmed;
}
}
}

View File

@@ -1,38 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// MXAccess connection lifecycle states. (MXA-002)
/// </summary>
public enum ConnectionState
{
/// <summary>
/// No active session exists to the Galaxy runtime.
/// </summary>
Disconnected,
/// <summary>
/// The bridge is opening a new MXAccess session to the runtime.
/// </summary>
Connecting,
/// <summary>
/// The bridge has an active MXAccess session and can service reads, writes, and subscriptions.
/// </summary>
Connected,
/// <summary>
/// The bridge is closing the current MXAccess session and draining runtime resources.
/// </summary>
Disconnecting,
/// <summary>
/// The bridge detected a connection fault that requires operator attention or recovery logic.
/// </summary>
Error,
/// <summary>
/// The bridge is attempting to restore service after a runtime communication failure.
/// </summary>
Reconnecting
}
}

View File

@@ -1,38 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Event args for connection state transitions. (MXA-002)
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs" /> class.
/// </summary>
/// <param name="previous">The connection state being exited.</param>
/// <param name="current">The connection state being entered.</param>
/// <param name="message">Additional context about the transition, such as a connection fault or reconnect attempt.</param>
public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
{
PreviousState = previous;
CurrentState = current;
Message = message ?? "";
}
/// <summary>
/// Gets the previous MXAccess connection state before the transition was raised.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the new MXAccess connection state that the bridge moved into.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets an operator-facing message that explains why the connection state changed.
/// </summary>
public string Message { get; }
}
}

View File

@@ -1,76 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// DTO matching attributes.sql result columns. (GR-002)
/// </summary>
public class GalaxyAttributeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier that owns the attribute.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the attribute name as defined on the Galaxy template or instance.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets the human-readable Galaxy data type name returned by the repository query.
/// </summary>
public string DataTypeName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients.
/// </summary>
public string PrimitiveName { get; set; } = "";
/// <summary>
/// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation,
/// or runtime data.
/// </summary>
public string AttributeSource { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
/// 0=FreeAccess, 1=Operate (default), 2=SecuredWrite, 3=VerifiedWrite, 4=Tune, 5=Configure, 6=ViewOnly.
/// </summary>
public int SecurityClassification { get; set; } = 1;
/// <summary>
/// Gets or sets a value indicating whether the attribute has a HistoryExtension primitive and is historized by the
/// Wonderware Historian.
/// </summary>
public bool IsHistorized { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm.
/// </summary>
public bool IsAlarm { get; set; }
}
}

View File

@@ -1,64 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// DTO matching hierarchy.sql result columns. (GR-001)
/// </summary>
public class GalaxyObjectInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the contained name shown for the object inside its parent area or object.
/// </summary>
public string ContainedName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object.
/// </summary>
public bool IsArea { get; set; }
/// <summary>
/// Gets or sets the template derivation chain for this object. Index 0 is the object's own template;
/// subsequent entries walk up toward the most ancestral template before <c>$Object</c>. Populated by
/// the recursive CTE in <c>hierarchy.sql</c> on <c>gobject.derived_from_gobject_id</c>. Used by
/// <see cref="AlarmObjectFilter"/> to decide whether an object's alarms should be monitored.
/// </summary>
public List<string> TemplateChain { get; set; } = new();
/// <summary>
/// Gets or sets the Galaxy template category id for this object. Category 1 is $WinPlatform,
/// 3 is $AppEngine, 13 is $Area, 10 is $UserDefined, and so on. Populated from
/// <c>template_definition.category_id</c> by <c>hierarchy.sql</c> and consumed by the runtime
/// status probe manager to identify hosts that should receive a <c>ScanState</c> probe.
/// </summary>
public int CategoryId { get; set; }
/// <summary>
/// Gets or sets the Galaxy object id of this object's runtime host, populated from
/// <c>gobject.hosted_by_gobject_id</c>. Walk this chain upward to find the nearest
/// <c>$WinPlatform</c> or <c>$AppEngine</c> ancestor for subtree quality invalidation when
/// a runtime host is reported Stopped. Zero for root objects that have no host.
/// </summary>
public int HostedByGobjectId { get; set; }
}
}

View File

@@ -1,29 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Runtime state of a deployed Galaxy runtime host ($WinPlatform or $AppEngine) as
/// observed by the bridge via its <c>ScanState</c> probe.
/// </summary>
public enum GalaxyRuntimeState
{
/// <summary>
/// Probe advised but no callback received yet. Transitions to <see cref="Running"/>
/// on the first successful <c>ScanState = true</c> callback, or to <see cref="Stopped"/>
/// once the unknown-resolution timeout elapses.
/// </summary>
Unknown,
/// <summary>
/// Last probe callback reported <c>ScanState = true</c> with a successful item status.
/// The host is on scan and executing.
/// </summary>
Running,
/// <summary>
/// Last probe callback reported <c>ScanState != true</c>, or a failed item status, or
/// the initial probe never resolved before the unknown timeout elapsed. The host is
/// off scan or unreachable.
/// </summary>
Stopped
}
}

View File

@@ -1,72 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Point-in-time runtime state of a single Galaxy runtime host ($WinPlatform or $AppEngine)
/// as tracked by the <c>GalaxyRuntimeProbeManager</c>. Surfaced on the status dashboard and
/// consumed by <c>HealthCheckService</c> so operators can detect a stopped host before
/// downstream clients notice the stale data.
/// </summary>
public sealed class GalaxyRuntimeStatus
{
/// <summary>
/// Gets or sets the Galaxy tag_name of the host (e.g., <c>DevPlatform</c> or
/// <c>DevAppEngine</c>).
/// </summary>
public string ObjectName { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy gobject_id of the host.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the Galaxy template category name — <c>$WinPlatform</c> or
/// <c>$AppEngine</c>. Used by the dashboard to group hosts by kind.
/// </summary>
public string Kind { get; set; } = "";
/// <summary>
/// Gets or sets the current runtime state.
/// </summary>
public GalaxyRuntimeState State { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the most recent probe callback, whether it
/// reported success or failure. <see langword="null"/> before the first callback.
/// </summary>
public DateTime? LastStateCallbackTime { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the most recent <see cref="State"/> transition.
/// Backs the dashboard "Since" column. <see langword="null"/> in the initial Unknown
/// state before any transition.
/// </summary>
public DateTime? LastStateChangeTime { get; set; }
/// <summary>
/// Gets or sets the last <c>ScanState</c> value received from the probe, or
/// <see langword="null"/> before the first update or when the last callback carried
/// a non-success item status (no value delivered).
/// </summary>
public bool? LastScanState { get; set; }
/// <summary>
/// Gets or sets the detail message from the most recent failure callback, cleared on
/// the next successful <c>ScanState = true</c> delivery.
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// Gets or sets the cumulative number of callbacks where <c>ScanState = true</c>.
/// </summary>
public long GoodUpdateCount { get; set; }
/// <summary>
/// Gets or sets the cumulative number of callbacks where <c>ScanState != true</c>
/// or the item status reported failure.
/// </summary>
public long FailureCount { get; set; }
}
}

View File

@@ -1,46 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Interface for Galaxy repository database queries. (GR-001 through GR-004)
/// </summary>
public interface IGalaxyRepository
{
/// <summary>
/// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of Galaxy objects ordered for address-space construction.</returns>
Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default);
/// <summary>
/// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>A list of attribute definitions with MXAccess references and type metadata.</returns>
Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default);
/// <summary>
/// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild.
/// </summary>
/// <param name="ct">A token that cancels the repository query.</param>
/// <returns>The latest deploy timestamp, or <see langword="null" /> when it cannot be determined.</returns>
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
/// <summary>
/// Verifies that the service can reach the Galaxy repository before it attempts to build the address space.
/// </summary>
/// <param name="ct">A token that cancels the connectivity check.</param>
/// <returns><see langword="true" /> when repository access succeeds; otherwise, <see langword="false" />.</returns>
Task<bool> TestConnectionAsync(CancellationToken ct = default);
/// <summary>
/// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild.
/// </summary>
event Action? OnGalaxyChanged;
}
}

View File

@@ -1,79 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Abstraction over MXAccess COM client for tag read/write/subscribe operations.
/// (MXA-001 through MXA-009, OPC-007, OPC-008, OPC-009)
/// </summary>
public interface IMxAccessClient : IDisposable
{
/// <summary>
/// Gets the current runtime connectivity state for the bridge.
/// </summary>
ConnectionState State { get; }
/// <summary>
/// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
/// </summary>
int ActiveSubscriptionCount { get; }
/// <summary>
/// Gets the number of reconnect cycles attempted since the client was created.
/// </summary>
int ReconnectCount { get; }
/// <summary>
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed Galaxy attribute publishes a new runtime value.
/// </summary>
event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Opens the MXAccess session required for runtime reads, writes, and subscriptions.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
Task ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Closes the MXAccess session and releases runtime resources.
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="callback">The callback to invoke when the runtime publishes a new value for the attribute.</param>
Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback);
/// <summary>
/// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
Task UnsubscribeAsync(string fullTagReference);
/// <summary>
/// Reads the current runtime value for a Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="ct">A token that cancels the read.</param>
/// <returns>The value, timestamp, and quality returned by the runtime.</returns>
Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default);
/// <summary>
/// Writes a new runtime value to a writable Galaxy attribute.
/// </summary>
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
/// <param name="value">The value to write to the runtime.</param>
/// <param name="ct">A token that cancels the write.</param>
/// <returns><see langword="true" /> when the write is accepted by the runtime; otherwise, <see langword="false" />.</returns>
Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
}
}

View File

@@ -1,99 +0,0 @@
using ArchestrA.MxAccess;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Delegate matching LMXProxyServer.OnDataChange COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that raised the change.</param>
/// <param name="phItemHandle">The runtime item handle for the attribute that changed.</param>
/// <param name="pvItemValue">The new raw runtime value for the attribute.</param>
/// <param name="pwItemQuality">The OPC DA quality code supplied by the runtime.</param>
/// <param name="pftItemTimeStamp">The timestamp object supplied by the runtime for the value.</param>
/// <param name="ItemStatus">The MXAccess status payload associated with the callback.</param>
public delegate void MxDataChangeHandler(
int hLMXServerHandle,
int phItemHandle,
object pvItemValue,
int pwItemQuality,
object pftItemTimeStamp,
ref MXSTATUS_PROXY[] ItemStatus);
/// <summary>
/// Delegate matching LMXProxyServer.OnWriteComplete COM event signature.
/// </summary>
/// <param name="hLMXServerHandle">The runtime connection handle that processed the write.</param>
/// <param name="phItemHandle">The runtime item handle that was written.</param>
/// <param name="ItemStatus">The MXAccess status payload describing the write outcome.</param>
public delegate void MxWriteCompleteHandler(
int hLMXServerHandle,
int phItemHandle,
ref MXSTATUS_PROXY[] ItemStatus);
/// <summary>
/// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001)
/// </summary>
public interface IMxProxy
{
/// <summary>
/// Registers the bridge as an MXAccess client with the runtime proxy.
/// </summary>
/// <param name="clientName">The client identity reported to the runtime for diagnostics and session tracking.</param>
/// <returns>The runtime connection handle assigned to the client session.</returns>
int Register(string clientName);
/// <summary>
/// Unregisters the bridge from the runtime proxy and releases the connection handle.
/// </summary>
/// <param name="handle">The connection handle returned by <see cref="Register(string)" />.</param>
void Unregister(int handle);
/// <summary>
/// Adds a Galaxy attribute reference to the active runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified attribute reference to resolve.</param>
/// <returns>The runtime item handle assigned to the attribute.</returns>
int AddItem(int handle, string address);
/// <summary>
/// Removes a previously registered attribute from the runtime session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle returned by <see cref="AddItem(int, string)" />.</param>
void RemoveItem(int handle, int itemHandle);
/// <summary>
/// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to monitor.</param>
void AdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Stops supervisory updates for an attribute.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to stop monitoring.</param>
void UnAdviseSupervisory(int handle, int itemHandle);
/// <summary>
/// Writes a new value to a runtime attribute through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
/// <param name="value">The new value to push into the runtime.</param>
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
void Write(int handle, int itemHandle, object value, int securityClassification);
/// <summary>
/// Occurs when the runtime pushes a data-change callback for a subscribed attribute.
/// </summary>
event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the runtime acknowledges completion of a write request.
/// </summary>
event MxWriteCompleteHandler? OnWriteComplete;
}
}

View File

@@ -1,41 +0,0 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP,
/// etc.).
/// </summary>
public interface IUserAuthenticationProvider
{
/// <summary>
/// Validates a username/password combination.
/// </summary>
bool ValidateCredentials(string username, string password);
}
/// <summary>
/// Extended interface for providers that can resolve application-level roles for authenticated users.
/// When the auth provider implements this interface, OnImpersonateUser uses the returned roles
/// to control write and alarm-ack permissions.
/// </summary>
public interface IRoleProvider
{
/// <summary>
/// Returns the set of application-level roles granted to the user.
/// </summary>
IReadOnlyList<string> GetUserRoles(string username);
}
/// <summary>
/// Well-known application-level role names used for permission enforcement.
/// </summary>
public static class AppRoles
{
public const string ReadOnly = "ReadOnly";
public const string WriteOperate = "WriteOperate";
public const string WriteTune = "WriteTune";
public const string WriteConfigure = "WriteConfigure";
public const string AlarmAck = "AlarmAck";
}
}

View File

@@ -1,148 +0,0 @@
using System;
using System.Collections.Generic;
using System.DirectoryServices.Protocols;
using System.Net;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Validates credentials via LDAP bind and resolves group membership to application roles.
/// </summary>
public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
{
private static readonly ILogger Log = Serilog.Log.ForContext<LdapAuthenticationProvider>();
private readonly LdapConfiguration _config;
private readonly Dictionary<string, string> _groupToRole;
public LdapAuthenticationProvider(LdapConfiguration config)
{
_config = config;
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ config.ReadOnlyGroup, AppRoles.ReadOnly },
{ config.WriteOperateGroup, AppRoles.WriteOperate },
{ config.WriteTuneGroup, AppRoles.WriteTune },
{ config.WriteConfigureGroup, AppRoles.WriteConfigure },
{ config.AlarmAckGroup, AppRoles.AlarmAck }
};
}
public IReadOnlyList<string> GetUserRoles(string username)
{
try
{
using (var connection = CreateConnection())
{
// Bind with service account to search
connection.Bind(new NetworkCredential(_config.ServiceAccountDn, _config.ServiceAccountPassword));
var request = new SearchRequest(
_config.BaseDN,
$"(cn={EscapeLdapFilter(username)})",
SearchScope.Subtree,
"memberOf");
var response = (SearchResponse)connection.SendRequest(request);
if (response.Entries.Count == 0)
{
Log.Warning("LDAP search returned no entries for {Username}", username);
return new[] { AppRoles.ReadOnly }; // safe fallback
}
var entry = response.Entries[0];
var memberOf = entry.Attributes["memberOf"];
if (memberOf == null || memberOf.Count == 0)
{
Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username);
return new[] { AppRoles.ReadOnly };
}
var roles = new List<string>();
for (var i = 0; i < memberOf.Count; i++)
{
var dn = memberOf[i]?.ToString() ?? "";
// Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
var groupName = ExtractGroupName(dn);
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role)) roles.Add(role);
}
if (roles.Count == 0)
{
Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username);
roles.Add(AppRoles.ReadOnly);
}
Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles));
return roles;
}
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username);
return new[] { AppRoles.ReadOnly };
}
}
public bool ValidateCredentials(string username, string password)
{
try
{
var bindDn = _config.BindDnTemplate.Replace("{username}", username);
using (var connection = CreateConnection())
{
connection.Bind(new NetworkCredential(bindDn, password));
}
Log.Debug("LDAP bind succeeded for {Username}", username);
return true;
}
catch (LdapException ex)
{
Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message);
return false;
}
catch (Exception ex)
{
Log.Warning(ex, "LDAP error during credential validation for {Username}", username);
return false;
}
}
private LdapConnection CreateConnection()
{
var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);
var connection = new LdapConnection(identifier)
{
AuthType = AuthType.Basic,
Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds)
};
connection.SessionOptions.ProtocolVersion = 3;
return connection;
}
private static string? ExtractGroupName(string dn)
{
// Parse "ou=ReadWrite,ou=groups,dc=..." or "cn=ReadWrite,..."
if (string.IsNullOrEmpty(dn)) return null;
var parts = dn.Split(',');
if (parts.Length == 0) return null;
var first = parts[0].Trim();
var eqIdx = first.IndexOf('=');
return eqIdx >= 0 ? first.Substring(eqIdx + 1) : null;
}
private static string EscapeLdapFilter(string input)
{
return input
.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
}
}
}

View File

@@ -1,18 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Stable identifiers for custom OPC UA roles mapped from LDAP groups.
/// The namespace URI is registered in the server namespace table at startup,
/// and the string identifiers are resolved to runtime NodeIds before use.
/// </summary>
public static class LmxRoleIds
{
public const string NamespaceUri = "urn:zbmom:lmxopcua:roles";
public const string ReadOnly = "Role.ReadOnly";
public const string WriteOperate = "Role.WriteOperate";
public const string WriteTune = "Role.WriteTune";
public const string WriteConfigure = "Role.WriteConfigure";
public const string AlarmAck = "Role.AlarmAck";
}
}

View File

@@ -1,87 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Maps Galaxy mx_data_type integers to OPC UA data types and CLR types. (OPC-005)
/// See gr/data_type_mapping.md for full mapping table.
/// </summary>
public static class MxDataTypeMapper
{
/// <summary>
/// Maps mx_data_type to OPC UA DataType NodeId numeric identifier.
/// Unknown types default to String (i=12).
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA built-in data type node identifier.</returns>
public static uint MapToOpcUaDataType(int mxDataType)
{
return mxDataType switch
{
1 => 1, // Boolean → i=1
2 => 6, // Integer → Int32 i=6
3 => 10, // Float → Float i=10
4 => 11, // Double → Double i=11
5 => 12, // String → String i=12
6 => 13, // Time → DateTime i=13
7 => 11, // ElapsedTime → Double i=11 (seconds)
8 => 12, // Reference → String i=12
13 => 6, // Enumeration → Int32 i=6
14 => 12, // Custom → String i=12
15 => 21, // InternationalizedString → LocalizedText i=21
16 => 12, // Custom → String i=12
_ => 12 // Unknown → String i=12
};
}
/// <summary>
/// Maps mx_data_type to the corresponding CLR type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The CLR type used to represent runtime values for the MX type.</returns>
public static Type MapToClrType(int mxDataType)
{
return mxDataType switch
{
1 => typeof(bool),
2 => typeof(int),
3 => typeof(float),
4 => typeof(double),
5 => typeof(string),
6 => typeof(DateTime),
7 => typeof(double), // ElapsedTime as seconds
8 => typeof(string), // Reference as string
13 => typeof(int), // Enum backing integer
14 => typeof(string),
15 => typeof(string), // LocalizedText stored as string
16 => typeof(string),
_ => typeof(string)
};
}
/// <summary>
/// Returns the OPC UA type name for a given mx_data_type.
/// </summary>
/// <param name="mxDataType">The Galaxy MX data type code.</param>
/// <returns>The OPC UA type name used in diagnostics.</returns>
public static string GetOpcUaTypeName(int mxDataType)
{
return mxDataType switch
{
1 => "Boolean",
2 => "Int32",
3 => "Float",
4 => "Double",
5 => "String",
6 => "DateTime",
7 => "Double",
8 => "String",
13 => "Int32",
14 => "String",
15 => "LocalizedText",
16 => "String",
_ => "String"
};
}
}
}

View File

@@ -1,76 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009)
/// </summary>
public static class MxErrorCodes
{
/// <summary>
/// The requested Galaxy attribute reference does not resolve in the runtime.
/// </summary>
public const int MX_E_InvalidReference = 1008;
/// <summary>
/// The supplied value does not match the attribute's configured data type.
/// </summary>
public const int MX_E_WrongDataType = 1012;
/// <summary>
/// The target attribute cannot be written because it is read-only or protected.
/// </summary>
public const int MX_E_NotWritable = 1013;
/// <summary>
/// The runtime did not complete the operation within the configured timeout.
/// </summary>
public const int MX_E_RequestTimedOut = 1014;
/// <summary>
/// Communication with the MXAccess runtime failed during the operation.
/// </summary>
public const int MX_E_CommFailure = 1015;
/// <summary>
/// The operation was attempted without an active MXAccess session.
/// </summary>
public const int MX_E_NotConnected = 1016;
/// <summary>
/// Converts a numeric MXAccess error code into an operator-facing message.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>A human-readable description of the runtime failure.</returns>
public static string GetMessage(int errorCode)
{
return errorCode switch
{
1008 => "Invalid reference: the tag address does not exist or is malformed",
1012 => "Wrong data type: the value type does not match the attribute's expected type",
1013 => "Not writable: the attribute is read-only or locked",
1014 => "Request timed out: the operation did not complete within the allowed time",
1015 => "Communication failure: lost connection to the runtime",
1016 => "Not connected: no active connection to the Galaxy runtime",
_ => $"Unknown MXAccess error code: {errorCode}"
};
}
/// <summary>
/// Maps an MXAccess error code to the OPC quality state that should be exposed to clients.
/// </summary>
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
/// <returns>The quality classification that best represents the runtime failure.</returns>
public static Quality MapToQuality(int errorCode)
{
return errorCode switch
{
1008 => Quality.BadConfigError,
1012 => Quality.BadConfigError,
1013 => Quality.BadOutOfService,
1014 => Quality.BadCommFailure,
1015 => Quality.BadCommFailure,
1016 => Quality.BadNotConnected,
_ => Quality.Bad
};
}
}
}

View File

@@ -1,18 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Maps a deployed Galaxy platform to the hostname where it executes.
/// </summary>
public class PlatformInfo
{
/// <summary>
/// Gets or sets the gobject_id of the platform object in the Galaxy repository.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the hostname (node_name) where the platform is deployed.
/// </summary>
public string NodeName { get; set; } = "";
}
}

View File

@@ -1,122 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005)
/// </summary>
public enum Quality : byte
{
// Bad family (0-63)
/// <summary>
/// No valid process value is available.
/// </summary>
Bad = 0,
/// <summary>
/// The value is invalid because the Galaxy attribute definition or mapping is wrong.
/// </summary>
BadConfigError = 4,
/// <summary>
/// The bridge is not currently connected to the Galaxy runtime.
/// </summary>
BadNotConnected = 8,
/// <summary>
/// The runtime device or adapter failed while obtaining the value.
/// </summary>
BadDeviceFailure = 12,
/// <summary>
/// The underlying field source reported a bad sensor condition.
/// </summary>
BadSensorFailure = 16,
/// <summary>
/// Communication with the runtime failed while retrieving the value.
/// </summary>
BadCommFailure = 20,
/// <summary>
/// The attribute is intentionally unavailable for service, such as a locked or unwritable value.
/// </summary>
BadOutOfService = 24,
/// <summary>
/// The bridge is still waiting for the first usable value after startup or resubscription.
/// </summary>
BadWaitingForInitialData = 32,
// Uncertain family (64-191)
/// <summary>
/// A value is available, but it should be treated cautiously.
/// </summary>
Uncertain = 64,
/// <summary>
/// The last usable value is being repeated because a newer one is unavailable.
/// </summary>
UncertainLastUsable = 68,
/// <summary>
/// The sensor or source is providing a value with reduced accuracy.
/// </summary>
UncertainSensorNotAccurate = 80,
/// <summary>
/// The value exceeds its engineered limits.
/// </summary>
UncertainEuExceeded = 84,
/// <summary>
/// The source is operating in a degraded or subnormal state.
/// </summary>
UncertainSubNormal = 88,
// Good family (192+)
/// <summary>
/// The value is current and suitable for normal client use.
/// </summary>
Good = 192,
/// <summary>
/// The value is good but currently overridden locally rather than flowing from the live source.
/// </summary>
GoodLocalOverride = 216
}
/// <summary>
/// Helper methods for reasoning about OPC quality families used by the bridge.
/// </summary>
public static class QualityExtensions
{
/// <summary>
/// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true" /> when the value is in the good quality range; otherwise, <see langword="false" />.</returns>
public static bool IsGood(this Quality q)
{
return (byte)q >= 192;
}
/// <summary>
/// Determines whether the quality represents an uncertain runtime value that should be treated cautiously.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true" /> when the value is in the uncertain range; otherwise, <see langword="false" />.</returns>
public static bool IsUncertain(this Quality q)
{
return (byte)q >= 64 && (byte)q < 192;
}
/// <summary>
/// Determines whether the quality represents a bad runtime value that should not be used as valid process data.
/// </summary>
/// <param name="q">The quality code to inspect.</param>
/// <returns><see langword="true" /> when the value is in the bad range; otherwise, <see langword="false" />.</returns>
public static bool IsBad(this Quality q)
{
return (byte)q < 64;
}
}
}

View File

@@ -1,60 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Maps MXAccess integer quality to domain Quality enum and OPC UA StatusCodes. (MXA-009, OPC-005)
/// </summary>
public static class QualityMapper
{
/// <summary>
/// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality.
/// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad.
/// </summary>
/// <param name="mxQuality">The raw MXAccess quality integer.</param>
/// <returns>The mapped bridge quality value.</returns>
public static Quality MapFromMxAccessQuality(int mxQuality)
{
var b = (byte)(mxQuality & 0xFF);
// Try exact match first
if (Enum.IsDefined(typeof(Quality), b))
return (Quality)b;
// Fall back to category
if (b >= 192) return Quality.Good;
if (b >= 64) return Quality.Uncertain;
return Quality.Bad;
}
/// <summary>
/// Maps domain Quality to OPC UA StatusCode uint32.
/// </summary>
/// <param name="quality">The bridge quality value.</param>
/// <returns>The OPC UA status code represented as a 32-bit unsigned integer.</returns>
public static uint MapToOpcUaStatusCode(Quality quality)
{
return quality switch
{
Quality.Good => 0x00000000u, // Good
Quality.GoodLocalOverride => 0x00D80000u, // Good_LocalOverride
Quality.Uncertain => 0x40000000u, // Uncertain
Quality.UncertainLastUsable => 0x40900000u,
Quality.UncertainSensorNotAccurate => 0x40930000u,
Quality.UncertainEuExceeded => 0x40940000u,
Quality.UncertainSubNormal => 0x40950000u,
Quality.Bad => 0x80000000u, // Bad
Quality.BadConfigError => 0x80890000u,
Quality.BadNotConnected => 0x808A0000u,
Quality.BadDeviceFailure => 0x808B0000u,
Quality.BadSensorFailure => 0x808C0000u,
Quality.BadCommFailure => 0x80050000u,
Quality.BadOutOfService => 0x808D0000u,
Quality.BadWaitingForInitialData => 0x80320000u,
_ => quality.IsGood() ? 0x00000000u :
quality.IsUncertain() ? 0x40000000u :
0x80000000u
};
}
}
}

View File

@@ -1,30 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Maps Galaxy security classification values to OPC UA write access decisions.
/// See gr/data_type_mapping.md for the full mapping table.
/// </summary>
public static class SecurityClassificationMapper
{
/// <summary>
/// Determines whether an attribute with the given security classification should allow writes.
/// </summary>
/// <param name="securityClassification">The Galaxy security classification value.</param>
/// <returns>
/// <see langword="true" /> for FreeAccess (0), Operate (1), Tune (4), Configure (5);
/// <see langword="false" /> for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6).
/// </returns>
public static bool IsWritable(int securityClassification)
{
switch (securityClassification)
{
case 2: // SecuredWrite
case 3: // VerifiedWrite
case 6: // ViewOnly
return false;
default:
return true;
}
}
}
}

View File

@@ -1,96 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
{
/// <summary>
/// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007)
/// </summary>
public readonly struct Vtq : IEquatable<Vtq>
{
/// <summary>
/// Gets the runtime value returned for the Galaxy attribute.
/// </summary>
public object? Value { get; }
/// <summary>
/// Gets the timestamp associated with the runtime value.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets the quality classification that tells OPC UA clients whether the value is usable.
/// </summary>
public Quality Quality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Vtq" /> struct for a Galaxy attribute value.
/// </summary>
/// <param name="value">The runtime value returned by MXAccess.</param>
/// <param name="timestamp">The timestamp assigned to the runtime value.</param>
/// <param name="quality">The quality classification for the runtime value.</param>
public Vtq(object? value, DateTime timestamp, Quality quality)
{
Value = value;
Timestamp = timestamp;
Quality = quality;
}
/// <summary>
/// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value.
/// </summary>
/// <param name="value">The runtime value to wrap.</param>
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and good quality.</returns>
public static Vtq Good(object? value)
{
return new Vtq(value, DateTime.UtcNow, Quality.Good);
}
/// <summary>
/// Creates a bad-quality VTQ snapshot when no usable runtime value is available.
/// </summary>
/// <param name="quality">The specific bad quality reason to expose to clients.</param>
/// <returns>A VTQ with no value, the current UTC timestamp, and the requested bad quality.</returns>
public static Vtq Bad(Quality quality = Quality.Bad)
{
return new Vtq(null, DateTime.UtcNow, quality);
}
/// <summary>
/// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously.
/// </summary>
/// <param name="value">The runtime value to wrap.</param>
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and uncertain quality.</returns>
public static Vtq Uncertain(object? value)
{
return new Vtq(value, DateTime.UtcNow, Quality.Uncertain);
}
/// <summary>
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
/// </summary>
/// <param name="other">The other VTQ snapshot to compare.</param>
/// <returns><see langword="true" /> when all fields match; otherwise, <see langword="false" />.</returns>
public bool Equals(Vtq other)
{
return Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
}
/// <inheritdoc />
public override bool Equals(object? obj)
{
return obj is Vtq other && Equals(other);
}
/// <inheritdoc />
public override int GetHashCode()
{
return HashCode.Combine(Value, Timestamp, Quality);
}
/// <inheritdoc />
public override string ToString()
{
return $"Vtq({Value}, {Timestamp:O}, {Quality})";
}
}
}

View File

@@ -1,7 +0,0 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura>
<ExcludeAssemblies>
ArchestrA.MxAccess
</ExcludeAssemblies>
</Costura>
</Weavers>

View File

@@ -1,176 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:all>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX86Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinArm64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
</xs:all>
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeReferences" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if runtime assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UseRuntimeReferencePaths" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCompression" type="xs:boolean">
<xs:annotation>
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCleanup" type="xs:boolean">
<xs:annotation>
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableEventSubscription" type="xs:boolean">
<xs:annotation>
<xs:documentation>The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UnmanagedWinX86Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UnmanagedWinX64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UnmanagedWinArm64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

View File

@@ -1,124 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
{
/// <summary>
/// Polls the Galaxy database for deployment changes and fires OnGalaxyChanged. (GR-003, GR-004)
/// </summary>
public class ChangeDetectionService : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<ChangeDetectionService>();
private readonly int _intervalSeconds;
private readonly IGalaxyRepository _repository;
private CancellationTokenSource? _cts;
private Task? _pollTask;
/// <summary>
/// Initializes a new change detector for Galaxy deploy timestamps.
/// </summary>
/// <param name="repository">The repository used to query the latest deploy timestamp.</param>
/// <param name="intervalSeconds">The polling interval, in seconds, between deploy checks.</param>
/// <param name="initialDeployTime">An optional deploy timestamp already known at service startup.</param>
public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds,
DateTime? initialDeployTime = null)
{
_repository = repository;
_intervalSeconds = intervalSeconds;
LastKnownDeployTime = initialDeployTime;
}
/// <summary>
/// Gets the last deploy timestamp observed by the polling loop.
/// </summary>
public DateTime? LastKnownDeployTime { get; private set; }
/// <summary>
/// Stops the polling loop and disposes the underlying cancellation resources.
/// </summary>
public void Dispose()
{
Stop();
_cts?.Dispose();
}
/// <summary>
/// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt.
/// </summary>
public event Action? OnGalaxyChanged;
/// <summary>
/// Starts the background polling loop that watches for Galaxy deploy changes.
/// </summary>
public void Start()
{
if (_cts != null)
Stop();
_cts = new CancellationTokenSource();
_pollTask = Task.Run(() => PollLoopAsync(_cts.Token));
Log.Information("Change detection started (interval={Interval}s)", _intervalSeconds);
}
/// <summary>
/// Stops the background polling loop.
/// </summary>
public void Stop()
{
_cts?.Cancel();
try { _pollTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
_pollTask = null;
Log.Information("Change detection stopped");
}
private async Task PollLoopAsync(CancellationToken ct)
{
// If no initial deploy time was provided, first poll triggers unconditionally
var firstPoll = LastKnownDeployTime == null;
while (!ct.IsCancellationRequested)
{
try
{
var deployTime = await _repository.GetLastDeployTimeAsync(ct);
if (firstPoll)
{
firstPoll = false;
LastKnownDeployTime = deployTime;
Log.Information("Initial deploy time: {DeployTime}", deployTime);
OnGalaxyChanged?.Invoke();
}
else if (deployTime != LastKnownDeployTime)
{
Log.Information("Galaxy deployment change detected: {Previous} → {Current}",
LastKnownDeployTime, deployTime);
LastKnownDeployTime = deployTime;
OnGalaxyChanged?.Invoke();
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Log.Warning(ex, "Change detection poll failed, will retry next interval");
}
try
{
await Task.Delay(TimeSpan.FromSeconds(_intervalSeconds), ct);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
}

View File

@@ -1,529 +0,0 @@
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
{
/// <summary>
/// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007)
/// </summary>
public class GalaxyRepositoryService : IGalaxyRepository
{
private static readonly ILogger Log = Serilog.Log.ForContext<GalaxyRepositoryService>();
private readonly GalaxyRepositoryConfiguration _config;
/// <summary>
/// When <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering is active, caches the set of
/// gobject_ids that passed the hierarchy filter so <see cref="GetAttributesAsync" /> can apply the same scope.
/// Populated by <see cref="GetHierarchyAsync" /> and consumed by <see cref="GetAttributesAsync" />.
/// </summary>
private HashSet<int>? _scopeFilteredGobjectIds;
/// <summary>
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
/// </summary>
/// <param name="config">The repository connection, timeout, and attribute-selection settings.</param>
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
{
_config = config;
}
/// <summary>
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
/// </summary>
public event Action? OnGalaxyChanged;
/// <summary>
/// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The deployed Galaxy objects that should appear in the namespace.</returns>
public async Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
{
var results = new List<GalaxyObjectInfo>();
using var conn = new SqlConnection(_config.ConnectionString);
await conn.OpenAsync(ct);
using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var templateChainRaw = reader.IsDBNull(8) ? "" : reader.GetString(8);
var templateChain = string.IsNullOrEmpty(templateChainRaw)
? new List<string>()
: templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.ToList();
results.Add(new GalaxyObjectInfo
{
GobjectId = Convert.ToInt32(reader.GetValue(0)),
TagName = reader.GetString(1),
ContainedName = reader.IsDBNull(2) ? "" : reader.GetString(2),
BrowseName = reader.GetString(3),
ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
CategoryId = Convert.ToInt32(reader.GetValue(6)),
HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
TemplateChain = templateChain
});
}
if (results.Count == 0)
Log.Warning("GetHierarchyAsync returned zero rows");
else
Log.Information("GetHierarchyAsync returned {Count} objects", results.Count);
if (_config.Scope == GalaxyScope.LocalPlatform)
{
var platforms = await GetPlatformsAsync(ct);
var platformName = string.IsNullOrWhiteSpace(_config.PlatformName)
? Environment.MachineName
: _config.PlatformName;
var (filtered, gobjectIds) = PlatformScopeFilter.Filter(results, platforms, platformName);
_scopeFilteredGobjectIds = gobjectIds;
return filtered;
}
_scopeFilteredGobjectIds = null;
return results;
}
/// <summary>
/// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The attribute rows required to build runtime tag mappings and variable metadata.</returns>
public async Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
{
var results = new List<GalaxyAttributeInfo>();
var extended = _config.ExtendedAttributes;
var sql = extended ? ExtendedAttributesSql : AttributesSql;
using var conn = new SqlConnection(_config.ConnectionString);
await conn.OpenAsync(ct);
using var cmd = new SqlCommand(sql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(extended ? ReadExtendedAttribute(reader) : ReadStandardAttribute(reader));
Log.Information("GetAttributesAsync returned {Count} attributes (extended={Extended})", results.Count,
extended);
if (_config.Scope == GalaxyScope.LocalPlatform && _scopeFilteredGobjectIds != null)
return PlatformScopeFilter.FilterAttributes(results, _scopeFilteredGobjectIds);
return results;
}
/// <summary>
/// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale.
/// </summary>
/// <param name="ct">A token that cancels the database query.</param>
/// <returns>The most recent deploy timestamp, or <see langword="null" /> when none is available.</returns>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
using var conn = new SqlConnection(_config.ConnectionString);
await conn.OpenAsync(ct);
using var cmd = new SqlCommand(ChangeDetectionSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
var result = await cmd.ExecuteScalarAsync(ct);
return result is DateTime dt ? dt : null;
}
/// <summary>
/// Executes a lightweight query to confirm that the repository database is reachable.
/// </summary>
/// <param name="ct">A token that cancels the connectivity check.</param>
/// <returns><see langword="true" /> when the query succeeds; otherwise, <see langword="false" />.</returns>
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
{
try
{
using var conn = new SqlConnection(_config.ConnectionString);
await conn.OpenAsync(ct);
using var cmd = new SqlCommand(TestConnectionSql, conn)
{ CommandTimeout = _config.CommandTimeoutSeconds };
await cmd.ExecuteScalarAsync(ct);
Log.Information("Galaxy repository database connection successful");
return true;
}
catch (Exception ex)
{
Log.Warning(ex, "Galaxy repository database connection failed");
return false;
}
}
/// <summary>
/// Queries the platform table for deployed platform-to-hostname mappings used by
/// <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering.
/// </summary>
private async Task<List<PlatformInfo>> GetPlatformsAsync(CancellationToken ct = default)
{
var results = new List<PlatformInfo>();
using var conn = new SqlConnection(_config.ConnectionString);
await conn.OpenAsync(ct);
using var cmd = new SqlCommand(PlatformLookupSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(new PlatformInfo
{
GobjectId = Convert.ToInt32(reader.GetValue(0)),
NodeName = reader.IsDBNull(1) ? "" : reader.GetString(1)
});
}
Log.Information("GetPlatformsAsync returned {Count} platform(s)", results.Count);
return results;
}
/// <summary>
/// Reads a row from the standard attributes query (12 columns).
/// Columns: gobject_id, tag_name, attribute_name, full_tag_reference, mx_data_type,
/// data_type_name, is_array, array_dimension, mx_attribute_category,
/// security_classification, is_historized, is_alarm
/// </summary>
private static GalaxyAttributeInfo ReadStandardAttribute(SqlDataReader reader)
{
return new GalaxyAttributeInfo
{
GobjectId = Convert.ToInt32(reader.GetValue(0)),
TagName = reader.GetString(1),
AttributeName = reader.GetString(2),
FullTagReference = reader.GetString(3),
MxDataType = Convert.ToInt32(reader.GetValue(4)),
DataTypeName = reader.IsDBNull(5) ? "" : reader.GetString(5),
IsArray = Convert.ToBoolean(reader.GetValue(6)),
ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1
};
}
/// <summary>
/// Reads a row from the extended attributes query (14 columns).
/// Columns: gobject_id, tag_name, primitive_name, attribute_name, full_tag_reference,
/// mx_data_type, data_type_name, is_array, array_dimension,
/// mx_attribute_category, security_classification, is_historized, is_alarm, attribute_source
/// </summary>
private static GalaxyAttributeInfo ReadExtendedAttribute(SqlDataReader reader)
{
return new GalaxyAttributeInfo
{
GobjectId = Convert.ToInt32(reader.GetValue(0)),
TagName = reader.GetString(1),
PrimitiveName = reader.IsDBNull(2) ? "" : reader.GetString(2),
AttributeName = reader.GetString(3),
FullTagReference = reader.GetString(4),
MxDataType = Convert.ToInt32(reader.GetValue(5)),
DataTypeName = reader.IsDBNull(6) ? "" : reader.GetString(6),
IsArray = Convert.ToBoolean(reader.GetValue(7)),
ArrayDimension = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)),
SecurityClassification = Convert.ToInt32(reader.GetValue(10)),
IsHistorized = Convert.ToInt32(reader.GetValue(11)) == 1,
IsAlarm = Convert.ToInt32(reader.GetValue(12)) == 1,
AttributeSource = reader.IsDBNull(13) ? "" : reader.GetString(13)
};
}
/// <summary>
/// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy.
/// </summary>
public void RaiseGalaxyChanged()
{
OnGalaxyChanged?.Invoke();
}
#region SQL Queries (GR-006: const string, no dynamic SQL)
private const string HierarchySql = @"
;WITH template_chain AS (
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
FROM gobject g
INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
UNION ALL
SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
FROM template_chain tc
INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
)
SELECT DISTINCT
g.gobject_id,
g.tag_name,
g.contained_name,
CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
THEN g.tag_name
ELSE g.contained_name
END AS browse_name,
CASE WHEN g.contained_by_gobject_id = 0
THEN g.area_gobject_id
ELSE g.contained_by_gobject_id
END AS parent_gobject_id,
CASE WHEN td.category_id = 13
THEN 1
ELSE 0
END AS is_area,
td.category_id AS category_id,
g.hosted_by_gobject_id AS hosted_by_gobject_id,
ISNULL(
STUFF((
SELECT '|' + tc.template_tag_name
FROM template_chain tc
WHERE tc.instance_gobject_id = g.gobject_id
ORDER BY tc.depth
FOR XML PATH('')
), 1, 1, ''),
''
) AS template_chain
FROM gobject g
INNER JOIN template_definition td
ON g.template_definition_id = td.template_definition_id
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
AND g.is_template = 0
AND g.deployed_package_id <> 0
ORDER BY parent_gobject_id, g.tag_name";
private const string AttributesSql = @"
;WITH deployed_package_chain AS (
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
FROM gobject g
INNER JOIN package p ON p.package_id = g.deployed_package_id
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
UNION ALL
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
FROM deployed_package_chain dpc
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
)
SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
mx_data_type, data_type_name, is_array, array_dimension,
mx_attribute_category, security_classification, is_historized, is_alarm
FROM (
SELECT
dpc.gobject_id,
g.tag_name,
da.attribute_name,
g.tag_name + '.' + da.attribute_name
+ CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
AS full_tag_reference,
da.mx_data_type,
dt.description AS data_type_name,
da.is_array,
CASE WHEN da.is_array = 1
THEN CONVERT(int, CONVERT(varbinary(2),
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
ELSE NULL
END AS array_dimension,
da.mx_attribute_category,
da.security_classification,
CASE WHEN EXISTS (
SELECT 1 FROM deployed_package_chain dpc2
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
WHERE dpc2.gobject_id = dpc.gobject_id
) THEN 1 ELSE 0 END AS is_historized,
CASE WHEN EXISTS (
SELECT 1 FROM deployed_package_chain dpc2
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
WHERE dpc2.gobject_id = dpc.gobject_id
) THEN 1 ELSE 0 END AS is_alarm,
ROW_NUMBER() OVER (
PARTITION BY dpc.gobject_id, da.attribute_name
ORDER BY dpc.depth
) AS rn
FROM deployed_package_chain dpc
INNER JOIN dynamic_attribute da
ON da.package_id = dpc.package_id
INNER JOIN gobject g
ON g.gobject_id = dpc.gobject_id
INNER JOIN template_definition td
ON td.template_definition_id = g.template_definition_id
LEFT JOIN data_type dt
ON dt.mx_data_type = da.mx_data_type
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
AND da.attribute_name NOT LIKE '[_]%'
AND da.attribute_name NOT LIKE '%.Description'
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
) ranked
WHERE rn = 1
ORDER BY tag_name, attribute_name";
private const string ExtendedAttributesSql = @"
;WITH deployed_package_chain AS (
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
FROM gobject g
INNER JOIN package p ON p.package_id = g.deployed_package_id
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
UNION ALL
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
FROM deployed_package_chain dpc
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
),
ranked_dynamic AS (
SELECT
dpc.gobject_id,
g.tag_name,
da.attribute_name,
g.tag_name + '.' + da.attribute_name
+ CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
AS full_tag_reference,
da.mx_data_type,
dt.description AS data_type_name,
da.is_array,
CASE WHEN da.is_array = 1
THEN CONVERT(int, CONVERT(varbinary(2),
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
ELSE NULL
END AS array_dimension,
da.mx_attribute_category,
da.security_classification,
CASE WHEN EXISTS (
SELECT 1 FROM deployed_package_chain dpc2
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
WHERE dpc2.gobject_id = dpc.gobject_id
) THEN 1 ELSE 0 END AS is_historized,
CASE WHEN EXISTS (
SELECT 1 FROM deployed_package_chain dpc2
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
WHERE dpc2.gobject_id = dpc.gobject_id
) THEN 1 ELSE 0 END AS is_alarm,
ROW_NUMBER() OVER (
PARTITION BY dpc.gobject_id, da.attribute_name
ORDER BY dpc.depth
) AS rn
FROM deployed_package_chain dpc
INNER JOIN dynamic_attribute da
ON da.package_id = dpc.package_id
INNER JOIN gobject g
ON g.gobject_id = dpc.gobject_id
INNER JOIN template_definition td
ON td.template_definition_id = g.template_definition_id
LEFT JOIN data_type dt
ON dt.mx_data_type = da.mx_data_type
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
AND da.attribute_name NOT LIKE '[_]%'
AND da.attribute_name NOT LIKE '%.Description'
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
)
SELECT
gobject_id,
tag_name,
primitive_name,
attribute_name,
full_tag_reference,
mx_data_type,
data_type_name,
is_array,
array_dimension,
mx_attribute_category,
security_classification,
is_historized,
is_alarm,
attribute_source
FROM (
SELECT
g.gobject_id,
g.tag_name,
pi.primitive_name,
ad.attribute_name,
CASE WHEN pi.primitive_name = ''
THEN g.tag_name + '.' + ad.attribute_name
ELSE g.tag_name + '.' + pi.primitive_name + '.' + ad.attribute_name
END + CASE WHEN ad.is_array = 1 THEN '[]' ELSE '' END
AS full_tag_reference,
ad.mx_data_type,
dt.description AS data_type_name,
ad.is_array,
CASE WHEN ad.is_array = 1
THEN CONVERT(int, CONVERT(varbinary(2),
SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2))
ELSE NULL
END AS array_dimension,
ad.mx_attribute_category,
ad.security_classification,
CAST(0 AS int) AS is_historized,
CAST(0 AS int) AS is_alarm,
'primitive' AS attribute_source
FROM gobject g
INNER JOIN instance i
ON i.gobject_id = g.gobject_id
INNER JOIN template_definition td
ON td.template_definition_id = g.template_definition_id
AND td.runtime_clsid <> '{00000000-0000-0000-0000-000000000000}'
INNER JOIN package p
ON p.package_id = g.deployed_package_id
INNER JOIN primitive_instance pi
ON pi.package_id = p.package_id
AND pi.property_bitmask & 0x10 <> 0x10
INNER JOIN attribute_definition ad
ON ad.primitive_definition_id = pi.primitive_definition_id
AND ad.attribute_name NOT LIKE '[_]%'
AND ad.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
LEFT JOIN data_type dt
ON dt.mx_data_type = ad.mx_data_type
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
AND g.is_template = 0
AND g.deployed_package_id <> 0
UNION ALL
SELECT
gobject_id,
tag_name,
'' AS primitive_name,
attribute_name,
full_tag_reference,
mx_data_type,
data_type_name,
is_array,
array_dimension,
mx_attribute_category,
security_classification,
is_historized,
is_alarm,
'dynamic' AS attribute_source
FROM ranked_dynamic
WHERE rn = 1
) all_attributes
ORDER BY tag_name, primitive_name, attribute_name";
private const string PlatformLookupSql = @"
SELECT p.platform_gobject_id, p.node_name
FROM platform p
INNER JOIN gobject g ON g.gobject_id = p.platform_gobject_id
WHERE g.is_template = 0 AND g.deployed_package_id <> 0";
private const string ChangeDetectionSql = "SELECT time_of_last_deploy FROM galaxy";
private const string TestConnectionSql = "SELECT 1";
#endregion
}
}

View File

@@ -1,40 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
{
/// <summary>
/// POCO for dashboard: Galaxy repository status info. (DASH-009)
/// </summary>
public class GalaxyRepositoryStats
{
/// <summary>
/// Gets or sets the Galaxy name currently being represented by the bridge.
/// </summary>
public string GalaxyName { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the Galaxy repository database is reachable.
/// </summary>
public bool DbConnected { get; set; }
/// <summary>
/// Gets or sets the latest deploy timestamp read from the Galaxy repository.
/// </summary>
public DateTime? LastDeployTime { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy objects currently published into the OPC UA address space.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space.
/// </summary>
public int AttributeCount { get; set; }
/// <summary>
/// Gets or sets the UTC time when the address space was last rebuilt from repository data.
/// </summary>
public DateTime? LastRebuildTime { get; set; }
}
}

View File

@@ -1,124 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
{
/// <summary>
/// Filters a Galaxy object hierarchy to retain only objects hosted by a specific platform
/// and the structural areas needed to keep the browse tree connected.
/// </summary>
public static class PlatformScopeFilter
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(PlatformScopeFilter));
private const int CategoryWinPlatform = 1;
private const int CategoryAppEngine = 3;
/// <summary>
/// Filters the hierarchy to objects hosted by the platform whose <c>node_name</c> matches
/// <paramref name="platformName" />, plus ancestor areas that keep the tree connected.
/// </summary>
/// <param name="hierarchy">The full Galaxy object hierarchy.</param>
/// <param name="platforms">Deployed platform-to-hostname mappings from the <c>platform</c> table.</param>
/// <param name="platformName">The target hostname to match (case-insensitive).</param>
/// <returns>
/// The filtered hierarchy and the set of included gobject_ids (for attribute filtering).
/// When no matching platform is found, returns an empty list and empty set.
/// </returns>
public static (List<GalaxyObjectInfo> Hierarchy, HashSet<int> GobjectIds) Filter(
List<GalaxyObjectInfo> hierarchy,
List<PlatformInfo> platforms,
string platformName)
{
// Find the platform gobject_id that matches the target hostname.
var matchingPlatform = platforms.FirstOrDefault(
p => string.Equals(p.NodeName, platformName, StringComparison.OrdinalIgnoreCase));
if (matchingPlatform == null)
{
Log.Warning(
"Scope filter found no deployed platform matching node name '{PlatformName}'; " +
"available platforms: [{Available}]",
platformName,
string.Join(", ", platforms.Select(p => $"{p.NodeName} (gobject_id={p.GobjectId})")));
return (new List<GalaxyObjectInfo>(), new HashSet<int>());
}
var platformGobjectId = matchingPlatform.GobjectId;
Log.Information(
"Scope filter targeting platform '{PlatformName}' (gobject_id={GobjectId})",
platformName, platformGobjectId);
// Build a lookup for the hierarchy by gobject_id.
var byId = hierarchy.ToDictionary(o => o.GobjectId);
// Step 1: Collect all host gobject_ids under this platform.
// Walk outward from the platform to find AppEngines (and any deeper hosting objects).
var hostIds = new HashSet<int> { platformGobjectId };
bool changed;
do
{
changed = false;
foreach (var obj in hierarchy)
{
if (hostIds.Contains(obj.GobjectId))
continue;
if (obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId)
&& (obj.CategoryId == CategoryAppEngine || obj.CategoryId == CategoryWinPlatform))
{
hostIds.Add(obj.GobjectId);
changed = true;
}
}
} while (changed);
// Step 2: Include all non-area objects hosted by any host in the set, plus the hosts themselves.
var includedIds = new HashSet<int>(hostIds);
foreach (var obj in hierarchy)
{
if (includedIds.Contains(obj.GobjectId))
continue;
if (!obj.IsArea && obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId))
includedIds.Add(obj.GobjectId);
}
// Step 3: Walk ParentGobjectId chains upward to include ancestor areas so the tree stays connected.
var toWalk = new Queue<int>(includedIds);
while (toWalk.Count > 0)
{
var id = toWalk.Dequeue();
if (!byId.TryGetValue(id, out var obj))
continue;
var parentId = obj.ParentGobjectId;
if (parentId != 0 && byId.ContainsKey(parentId) && includedIds.Add(parentId))
toWalk.Enqueue(parentId);
}
// Step 4: Return the filtered hierarchy preserving original order.
var filtered = hierarchy.Where(o => includedIds.Contains(o.GobjectId)).ToList();
Log.Information(
"Scope filter retained {FilteredCount} of {TotalCount} objects for platform '{PlatformName}'",
filtered.Count, hierarchy.Count, platformName);
return (filtered, includedIds);
}
/// <summary>
/// Filters attributes to retain only those belonging to objects in the given set.
/// </summary>
public static List<GalaxyAttributeInfo> FilterAttributes(
List<GalaxyAttributeInfo> attributes,
HashSet<int> gobjectIds)
{
var filtered = attributes.Where(a => gobjectIds.Contains(a.GobjectId)).ToList();
Log.Information(
"Scope filter retained {FilteredCount} of {TotalCount} attributes",
filtered.Count, attributes.Count);
return filtered;
}
}
}

View File

@@ -1,31 +0,0 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// Maps OPC UA aggregate NodeIds to the Wonderware Historian AnalogSummary column names
/// consumed by the historian plugin. Kept in Host so HistoryReadProcessed can validate
/// aggregate support without requiring the plugin to be loaded.
/// </summary>
public static class HistorianAggregateMap
{
public static string? MapAggregateToColumn(NodeId aggregateId)
{
if (aggregateId == ObjectIds.AggregateFunction_Average)
return "Average";
if (aggregateId == ObjectIds.AggregateFunction_Minimum)
return "Minimum";
if (aggregateId == ObjectIds.AggregateFunction_Maximum)
return "Maximum";
if (aggregateId == ObjectIds.AggregateFunction_Count)
return "ValueCount";
if (aggregateId == ObjectIds.AggregateFunction_Start)
return "First";
if (aggregateId == ObjectIds.AggregateFunction_End)
return "Last";
if (aggregateId == ObjectIds.AggregateFunction_StandardDeviationPopulation)
return "StdDev";
return null;
}
}
}

View File

@@ -1,49 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// Point-in-time state of a single historian cluster node. One entry per configured node is
/// surfaced inside <see cref="HistorianHealthSnapshot"/> so the status dashboard can render
/// per-node health and operators can see which nodes are in cooldown.
/// </summary>
public sealed class HistorianClusterNodeState
{
/// <summary>
/// Gets or sets the configured node hostname exactly as it appears in
/// <c>HistorianConfiguration.ServerNames</c>.
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the node is currently eligible for new connection
/// attempts. <see langword="false"/> means the node is in its post-failure cooldown window
/// and the picker is skipping it.
/// </summary>
public bool IsHealthy { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp at which the node's cooldown expires, or
/// <see langword="null"/> when the node is not in cooldown.
/// </summary>
public DateTime? CooldownUntil { get; set; }
/// <summary>
/// Gets or sets the number of times this node has transitioned from healthy to failed
/// since startup. Does not decrement on recovery.
/// </summary>
public int FailureCount { get; set; }
/// <summary>
/// Gets or sets the message from the most recent failure, or <see langword="null"/> when
/// the node has never failed.
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the most recent failure, or <see langword="null"/>
/// when the node has never failed.
/// </summary>
public DateTime? LastFailureTime { get; set; }
}
}

View File

@@ -1,18 +0,0 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// SDK-free representation of a Historian event record exposed by the historian plugin.
/// Prevents ArchestrA types from leaking into the Host assembly.
/// </summary>
public sealed class HistorianEventDto
{
public Guid Id { get; set; }
public string? Source { get; set; }
public DateTime EventTime { get; set; }
public DateTime ReceivedTime { get; set; }
public string? DisplayText { get; set; }
public ushort Severity { get; set; }
}
}

View File

@@ -1,97 +0,0 @@
using System;
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// Point-in-time runtime health of the historian plugin, surfaced to the status dashboard
/// and health check service. Fills the gap between the load-time plugin status
/// (<see cref="HistorianPluginLoader.LastOutcome"/>) and actual query behavior so operators
/// can detect silent query degradation.
/// </summary>
public sealed class HistorianHealthSnapshot
{
/// <summary>
/// Gets or sets the total number of historian read operations attempted since startup
/// across all read paths (raw, aggregate, at-time, events).
/// </summary>
public long TotalQueries { get; set; }
/// <summary>
/// Gets or sets the total number of read operations that completed without an exception
/// being caught by the plugin's error handler. Includes empty result sets as successes —
/// the counter reflects "the SDK call returned" not "the SDK call returned data".
/// </summary>
public long TotalSuccesses { get; set; }
/// <summary>
/// Gets or sets the total number of read operations that raised an exception. Each failure
/// also resets and closes the underlying SDK connection via the existing reconnect path.
/// </summary>
public long TotalFailures { get; set; }
/// <summary>
/// Gets or sets the number of consecutive failures since the last success. Latches until
/// a successful query clears it. The health check service uses this as a degradation signal.
/// </summary>
public int ConsecutiveFailures { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the last successful read, or <see langword="null"/>
/// when no query has succeeded since startup.
/// </summary>
public DateTime? LastSuccessTime { get; set; }
/// <summary>
/// Gets or sets the UTC timestamp of the last failure, or <see langword="null"/> when no
/// query has failed since startup.
/// </summary>
public DateTime? LastFailureTime { get; set; }
/// <summary>
/// Gets or sets the exception message from the most recent failure. Cleared on the next
/// successful query.
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the plugin currently holds an open SDK
/// connection for the process (historical values) path.
/// </summary>
public bool ProcessConnectionOpen { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the plugin currently holds an open SDK
/// connection for the event (alarm history) path.
/// </summary>
public bool EventConnectionOpen { get; set; }
/// <summary>
/// Gets or sets the node the plugin is currently connected to for the process path,
/// or <see langword="null"/> when no connection is open.
/// </summary>
public string? ActiveProcessNode { get; set; }
/// <summary>
/// Gets or sets the node the plugin is currently connected to for the event path,
/// or <see langword="null"/> when no event connection is open.
/// </summary>
public string? ActiveEventNode { get; set; }
/// <summary>
/// Gets or sets the total number of configured historian cluster nodes. A value of 1
/// reflects a legacy single-node deployment.
/// </summary>
public int NodeCount { get; set; }
/// <summary>
/// Gets or sets the number of configured nodes that are currently healthy (not in cooldown).
/// </summary>
public int HealthyNodeCount { get; set; }
/// <summary>
/// Gets or sets the per-node cluster state in configuration order.
/// </summary>
public List<HistorianClusterNodeState> Nodes { get; set; } = new();
}
}

View File

@@ -1,180 +0,0 @@
using System;
using System.IO;
using System.Reflection;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// Result of the most recent historian plugin load attempt.
/// </summary>
public enum HistorianPluginStatus
{
/// <summary>Historian.Enabled is false; TryLoad was not called.</summary>
Disabled,
/// <summary>Plugin DLL was not present in the Historian/ subfolder.</summary>
NotFound,
/// <summary>Plugin file exists but could not be loaded or instantiated.</summary>
LoadFailed,
/// <summary>Plugin loaded and an IHistorianDataSource was constructed.</summary>
Loaded
}
/// <summary>
/// Structured outcome of a <see cref="HistorianPluginLoader.TryLoad"/> or
/// <see cref="HistorianPluginLoader.MarkDisabled"/> call, used by the status dashboard.
/// </summary>
public sealed class HistorianPluginOutcome
{
public HistorianPluginOutcome(HistorianPluginStatus status, string pluginPath, string? error)
{
Status = status;
PluginPath = pluginPath;
Error = error;
}
public HistorianPluginStatus Status { get; }
public string PluginPath { get; }
public string? Error { get; }
}
/// <summary>
/// Loads the Wonderware historian plugin assembly from the Historian/ subfolder next to
/// the host executable. Used so the aahClientManaged SDK is not needed on hosts that run
/// with Historian.Enabled=false.
/// </summary>
public static class HistorianPluginLoader
{
private const string PluginSubfolder = "Historian";
private const string PluginAssemblyName = "ZB.MOM.WW.OtOpcUa.Historian.Aveva";
private const string PluginEntryType = "ZB.MOM.WW.OtOpcUa.Historian.Aveva.AvevaHistorianPluginEntry";
private const string PluginEntryMethod = "Create";
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(HistorianPluginLoader));
private static readonly object ResolverGate = new object();
private static bool _resolverInstalled;
private static string? _resolvedProbeDirectory;
/// <summary>
/// Gets the outcome of the most recent load attempt (or <see cref="HistorianPluginStatus.Disabled"/>
/// if the loader has never been invoked). The dashboard reads this to distinguish "disabled",
/// "plugin missing", and "plugin crashed".
/// </summary>
public static HistorianPluginOutcome LastOutcome { get; private set; }
= new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
/// <summary>
/// Records that the historian plugin is disabled by configuration. Called by
/// <c>OpcUaService</c> when <c>Historian.Enabled=false</c> so the status dashboard can
/// report the exact reason history is unavailable.
/// </summary>
public static void MarkDisabled()
{
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
}
/// <summary>
/// Attempts to load the historian plugin and construct an <see cref="IHistorianDataSource"/>.
/// Returns null on any failure so the server can continue with history unsupported. The
/// specific reason is published on <see cref="LastOutcome"/>.
/// </summary>
public static IHistorianDataSource? TryLoad(HistorianConfiguration config)
{
var pluginDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, PluginSubfolder);
var pluginPath = Path.Combine(pluginDirectory, PluginAssemblyName + ".dll");
if (!File.Exists(pluginPath))
{
Log.Warning(
"Historian plugin not found at {PluginPath} — history read operations will return BadHistoryOperationUnsupported",
pluginPath);
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.NotFound, pluginPath, null);
return null;
}
EnsureAssemblyResolverInstalled(pluginDirectory);
try
{
var assembly = Assembly.LoadFrom(pluginPath);
var entryType = assembly.GetType(PluginEntryType, throwOnError: false);
if (entryType == null)
{
Log.Warning("Historian plugin {PluginPath} does not expose {EntryType}", pluginPath, PluginEntryType);
LastOutcome = new HistorianPluginOutcome(
HistorianPluginStatus.LoadFailed, pluginPath,
$"Plugin assembly does not expose entry type {PluginEntryType}");
return null;
}
var create = entryType.GetMethod(PluginEntryMethod, BindingFlags.Public | BindingFlags.Static);
if (create == null)
{
Log.Warning("Historian plugin entry type {EntryType} missing static {Method}", PluginEntryType, PluginEntryMethod);
LastOutcome = new HistorianPluginOutcome(
HistorianPluginStatus.LoadFailed, pluginPath,
$"Plugin entry type {PluginEntryType} is missing a public static {PluginEntryMethod} method");
return null;
}
var result = create.Invoke(null, new object[] { config });
if (result is IHistorianDataSource dataSource)
{
Log.Information("Historian plugin loaded from {PluginPath}", pluginPath);
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Loaded, pluginPath, null);
return dataSource;
}
Log.Warning("Historian plugin {PluginPath} returned an object that does not implement IHistorianDataSource", pluginPath);
LastOutcome = new HistorianPluginOutcome(
HistorianPluginStatus.LoadFailed, pluginPath,
"Plugin entry method returned an object that does not implement IHistorianDataSource");
return null;
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to load historian plugin from {PluginPath} — history disabled", pluginPath);
LastOutcome = new HistorianPluginOutcome(
HistorianPluginStatus.LoadFailed, pluginPath,
ex.GetBaseException().Message);
return null;
}
}
private static void EnsureAssemblyResolverInstalled(string pluginDirectory)
{
lock (ResolverGate)
{
_resolvedProbeDirectory = pluginDirectory;
if (_resolverInstalled)
return;
AppDomain.CurrentDomain.AssemblyResolve += ResolveFromPluginDirectory;
_resolverInstalled = true;
}
}
private static Assembly? ResolveFromPluginDirectory(object? sender, ResolveEventArgs args)
{
var probeDirectory = _resolvedProbeDirectory;
if (string.IsNullOrEmpty(probeDirectory))
return null;
var requested = new AssemblyName(args.Name);
var candidate = Path.Combine(probeDirectory!, requested.Name + ".dll");
if (!File.Exists(candidate))
return null;
try
{
return Assembly.LoadFrom(candidate);
}
catch (Exception ex)
{
Log.Debug(ex, "Historian plugin resolver failed to load {Candidate}", candidate);
return null;
}
}
}
}

View File

@@ -1,97 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using Opc.Ua;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// Manages continuation points for OPC UA HistoryRead requests that return
/// more data than the per-request limit allows.
/// </summary>
internal sealed class HistoryContinuationPointManager
{
private static readonly ILogger Log = Serilog.Log.ForContext<HistoryContinuationPointManager>();
private readonly ConcurrentDictionary<Guid, StoredContinuation> _store = new();
private readonly TimeSpan _timeout;
public HistoryContinuationPointManager() : this(TimeSpan.FromMinutes(5)) { }
internal HistoryContinuationPointManager(TimeSpan timeout)
{
_timeout = timeout;
}
/// <summary>
/// Stores remaining data values and returns a continuation point identifier.
/// </summary>
public byte[] Store(List<DataValue> remaining)
{
PurgeExpired();
var id = Guid.NewGuid();
_store[id] = new StoredContinuation(remaining, DateTime.UtcNow);
Log.Debug("Stored history continuation point {Id} with {Count} remaining values", id, remaining.Count);
return id.ToByteArray();
}
/// <summary>
/// Retrieves and removes the remaining data values for a continuation point.
/// Returns null if the continuation point is invalid or expired.
/// </summary>
public List<DataValue>? Retrieve(byte[] continuationPoint)
{
PurgeExpired();
if (continuationPoint == null || continuationPoint.Length != 16)
return null;
var id = new Guid(continuationPoint);
if (!_store.TryRemove(id, out var stored))
return null;
if (DateTime.UtcNow - stored.CreatedAt > _timeout)
{
Log.Debug("History continuation point {Id} expired", id);
return null;
}
return stored.Values;
}
/// <summary>
/// Releases a continuation point without retrieving its data.
/// </summary>
public void Release(byte[] continuationPoint)
{
PurgeExpired();
if (continuationPoint == null || continuationPoint.Length != 16)
return;
var id = new Guid(continuationPoint);
_store.TryRemove(id, out _);
}
private void PurgeExpired()
{
var cutoff = DateTime.UtcNow - _timeout;
foreach (var kvp in _store)
{
if (kvp.Value.CreatedAt < cutoff)
_store.TryRemove(kvp.Key, out _);
}
}
private sealed class StoredContinuation
{
public StoredContinuation(List<DataValue> values, DateTime createdAt)
{
Values = values;
CreatedAt = createdAt;
}
public List<DataValue> Values { get; }
public DateTime CreatedAt { get; }
}
}
}

View File

@@ -1,40 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// OPC UA-typed surface for the historian plugin. Host consumers depend only on this
/// interface so the Wonderware Historian SDK assemblies are not required unless the
/// plugin is loaded at runtime.
/// </summary>
public interface IHistorianDataSource : IDisposable
{
Task<List<DataValue>> ReadRawAsync(
string tagName, DateTime startTime, DateTime endTime, int maxValues,
CancellationToken ct = default);
Task<List<DataValue>> ReadAggregateAsync(
string tagName, DateTime startTime, DateTime endTime,
double intervalMs, string aggregateColumn,
CancellationToken ct = default);
Task<List<DataValue>> ReadAtTimeAsync(
string tagName, DateTime[] timestamps,
CancellationToken ct = default);
Task<List<HistorianEventDto>> ReadEventsAsync(
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
CancellationToken ct = default);
/// <summary>
/// Returns a runtime snapshot of query success/failure counters and connection state.
/// Consumed by the status dashboard and health check service so operators can detect
/// silent query degradation that the load-time plugin status can't catch.
/// </summary>
HistorianHealthSnapshot GetHealthSnapshot();
}
}

View File

@@ -1,265 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Host.Metrics
{
/// <summary>
/// Disposable scope returned by <see cref="PerformanceMetrics.BeginOperation" />. (MXA-008)
/// </summary>
public interface ITimingScope : IDisposable
{
/// <summary>
/// Marks whether the timed bridge operation completed successfully.
/// </summary>
/// <param name="success">A value indicating whether the measured operation succeeded.</param>
void SetSuccess(bool success);
}
/// <summary>
/// Statistics snapshot for a single operation type.
/// </summary>
public class MetricsStatistics
{
/// <summary>
/// Gets or sets the total number of recorded executions for the operation.
/// </summary>
public long TotalCount { get; set; }
/// <summary>
/// Gets or sets the number of recorded executions that completed successfully.
/// </summary>
public long SuccessCount { get; set; }
/// <summary>
/// Gets or sets the ratio of successful executions to total executions.
/// </summary>
public double SuccessRate { get; set; }
/// <summary>
/// Gets or sets the mean execution time in milliseconds across the recorded sample.
/// </summary>
public double AverageMilliseconds { get; set; }
/// <summary>
/// Gets or sets the fastest recorded execution time in milliseconds.
/// </summary>
public double MinMilliseconds { get; set; }
/// <summary>
/// Gets or sets the slowest recorded execution time in milliseconds.
/// </summary>
public double MaxMilliseconds { get; set; }
/// <summary>
/// Gets or sets the 95th percentile execution time in milliseconds.
/// </summary>
public double Percentile95Milliseconds { get; set; }
}
/// <summary>
/// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008)
/// </summary>
public class OperationMetrics
{
private readonly List<double> _durations = new();
private readonly object _lock = new();
private double _maxMilliseconds;
private double _minMilliseconds = double.MaxValue;
private long _successCount;
private long _totalCount;
private double _totalMilliseconds;
/// <summary>
/// Records the outcome and duration of a single bridge operation invocation.
/// </summary>
/// <param name="duration">The elapsed time for the operation.</param>
/// <param name="success">A value indicating whether the operation completed successfully.</param>
public void Record(TimeSpan duration, bool success)
{
lock (_lock)
{
_totalCount++;
if (success) _successCount++;
var ms = duration.TotalMilliseconds;
_durations.Add(ms);
_totalMilliseconds += ms;
if (ms < _minMilliseconds) _minMilliseconds = ms;
if (ms > _maxMilliseconds) _maxMilliseconds = ms;
if (_durations.Count > 1000) _durations.RemoveAt(0);
}
}
/// <summary>
/// Creates a snapshot of the current statistics for this operation type.
/// </summary>
/// <returns>A statistics snapshot suitable for logs, status reporting, and tests.</returns>
public MetricsStatistics GetStatistics()
{
lock (_lock)
{
if (_totalCount == 0)
return new MetricsStatistics();
var sorted = _durations.OrderBy(d => d).ToList();
var p95Index = Math.Max(0, (int)Math.Ceiling(sorted.Count * 0.95) - 1);
return new MetricsStatistics
{
TotalCount = _totalCount,
SuccessCount = _successCount,
SuccessRate = (double)_successCount / _totalCount,
AverageMilliseconds = _totalMilliseconds / _totalCount,
MinMilliseconds = _minMilliseconds,
MaxMilliseconds = _maxMilliseconds,
Percentile95Milliseconds = sorted[p95Index]
};
}
}
}
/// <summary>
/// Tracks per-operation performance metrics with periodic logging. (MXA-008)
/// </summary>
public class PerformanceMetrics : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
private readonly ConcurrentDictionary<string, OperationMetrics>
_metrics = new(StringComparer.OrdinalIgnoreCase);
private readonly Timer _reportingTimer;
private bool _disposed;
/// <summary>
/// Initializes a new metrics collector and starts periodic performance reporting.
/// </summary>
public PerformanceMetrics()
{
_reportingTimer = new Timer(ReportMetrics, null,
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
}
/// <summary>
/// Stops periodic reporting and emits a final metrics snapshot.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_reportingTimer.Dispose();
ReportMetrics(null);
}
/// <summary>
/// Records a completed bridge operation under the specified metrics bucket.
/// </summary>
/// <param name="operationName">The logical operation name, such as read, write, or subscribe.</param>
/// <param name="duration">The elapsed time for the operation.</param>
/// <param name="success">A value indicating whether the operation completed successfully.</param>
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
{
var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
metrics.Record(duration, success);
}
/// <summary>
/// Starts timing a bridge operation and returns a disposable scope that records the result when disposed.
/// </summary>
/// <param name="operationName">The logical operation name to record.</param>
/// <returns>A timing scope that reports elapsed time back into this collector.</returns>
public ITimingScope BeginOperation(string operationName)
{
return new TimingScope(this, operationName);
}
/// <summary>
/// Retrieves the raw metrics bucket for a named operation.
/// </summary>
/// <param name="operationName">The logical operation name to look up.</param>
/// <returns>The metrics bucket when present; otherwise, <see langword="null" />.</returns>
public OperationMetrics? GetMetrics(string operationName)
{
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
}
/// <summary>
/// Produces a statistics snapshot for all recorded bridge operations.
/// </summary>
/// <returns>A dictionary keyed by operation name containing current metrics statistics.</returns>
public Dictionary<string, MetricsStatistics> GetStatistics()
{
var result = new Dictionary<string, MetricsStatistics>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in _metrics)
result[kvp.Key] = kvp.Value.GetStatistics();
return result;
}
private void ReportMetrics(object? state)
{
foreach (var kvp in _metrics)
{
var stats = kvp.Value.GetStatistics();
if (stats.TotalCount == 0) continue;
Logger.Information(
"Metrics: {Operation} — Count={Count}, SuccessRate={SuccessRate:P1}, " +
"AvgMs={AverageMs:F1}, MinMs={MinMs:F1}, MaxMs={MaxMs:F1}, P95Ms={P95Ms:F1}",
kvp.Key, stats.TotalCount, stats.SuccessRate,
stats.AverageMilliseconds, stats.MinMilliseconds,
stats.MaxMilliseconds, stats.Percentile95Milliseconds);
}
}
/// <summary>
/// Timing scope that records one operation result into the owning metrics collector.
/// </summary>
private class TimingScope : ITimingScope
{
private readonly PerformanceMetrics _metrics;
private readonly string _operationName;
private readonly Stopwatch _stopwatch;
private bool _disposed;
private bool _success = true;
/// <summary>
/// Initializes a timing scope for a named bridge operation.
/// </summary>
/// <param name="metrics">The metrics collector that should receive the result.</param>
/// <param name="operationName">The logical operation name being timed.</param>
public TimingScope(PerformanceMetrics metrics, string operationName)
{
_metrics = metrics;
_operationName = operationName;
_stopwatch = Stopwatch.StartNew();
}
/// <summary>
/// Marks whether the timed operation should be recorded as successful.
/// </summary>
/// <param name="success">A value indicating whether the operation succeeded.</param>
public void SetSuccess(bool success)
{
_success = success;
}
/// <summary>
/// Stops timing and records the operation result once.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_stopwatch.Stop();
_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
}
}
}
}

View File

@@ -1,472 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
{
/// <summary>
/// Advises <c>&lt;ObjectName&gt;.ScanState</c> on every deployed <c>$WinPlatform</c> and
/// <c>$AppEngine</c>, tracks their runtime state (Unknown / Running / Stopped), and notifies
/// the owning node manager on Running↔Stopped transitions so it can proactively flip every
/// OPC UA variable hosted by that object to <c>BadOutOfService</c> (and clear on recovery).
/// </summary>
/// <remarks>
/// State machine semantics are documented in <c>runtimestatus.md</c>. Key facts:
/// <list type="bullet">
/// <item><c>ScanState</c> is delivered on-change only — no periodic heartbeat. A stably
/// Running host may go hours without a callback.</item>
/// <item>Running → Stopped is driven by explicit error callbacks or <c>ScanState = false</c>,
/// NEVER by starvation. The only starvation check applies to the initial Unknown state.</item>
/// <item>When the MxAccess transport is disconnected, <see cref="GetSnapshot"/> returns every
/// entry with <see cref="GalaxyRuntimeState.Unknown"/> regardless of the underlying state,
/// because we can't observe anything through a dead transport.</item>
/// <item>The stop/start callbacks fire synchronously from whichever thread delivered the
/// probe update. The manager releases its own lock before invoking them to avoid
/// lock-inversion deadlocks with the node manager's <c>Lock</c>.</item>
/// </list>
/// </remarks>
public sealed class GalaxyRuntimeProbeManager : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<GalaxyRuntimeProbeManager>();
private const int CategoryWinPlatform = 1;
private const int CategoryAppEngine = 3;
private const string KindWinPlatform = "$WinPlatform";
private const string KindAppEngine = "$AppEngine";
private const string ProbeAttribute = ".ScanState";
private readonly IMxAccessClient _client;
private readonly TimeSpan _unknownTimeout;
private readonly Action<int>? _onHostStopped;
private readonly Action<int>? _onHostRunning;
private readonly Func<DateTime> _clock;
// Key: probe tag reference (e.g. "DevAppEngine.ScanState").
// Value: the current runtime status for that host, kept in sync on every probe callback
// and queried via GetSnapshot for dashboard rendering.
private readonly Dictionary<string, GalaxyRuntimeStatus> _byProbe =
new Dictionary<string, GalaxyRuntimeStatus>(StringComparer.OrdinalIgnoreCase);
// Reverse index: gobject_id -> probe tag, so Sync() can diff new/removed hosts efficiently.
private readonly Dictionary<int, string> _probeByGobjectId = new Dictionary<int, string>();
private readonly object _lock = new object();
private bool _disposed;
/// <summary>
/// Initializes a new probe manager. <paramref name="onHostStopped"/> and
/// <paramref name="onHostRunning"/> are invoked synchronously on Running↔Stopped
/// transitions so the owning node manager can invalidate / restore the hosted subtree.
/// </summary>
public GalaxyRuntimeProbeManager(
IMxAccessClient client,
int unknownTimeoutSeconds,
Action<int>? onHostStopped = null,
Action<int>? onHostRunning = null)
: this(client, unknownTimeoutSeconds, onHostStopped, onHostRunning, () => DateTime.UtcNow)
{
}
internal GalaxyRuntimeProbeManager(
IMxAccessClient client,
int unknownTimeoutSeconds,
Action<int>? onHostStopped,
Action<int>? onHostRunning,
Func<DateTime> clock)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_unknownTimeout = TimeSpan.FromSeconds(Math.Max(1, unknownTimeoutSeconds));
_onHostStopped = onHostStopped;
_onHostRunning = onHostRunning;
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
/// <summary>
/// Gets the number of active probe subscriptions. Surfaced on the dashboard Subscriptions
/// panel so operators can see bridge-owned probe count separately from the total.
/// </summary>
public int ActiveProbeCount
{
get
{
lock (_lock)
return _byProbe.Count;
}
}
/// <summary>
/// Returns <see langword="true"/> when the galaxy runtime host identified by
/// <paramref name="gobjectId"/> is currently in the <see cref="GalaxyRuntimeState.Stopped"/>
/// state. Used by the node manager's Read path to short-circuit on-demand reads of tags
/// hosted by a known-stopped runtime object, preventing MxAccess from serving stale
/// cached values as Good. Unlike <see cref="GetSnapshot"/> this check uses the
/// underlying state directly — transport-disconnected hosts will NOT report Stopped here
/// (they report their last-known state), because connection-loss is handled by the
/// normal MxAccess error paths and we don't want this method to double-flag.
/// </summary>
public bool IsHostStopped(int gobjectId)
{
lock (_lock)
{
if (_probeByGobjectId.TryGetValue(gobjectId, out var probe)
&& _byProbe.TryGetValue(probe, out var status))
{
return status.State == GalaxyRuntimeState.Stopped;
}
}
return false;
}
/// <summary>
/// Returns a point-in-time clone of the runtime status for the host identified by
/// <paramref name="gobjectId"/>, or <see langword="null"/> when no probe is registered
/// for that object. Used by the node manager to populate the synthetic <c>$RuntimeState</c>
/// child variables on each host object. Uses the underlying state directly (not the
/// transport-gated rewrite), matching <see cref="IsHostStopped"/>.
/// </summary>
public GalaxyRuntimeStatus? GetHostStatus(int gobjectId)
{
lock (_lock)
{
if (_probeByGobjectId.TryGetValue(gobjectId, out var probe)
&& _byProbe.TryGetValue(probe, out var status))
{
return Clone(status, forceUnknown: false);
}
}
return null;
}
/// <summary>
/// Diffs the supplied hierarchy against the active probe set, advising new hosts and
/// unadvising removed ones. The hierarchy is filtered to runtime host categories
/// ($WinPlatform, $AppEngine) — non-host rows are ignored. Idempotent: a second call
/// with the same hierarchy performs no Advise / Unadvise work.
/// </summary>
/// <remarks>
/// Sync is synchronous on MxAccess: <see cref="IMxAccessClient.SubscribeAsync"/> is
/// awaited for each new host, so for a galaxy with N runtime hosts the call blocks for
/// ~N round-trips. This is acceptable because it only runs during address-space build
/// and rebuild, not on the hot path.
/// </remarks>
public async Task SyncAsync(IReadOnlyList<GalaxyObjectInfo> hierarchy)
{
if (_disposed || hierarchy == null)
return;
// Filter to runtime hosts and project to the expected probe tag name.
var desired = new Dictionary<int, (string Probe, string Kind, GalaxyObjectInfo Obj)>();
foreach (var obj in hierarchy)
{
if (obj.CategoryId != CategoryWinPlatform && obj.CategoryId != CategoryAppEngine)
continue;
if (string.IsNullOrWhiteSpace(obj.TagName))
continue;
var probe = obj.TagName + ProbeAttribute;
var kind = obj.CategoryId == CategoryWinPlatform ? KindWinPlatform : KindAppEngine;
desired[obj.GobjectId] = (probe, kind, obj);
}
// Compute diffs under lock, release lock before issuing SDK calls (which can block).
// toSubscribe carries the gobject id alongside the probe name so the rollback path on
// subscribe failure can unwind both dictionaries without a reverse lookup.
List<(int GobjectId, string Probe)> toSubscribe;
List<string> toUnsubscribe;
lock (_lock)
{
toSubscribe = new List<(int, string)>();
toUnsubscribe = new List<string>();
foreach (var kvp in desired)
{
if (_probeByGobjectId.TryGetValue(kvp.Key, out var existingProbe))
{
// Already tracked: ensure the status entry is aligned (tag rename path is
// intentionally not supported — if the probe changed, treat it as remove+add).
if (!string.Equals(existingProbe, kvp.Value.Probe, StringComparison.OrdinalIgnoreCase))
{
toUnsubscribe.Add(existingProbe);
_byProbe.Remove(existingProbe);
_probeByGobjectId.Remove(kvp.Key);
toSubscribe.Add((kvp.Key, kvp.Value.Probe));
_byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind);
_probeByGobjectId[kvp.Key] = kvp.Value.Probe;
}
}
else
{
toSubscribe.Add((kvp.Key, kvp.Value.Probe));
_byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind);
_probeByGobjectId[kvp.Key] = kvp.Value.Probe;
}
}
// Remove hosts that are no longer in the desired set.
var toRemove = _probeByGobjectId.Keys.Where(id => !desired.ContainsKey(id)).ToList();
foreach (var id in toRemove)
{
var probe = _probeByGobjectId[id];
toUnsubscribe.Add(probe);
_byProbe.Remove(probe);
_probeByGobjectId.Remove(id);
}
}
// Apply the diff outside the lock.
foreach (var (gobjectId, probe) in toSubscribe)
{
try
{
await _client.SubscribeAsync(probe, OnProbeValueChanged);
Log.Information("Galaxy runtime probe advised: {Probe}", probe);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to advise galaxy runtime probe {Probe}", probe);
// Roll back the pending entry so Tick() can't later transition a never-advised
// probe from Unknown to Stopped and fan out a false-negative host-down signal.
// A concurrent SyncAsync may have re-added the same gobject under a new probe
// name, so compare against the captured probe string before removing.
lock (_lock)
{
if (_probeByGobjectId.TryGetValue(gobjectId, out var current)
&& string.Equals(current, probe, StringComparison.OrdinalIgnoreCase))
{
_probeByGobjectId.Remove(gobjectId);
}
_byProbe.Remove(probe);
}
}
}
foreach (var probe in toUnsubscribe)
{
try
{
await _client.UnsubscribeAsync(probe);
}
catch (Exception ex)
{
Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during sync", probe);
}
}
}
/// <summary>
/// Routes an <c>OnTagValueChanged</c> callback to the probe state machine. Returns
/// <see langword="true"/> when <paramref name="tagRef"/> matches a bridge-owned probe
/// (in which case the owning node manager should skip its normal variable-update path).
/// </summary>
public bool HandleProbeUpdate(string tagRef, Vtq vtq)
{
if (_disposed || string.IsNullOrEmpty(tagRef))
return false;
GalaxyRuntimeStatus? status;
int fromToGobjectId = 0;
GalaxyRuntimeState? transitionTo = null;
lock (_lock)
{
if (!_byProbe.TryGetValue(tagRef, out status))
return false; // not a probe — let the caller handle it normally
var now = _clock();
var isRunning = vtq.Quality.IsGood() && vtq.Value is bool b && b;
status.LastStateCallbackTime = now;
status.LastScanState = vtq.Value as bool?;
if (isRunning)
{
status.GoodUpdateCount++;
status.LastError = null;
if (status.State != GalaxyRuntimeState.Running)
{
// Only fire the host-running callback on a true Stopped → Running
// recovery. Unknown → Running happens once at startup for every host
// and is not a recovery — firing ClearHostVariablesBadQuality there
// would wipe Bad status set by the concurrently-stopping other host
// on variables that span both lists.
var wasStopped = status.State == GalaxyRuntimeState.Stopped;
status.State = GalaxyRuntimeState.Running;
status.LastStateChangeTime = now;
if (wasStopped)
{
transitionTo = GalaxyRuntimeState.Running;
fromToGobjectId = status.GobjectId;
}
}
}
else
{
status.FailureCount++;
status.LastError = BuildErrorDetail(vtq);
if (status.State != GalaxyRuntimeState.Stopped)
{
status.State = GalaxyRuntimeState.Stopped;
status.LastStateChangeTime = now;
transitionTo = GalaxyRuntimeState.Stopped;
fromToGobjectId = status.GobjectId;
}
}
}
// Invoke transition callbacks outside the lock to avoid inverting the node manager's
// lock order when it subsequently takes its own Lock to flip hosted variables.
if (transitionTo == GalaxyRuntimeState.Stopped)
{
Log.Information("Galaxy runtime {Probe} transitioned Running → Stopped ({Err})",
tagRef, status?.LastError ?? "(no detail)");
try { _onHostStopped?.Invoke(fromToGobjectId); }
catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw for {Probe}", tagRef); }
}
else if (transitionTo == GalaxyRuntimeState.Running)
{
Log.Information("Galaxy runtime {Probe} transitioned → Running", tagRef);
try { _onHostRunning?.Invoke(fromToGobjectId); }
catch (Exception ex) { Log.Warning(ex, "onHostRunning callback threw for {Probe}", tagRef); }
}
return true;
}
/// <summary>
/// Periodic tick — flips Unknown entries to Stopped once their registration has been
/// outstanding for longer than the configured timeout without ever receiving a first
/// callback. Does nothing to Running or Stopped entries.
/// </summary>
public void Tick()
{
if (_disposed)
return;
var transitions = new List<int>();
lock (_lock)
{
var now = _clock();
foreach (var entry in _byProbe.Values)
{
if (entry.State != GalaxyRuntimeState.Unknown)
continue;
// LastStateChangeTime is set at creation to "now" so the timeout is measured
// from when the probe was advised.
if (entry.LastStateChangeTime.HasValue
&& now - entry.LastStateChangeTime.Value > _unknownTimeout)
{
entry.State = GalaxyRuntimeState.Stopped;
entry.LastStateChangeTime = now;
entry.FailureCount++;
entry.LastError = "Probe never received an initial callback within the unknown-resolution timeout";
transitions.Add(entry.GobjectId);
}
}
}
foreach (var gobjectId in transitions)
{
Log.Warning("Galaxy runtime gobject {GobjectId} timed out in Unknown state → Stopped", gobjectId);
try { _onHostStopped?.Invoke(gobjectId); }
catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw during tick for {GobjectId}", gobjectId); }
}
}
/// <summary>
/// Returns a read-only snapshot of every tracked host. When the MxAccess transport is
/// disconnected, every entry is rewritten to Unknown on the way out so operators aren't
/// misled by cached per-host state — the Connection panel is the primary signal in that
/// case. The underlying <c>_byProbe</c> map is not modified.
/// </summary>
public IReadOnlyList<GalaxyRuntimeStatus> GetSnapshot()
{
var transportDown = _client.State != ConnectionState.Connected;
lock (_lock)
{
var result = new List<GalaxyRuntimeStatus>(_byProbe.Count);
foreach (var entry in _byProbe.Values)
result.Add(Clone(entry, forceUnknown: transportDown));
// Stable ordering by name so dashboard rows don't jitter between refreshes.
result.Sort((a, b) => string.CompareOrdinal(a.ObjectName, b.ObjectName));
return result;
}
}
/// <inheritdoc />
public void Dispose()
{
List<string> probes;
lock (_lock)
{
if (_disposed)
return;
_disposed = true;
probes = _byProbe.Keys.ToList();
_byProbe.Clear();
_probeByGobjectId.Clear();
}
foreach (var probe in probes)
{
try
{
_client.UnsubscribeAsync(probe).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during Dispose", probe);
}
}
}
private void OnProbeValueChanged(string tagRef, Vtq vtq)
{
HandleProbeUpdate(tagRef, vtq);
}
private GalaxyRuntimeStatus MakeInitialStatus(GalaxyObjectInfo obj, string kind)
{
return new GalaxyRuntimeStatus
{
ObjectName = obj.TagName,
GobjectId = obj.GobjectId,
Kind = kind,
State = GalaxyRuntimeState.Unknown,
LastStateChangeTime = _clock()
};
}
private static GalaxyRuntimeStatus Clone(GalaxyRuntimeStatus src, bool forceUnknown)
{
return new GalaxyRuntimeStatus
{
ObjectName = src.ObjectName,
GobjectId = src.GobjectId,
Kind = src.Kind,
State = forceUnknown ? GalaxyRuntimeState.Unknown : src.State,
LastStateCallbackTime = src.LastStateCallbackTime,
LastStateChangeTime = src.LastStateChangeTime,
LastScanState = src.LastScanState,
LastError = forceUnknown ? null : src.LastError,
GoodUpdateCount = src.GoodUpdateCount,
FailureCount = src.FailureCount
};
}
private static string BuildErrorDetail(Vtq vtq)
{
if (vtq.Quality.IsBad())
return $"bad quality ({vtq.Quality})";
if (vtq.Quality.IsUncertain())
return $"uncertain quality ({vtq.Quality})";
if (vtq.Value is bool b && !b)
return "ScanState = false (OffScan)";
return $"unexpected value: {vtq.Value ?? "(null)"}";
}
}
}

View File

@@ -1,149 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription.
/// </summary>
/// <param name="ct">A token that cancels the connection attempt.</param>
public async Task ConnectAsync(CancellationToken ct = default)
{
if (_state == ConnectionState.Connected) return;
SetState(ConnectionState.Connecting);
try
{
_connectionHandle = await _staThread.RunAsync(() =>
{
AttachProxyEvents();
return _proxy.Register(_config.ClientName);
});
Log.Information("MxAccess registered with handle {Handle}", _connectionHandle);
SetState(ConnectionState.Connected);
// Replay stored subscriptions
await ReplayStoredSubscriptionsAsync();
// Start probe if configured
if (!string.IsNullOrWhiteSpace(_config.ProbeTag))
{
_probeTag = _config.ProbeTag;
_lastProbeValueTime = DateTime.UtcNow;
await SubscribeInternalAsync(_probeTag!);
Log.Information("Probe tag subscribed: {ProbeTag}", _probeTag);
}
}
catch (Exception ex)
{
try
{
await _staThread.RunAsync(DetachProxyEvents);
}
catch (Exception cleanupEx)
{
Log.Warning(cleanupEx, "Failed to detach proxy events after connection failure");
}
Log.Error(ex, "MxAccess connection failed");
SetState(ConnectionState.Error, ex.Message);
throw;
}
}
/// <summary>
/// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations.
/// </summary>
public async Task DisconnectAsync()
{
if (_state == ConnectionState.Disconnected) return;
SetState(ConnectionState.Disconnecting);
try
{
await _staThread.RunAsync(() =>
{
// UnAdvise + RemoveItem for all active subscriptions
foreach (var kvp in _addressToHandle)
try
{
_proxy.UnAdviseSupervisory(_connectionHandle, kvp.Value);
_proxy.RemoveItem(_connectionHandle, kvp.Value);
}
catch (Exception ex)
{
Log.Warning(ex, "Error cleaning up subscription for {Address}", kvp.Key);
}
// Unwire events before unregister
DetachProxyEvents();
// Unregister
try
{
_proxy.Unregister(_connectionHandle);
}
catch (Exception ex)
{
Log.Warning(ex, "Error during Unregister");
}
});
_handleToAddress.Clear();
_addressToHandle.Clear();
_pendingReadsByAddress.Clear();
_pendingWrites.Clear();
}
catch (Exception ex)
{
Log.Warning(ex, "Error during disconnect");
}
finally
{
SetState(ConnectionState.Disconnected);
}
}
/// <summary>
/// Attempts to recover from a runtime fault by disconnecting and reconnecting the client.
/// </summary>
public async Task ReconnectAsync()
{
SetState(ConnectionState.Reconnecting);
Interlocked.Increment(ref _reconnectCount);
Log.Information("MxAccess reconnect attempt #{Count}", _reconnectCount);
try
{
await DisconnectAsync();
await ConnectAsync();
}
catch (Exception ex)
{
Log.Error(ex, "Reconnect failed");
SetState(ConnectionState.Error, ex.Message);
}
}
private void AttachProxyEvents()
{
if (_proxyEventsAttached) return;
_proxy.OnDataChange += HandleOnDataChange;
_proxy.OnWriteComplete += HandleOnWriteComplete;
_proxyEventsAttached = true;
}
private void DetachProxyEvents()
{
if (!_proxyEventsAttached) return;
_proxy.OnDataChange -= HandleOnDataChange;
_proxy.OnWriteComplete -= HandleOnWriteComplete;
_proxyEventsAttached = false;
}
}
}

View File

@@ -1,97 +0,0 @@
using System;
using ArchestrA.MxAccess;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// COM event handler for MxAccess OnDataChange events.
/// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface.
/// </summary>
private void HandleOnDataChange(
int hLMXServerHandle,
int phItemHandle,
object pvItemValue,
int pwItemQuality,
object pftItemTimeStamp,
ref MXSTATUS_PROXY[] ItemStatus)
{
try
{
if (!_handleToAddress.TryGetValue(phItemHandle, out var address))
{
Log.Debug("OnDataChange for unknown handle {Handle}", phItemHandle);
return;
}
var quality = QualityMapper.MapFromMxAccessQuality(pwItemQuality);
// Check MXSTATUS_PROXY — if success is false, use more specific quality
if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0)
quality = MxErrorCodes.MapToQuality(ItemStatus[0].detail);
var timestamp = ConvertTimestamp(pftItemTimeStamp);
var vtq = new Vtq(pvItemValue, timestamp, quality);
// Update probe timestamp
if (string.Equals(address, _probeTag, StringComparison.OrdinalIgnoreCase))
_lastProbeValueTime = DateTime.UtcNow;
// Invoke stored subscription callback
if (_storedSubscriptions.TryGetValue(address, out var callback)) callback(address, vtq);
if (_pendingReadsByAddress.TryGetValue(address, out var pendingReads))
foreach (var pendingRead in pendingReads.Values)
pendingRead.TrySetResult(vtq);
// Global handler
OnTagValueChanged?.Invoke(address, vtq);
}
catch (Exception ex)
{
Log.Error(ex, "Error processing OnDataChange for handle {Handle}", phItemHandle);
}
}
/// <summary>
/// COM event handler for MxAccess OnWriteComplete events.
/// </summary>
private void HandleOnWriteComplete(
int hLMXServerHandle,
int phItemHandle,
ref MXSTATUS_PROXY[] ItemStatus)
{
try
{
if (_pendingWrites.TryRemove(phItemHandle, out var tcs))
{
var success = ItemStatus == null || ItemStatus.Length == 0 || ItemStatus[0].success != 0;
if (success)
{
tcs.TrySetResult(true);
}
else
{
var detail = ItemStatus![0].detail;
var message = MxErrorCodes.GetMessage(detail);
Log.Warning("Write failed for handle {Handle}: {Message}", phItemHandle, message);
tcs.TrySetResult(false);
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Error processing OnWriteComplete for handle {Handle}", phItemHandle);
}
}
private static DateTime ConvertTimestamp(object pftItemTimeStamp)
{
if (pftItemTimeStamp is DateTime dt)
return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime();
return DateTime.UtcNow;
}
}
}

View File

@@ -1,78 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
private Task? _monitorTask;
/// <summary>
/// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness.
/// </summary>
public void StartMonitor()
{
if (_monitorCts != null)
StopMonitor();
_monitorCts = new CancellationTokenSource();
_monitorTask = Task.Run(() => MonitorLoopAsync(_monitorCts.Token));
Log.Information("MxAccess monitor started (interval={Interval}s)", _config.MonitorIntervalSeconds);
}
/// <summary>
/// Stops the background monitor loop.
/// </summary>
public void StopMonitor()
{
_monitorCts?.Cancel();
try { _monitorTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
_monitorTask = null;
}
private async Task MonitorLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(_config.MonitorIntervalSeconds), ct);
}
catch (OperationCanceledException)
{
break;
}
try
{
if ((_state == ConnectionState.Disconnected || _state == ConnectionState.Error) &&
_config.AutoReconnect)
{
Log.Information("Monitor: connection lost (state={State}), attempting reconnect", _state);
await ReconnectAsync();
continue;
}
if (_state == ConnectionState.Connected && _probeTag != null)
{
var elapsed = DateTime.UtcNow - _lastProbeValueTime;
if (elapsed.TotalSeconds > _config.ProbeStaleThresholdSeconds)
{
Log.Warning("Monitor: probe stale ({Elapsed:F0}s > {Threshold}s), forcing reconnect",
elapsed.TotalSeconds, _config.ProbeStaleThresholdSeconds);
await ReconnectAsync();
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Monitor loop error");
}
}
Log.Information("MxAccess monitor stopped");
}
}
}

View File

@@ -1,166 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to read.</param>
/// <param name="ct">A token that cancels the read.</param>
/// <returns>The resulting VTQ value or a bad-quality fallback on timeout or failure.</returns>
public async Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
{
if (_state != ConnectionState.Connected)
return Vtq.Bad(Quality.BadNotConnected);
await _operationSemaphore.WaitAsync(ct);
try
{
using var scope = _metrics.BeginOperation("Read");
var tcs = new TaskCompletionSource<Vtq>();
var itemHandle = await _staThread.RunAsync(() =>
{
var h = _proxy.AddItem(_connectionHandle, fullTagReference);
_proxy.AdviseSupervisory(_connectionHandle, h);
return h;
});
var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference,
_ => new ConcurrentDictionary<int, TaskCompletionSource<Vtq>>());
pendingReads[itemHandle] = tcs;
_handleToAddress[itemHandle] = fullTagReference;
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(_config.ReadTimeoutSeconds));
cts.Token.Register(() => tcs.TrySetResult(Vtq.Bad(Quality.BadCommFailure)));
var result = await tcs.Task;
if (result.Quality != Quality.Good)
scope.SetSuccess(false);
return result;
}
catch
{
scope.SetSuccess(false);
return Vtq.Bad(Quality.BadCommFailure);
}
finally
{
if (_pendingReadsByAddress.TryGetValue(fullTagReference, out var reads))
{
reads.TryRemove(itemHandle, out _);
if (reads.IsEmpty)
_pendingReadsByAddress.TryRemove(fullTagReference, out _);
}
_handleToAddress.TryRemove(itemHandle, out _);
try
{
await _staThread.RunAsync(() =>
{
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
_proxy.RemoveItem(_connectionHandle, itemHandle);
});
}
catch (Exception ex)
{
Log.Warning(ex, "Error cleaning up read subscription for {Address}", fullTagReference);
}
}
}
finally
{
_operationSemaphore.Release();
}
}
/// <summary>
/// Writes a value to a Galaxy tag and waits for the runtime write-complete callback.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="ct">A token that cancels the write.</param>
/// <returns><see langword="true" /> when the runtime acknowledges success; otherwise, <see langword="false" />.</returns>
public async Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
{
if (_state != ConnectionState.Connected) return false;
await _operationSemaphore.WaitAsync(ct);
try
{
using var scope = _metrics.BeginOperation("Write");
var itemHandle = await _staThread.RunAsync(() =>
{
var h = _proxy.AddItem(_connectionHandle, fullTagReference);
_proxy.AdviseSupervisory(_connectionHandle, h);
return h;
});
_handleToAddress[itemHandle] = fullTagReference;
var tcs = new TaskCompletionSource<bool>();
_pendingWrites[itemHandle] = tcs;
try
{
await _staThread.RunAsync(() => _proxy.Write(_connectionHandle, itemHandle, value, -1));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds));
cts.Token.Register(() =>
{
Log.Warning("Write timed out for {Address} after {Timeout}s", fullTagReference,
_config.WriteTimeoutSeconds);
tcs.TrySetResult(false);
});
var success = await tcs.Task;
if (!success)
scope.SetSuccess(false);
return success;
}
catch (Exception ex)
{
scope.SetSuccess(false);
Log.Error(ex, "Write failed for {Address}", fullTagReference);
return false;
}
finally
{
_pendingWrites.TryRemove(itemHandle, out _);
_handleToAddress.TryRemove(itemHandle, out _);
try
{
await _staThread.RunAsync(() =>
{
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
_proxy.RemoveItem(_connectionHandle, itemHandle);
});
}
catch (Exception ex)
{
Log.Warning(ex, "Error cleaning up write subscription for {Address}", fullTagReference);
}
}
}
finally
{
_operationSemaphore.Release();
}
}
}
}

View File

@@ -1,107 +0,0 @@
using System;
using System.Threading.Tasks;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to monitor.</param>
/// <param name="callback">The callback that should receive runtime value changes.</param>
public async Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
{
_storedSubscriptions[fullTagReference] = callback;
if (_state != ConnectionState.Connected) return;
if (_addressToHandle.ContainsKey(fullTagReference)) return;
await SubscribeInternalAsync(fullTagReference);
}
/// <summary>
/// Removes a persistent subscription callback and tears down the runtime item when appropriate.
/// </summary>
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to stop monitoring.</param>
public async Task UnsubscribeAsync(string fullTagReference)
{
_storedSubscriptions.TryRemove(fullTagReference, out _);
// Don't unsubscribe the probe tag
if (string.Equals(fullTagReference, _probeTag, StringComparison.OrdinalIgnoreCase))
return;
if (_addressToHandle.TryRemove(fullTagReference, out var itemHandle))
{
_handleToAddress.TryRemove(itemHandle, out _);
if (_state == ConnectionState.Connected)
await _staThread.RunAsync(() =>
{
try
{
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
_proxy.RemoveItem(_connectionHandle, itemHandle);
}
catch (Exception ex)
{
Log.Warning(ex, "Error unsubscribing {Address}", fullTagReference);
}
});
}
}
private async Task SubscribeInternalAsync(string address)
{
if (_addressToHandle.ContainsKey(address))
return;
using var scope = _metrics.BeginOperation("Subscribe");
try
{
var itemHandle = await _staThread.RunAsync(() =>
{
var h = _proxy.AddItem(_connectionHandle, address);
_proxy.AdviseSupervisory(_connectionHandle, h);
return h;
});
var registeredHandle = _addressToHandle.GetOrAdd(address, itemHandle);
if (registeredHandle != itemHandle)
{
await _staThread.RunAsync(() =>
{
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
_proxy.RemoveItem(_connectionHandle, itemHandle);
});
return;
}
_handleToAddress[itemHandle] = address;
Log.Debug("Subscribed to {Address} (handle={Handle})", address, itemHandle);
}
catch (Exception ex)
{
scope.SetSuccess(false);
Log.Error(ex, "Failed to subscribe to {Address}", address);
throw;
}
}
private async Task ReplayStoredSubscriptionsAsync()
{
foreach (var kvp in _storedSubscriptions)
try
{
await SubscribeInternalAsync(kvp.Key);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to replay subscription for {Address}", kvp.Key);
}
Log.Information("Replayed {Count} stored subscriptions", _storedSubscriptions.Count);
}
}
}

View File

@@ -1,125 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
{
/// <summary>
/// Core MXAccess client implementing IMxAccessClient via IMxProxy abstraction.
/// Split across partial classes: Connection, Subscription, ReadWrite, EventHandlers, Monitor.
/// (MXA-001 through MXA-009)
/// </summary>
public sealed partial class MxAccessClient : IMxAccessClient
{
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
private readonly ConcurrentDictionary<string, int> _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
private readonly MxAccessConfiguration _config;
// Handle mappings
private readonly ConcurrentDictionary<int, string> _handleToAddress = new();
private readonly PerformanceMetrics _metrics;
private readonly SemaphoreSlim _operationSemaphore;
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>>
_pendingReadsByAddress
= new(StringComparer.OrdinalIgnoreCase);
// Pending writes
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites = new();
private readonly IMxProxy _proxy;
private readonly StaComThread _staThread;
// Subscription storage
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _storedSubscriptions
= new(StringComparer.OrdinalIgnoreCase);
private int _connectionHandle;
private DateTime _lastProbeValueTime = DateTime.UtcNow;
private CancellationTokenSource? _monitorCts;
// Probe
private string? _probeTag;
private bool _proxyEventsAttached;
private int _reconnectCount;
private volatile ConnectionState _state = ConnectionState.Disconnected;
/// <summary>
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
/// </summary>
/// <param name="staThread">The STA thread used to marshal COM interactions.</param>
/// <param name="proxy">The COM proxy abstraction used to talk to the runtime.</param>
/// <param name="config">The runtime timeout, throttling, and reconnect settings.</param>
/// <param name="metrics">The metrics collector used to time MXAccess operations.</param>
public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config,
PerformanceMetrics metrics)
{
_staThread = staThread;
_proxy = proxy;
_config = config;
_metrics = metrics;
_operationSemaphore = new SemaphoreSlim(config.MaxConcurrentOperations, config.MaxConcurrentOperations);
}
/// <summary>
/// Gets the current runtime connection state for the MXAccess client.
/// </summary>
public ConnectionState State => _state;
/// <summary>
/// Gets the number of active tag subscriptions currently maintained against the runtime.
/// </summary>
public int ActiveSubscriptionCount => _storedSubscriptions.Count;
/// <summary>
/// Gets the number of reconnect attempts performed since the client was created.
/// </summary>
public int ReconnectCount => _reconnectCount;
/// <summary>
/// Occurs when the MXAccess connection state changes.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <summary>
/// Occurs when a subscribed runtime tag publishes a new value.
/// </summary>
public event Action<string, Vtq>? OnTagValueChanged;
/// <summary>
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
/// </summary>
public void Dispose()
{
try
{
_monitorCts?.Cancel();
DisconnectAsync().GetAwaiter().GetResult();
}
catch (Exception ex)
{
Log.Warning(ex, "Error during MxAccessClient dispose");
}
finally
{
_operationSemaphore.Dispose();
_monitorCts?.Dispose();
}
}
private void SetState(ConnectionState newState, string message = "")
{
var previous = _state;
if (previous == newState) return;
_state = newState;
Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message);
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message));
}
}
}

View File

@@ -1,130 +0,0 @@
using System;
using System.Runtime.InteropServices;
using ArchestrA.MxAccess;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
{
/// <summary>
/// Wraps the real ArchestrA.MxAccess.LMXProxyServer COM object, forwarding calls to IMxProxy.
/// Uses strongly-typed interop — same pattern as the reference LmxProxy implementation. (MXA-001)
/// </summary>
public sealed class MxProxyAdapter : IMxProxy
{
private LMXProxyServer? _lmxProxy;
/// <summary>
/// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute.
/// </summary>
public event MxDataChangeHandler? OnDataChange;
/// <summary>
/// Occurs when the COM proxy confirms completion of a write request.
/// </summary>
public event MxWriteCompleteHandler? OnWriteComplete;
/// <summary>
/// Creates and registers the COM proxy session that backs live MXAccess operations.
/// </summary>
/// <param name="clientName">The client name reported to the Wonderware runtime.</param>
/// <returns>The runtime connection handle assigned by the COM server.</returns>
public int Register(string clientName)
{
_lmxProxy = new LMXProxyServer();
_lmxProxy.OnDataChange += ProxyOnDataChange;
_lmxProxy.OnWriteComplete += ProxyOnWriteComplete;
var handle = _lmxProxy.Register(clientName);
if (handle <= 0)
throw new InvalidOperationException($"LMXProxyServer.Register returned invalid handle: {handle}");
return handle;
}
/// <summary>
/// Unregisters the COM proxy session and releases the underlying COM object.
/// </summary>
/// <param name="handle">The runtime connection handle returned by <see cref="Register(string)" />.</param>
public void Unregister(int handle)
{
if (_lmxProxy != null)
try
{
_lmxProxy.OnDataChange -= ProxyOnDataChange;
_lmxProxy.OnWriteComplete -= ProxyOnWriteComplete;
_lmxProxy.Unregister(handle);
}
finally
{
Marshal.ReleaseComObject(_lmxProxy);
_lmxProxy = null;
}
}
/// <summary>
/// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="address">The fully qualified Galaxy attribute reference.</param>
/// <returns>The item handle assigned by the COM proxy.</returns>
public int AddItem(int handle, string address)
{
return _lmxProxy!.AddItem(handle, address);
}
/// <summary>
/// Removes an item handle from the active COM proxy session.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to remove.</param>
public void RemoveItem(int handle, int itemHandle)
{
_lmxProxy!.RemoveItem(handle, itemHandle);
}
/// <summary>
/// Enables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to monitor.</param>
public void AdviseSupervisory(int handle, int itemHandle)
{
_lmxProxy!.AdviseSupervisory(handle, itemHandle);
}
/// <summary>
/// Disables supervisory callbacks for the specified runtime item.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to stop monitoring.</param>
public void UnAdviseSupervisory(int handle, int itemHandle)
{
_lmxProxy!.UnAdvise(handle, itemHandle);
}
/// <summary>
/// Writes a value to the specified runtime item through the COM proxy.
/// </summary>
/// <param name="handle">The runtime connection handle.</param>
/// <param name="itemHandle">The item handle to write.</param>
/// <param name="value">The value to send to the runtime.</param>
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
public void Write(int handle, int itemHandle, object value, int securityClassification)
{
_lmxProxy!.Write(handle, itemHandle, value, securityClassification);
}
private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
{
OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp,
ref ItemStatus);
}
private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
{
OnWriteComplete?.Invoke(hLMXServerHandle, phItemHandle, ref ItemStatus);
}
}
}

View File

@@ -1,309 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
{
/// <summary>
/// Dedicated STA thread with a raw Win32 message pump for COM interop.
/// All MxAccess COM objects must be created and called on this thread. (MXA-001)
/// </summary>
public sealed class StaComThread : IDisposable
{
private const uint WM_APP = 0x8000;
private const uint PM_NOREMOVE = 0x0000;
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
private readonly TaskCompletionSource<bool> _ready = new();
private readonly Thread _thread;
private readonly ConcurrentQueue<WorkItem> _workItems = new();
private long _appMessages;
private long _dispatchedMessages;
private bool _disposed;
private DateTime _lastLogTime;
private volatile uint _nativeThreadId;
private volatile bool _pumpExited;
private long _totalMessages;
private long _workItemsExecuted;
/// <summary>
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
/// </summary>
public StaComThread()
{
_thread = new Thread(ThreadEntry)
{
Name = "MxAccess-STA",
IsBackground = true
};
_thread.SetApartmentState(ApartmentState.STA);
}
/// <summary>
/// Gets a value indicating whether the STA thread is running and able to accept work.
/// </summary>
public bool IsRunning => _nativeThreadId != 0 && !_disposed && !_pumpExited;
/// <summary>
/// Stops the STA thread and releases the message-pump resources used for COM interop.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
if (_nativeThreadId != 0 && !_pumpExited)
PostThreadMessage(_nativeThreadId, WM_APP + 1, IntPtr.Zero, IntPtr.Zero);
_thread.Join(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
Log.Warning(ex, "Error shutting down STA COM thread");
}
DrainAndFaultQueue();
Log.Information("STA COM thread stopped");
}
/// <summary>
/// Starts the STA thread and waits until its message pump is ready for COM work.
/// </summary>
public void Start()
{
_thread.Start();
_ready.Task.GetAwaiter().GetResult();
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
}
/// <summary>
/// Queues an action to execute on the STA thread.
/// </summary>
/// <param name="action">The work item to execute on the STA thread.</param>
/// <returns>A task that completes when the action has finished executing.</returns>
public Task RunAsync(Action action)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited");
var tcs = new TaskCompletionSource<bool>();
_workItems.Enqueue(new WorkItem
{
Execute = () =>
{
try
{
action();
tcs.TrySetResult(true);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
},
Fault = ex => tcs.TrySetException(ex)
});
if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero))
{
_pumpExited = true;
DrainAndFaultQueue();
}
return tcs.Task;
}
/// <summary>
/// Queues a function to execute on the STA thread and returns its result.
/// </summary>
/// <typeparam name="T">The result type produced by the function.</typeparam>
/// <param name="func">The work item to execute on the STA thread.</param>
/// <returns>A task that completes with the function result.</returns>
public Task<T> RunAsync<T>(Func<T> func)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited");
var tcs = new TaskCompletionSource<T>();
_workItems.Enqueue(new WorkItem
{
Execute = () =>
{
try
{
tcs.TrySetResult(func());
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
},
Fault = ex => tcs.TrySetException(ex)
});
if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero))
{
_pumpExited = true;
DrainAndFaultQueue();
}
return tcs.Task;
}
private void ThreadEntry()
{
try
{
_nativeThreadId = GetCurrentThreadId();
MSG msg;
PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_NOREMOVE);
_ready.TrySetResult(true);
_lastLogTime = DateTime.UtcNow;
Log.Debug("STA message pump entering loop");
while (GetMessage(out msg, IntPtr.Zero, 0, 0) > 0)
{
_totalMessages++;
if (msg.message == WM_APP)
{
_appMessages++;
DrainQueue();
}
else if (msg.message == WM_APP + 1)
{
DrainQueue();
PostQuitMessage(0);
}
else
{
_dispatchedMessages++;
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
LogPumpStatsIfDue();
}
Log.Information(
"STA message pump exited (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})",
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted);
}
catch (Exception ex)
{
Log.Error(ex, "STA COM thread crashed");
_ready.TrySetException(ex);
}
finally
{
_pumpExited = true;
DrainAndFaultQueue();
}
}
private void DrainQueue()
{
while (_workItems.TryDequeue(out var workItem))
{
_workItemsExecuted++;
try
{
workItem.Execute();
}
catch (Exception ex)
{
Log.Error(ex, "Unhandled exception in STA work item");
}
}
}
private void DrainAndFaultQueue()
{
var faultException = new InvalidOperationException("STA COM thread pump has exited");
while (_workItems.TryDequeue(out var workItem))
{
try
{
workItem.Fault(faultException);
}
catch
{
// Faulting a TCS should not throw, but guard against it
}
}
}
private void LogPumpStatsIfDue()
{
var now = DateTime.UtcNow;
if (now - _lastLogTime < PumpLogInterval) return;
Log.Debug(
"STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}",
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count);
_lastLogTime = now;
}
private sealed class WorkItem
{
public Action Execute { get; set; }
public Action<Exception> Fault { get; set; }
}
#region Win32 PInvoke
[StructLayout(LayoutKind.Sequential)]
private struct MSG
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int x;
public int y;
}
[DllImport("user32.dll")]
private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern void PostQuitMessage(int nExitCode);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax,
uint wRemoveMsg);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
#endregion
}
}

View File

@@ -1,224 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Builds the tag reference mappings from Galaxy hierarchy and attributes.
/// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004)
/// </summary>
public class AddressSpaceBuilder
{
private static readonly ILogger Log = Serilog.Log.ForContext<AddressSpaceBuilder>();
/// <summary>
/// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes
/// nodes.
/// </summary>
/// <param name="hierarchy">The Galaxy object hierarchy returned by the repository.</param>
/// <param name="attributes">The Galaxy attribute rows associated with the hierarchy.</param>
/// <returns>An address-space model containing roots, variables, and tag-reference mappings.</returns>
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
var model = new AddressSpaceModel();
var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
var attrsByObject = attributes
.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
// Build parent→children map
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
// Find root objects (parent not in hierarchy)
var knownIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
foreach (var obj in hierarchy)
{
var nodeInfo = BuildNodeInfo(obj, attrsByObject, childrenByParent, model);
if (!knownIds.Contains(obj.ParentGobjectId))
model.RootNodes.Add(nodeInfo);
}
Log.Information("Address space model: {Objects} objects, {Variables} variables, {Mappings} tag refs",
model.ObjectCount, model.VariableCount, model.NodeIdToTagReference.Count);
return model;
}
private static NodeInfo BuildNodeInfo(GalaxyObjectInfo obj,
Dictionary<int, List<GalaxyAttributeInfo>> attrsByObject,
Dictionary<int, List<GalaxyObjectInfo>> childrenByParent,
AddressSpaceModel model)
{
var node = new NodeInfo
{
GobjectId = obj.GobjectId,
TagName = obj.TagName,
BrowseName = obj.BrowseName,
ParentGobjectId = obj.ParentGobjectId,
IsArea = obj.IsArea
};
if (!obj.IsArea)
model.ObjectCount++;
if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs))
foreach (var attr in attrs)
{
node.Attributes.Add(new AttributeNodeInfo
{
AttributeName = attr.AttributeName,
FullTagReference = attr.FullTagReference,
MxDataType = attr.MxDataType,
IsArray = attr.IsArray,
ArrayDimension = attr.ArrayDimension,
PrimitiveName = attr.PrimitiveName ?? "",
SecurityClassification = attr.SecurityClassification,
IsHistorized = attr.IsHistorized,
IsAlarm = attr.IsAlarm
});
model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
model.VariableCount++;
}
return node;
}
private static string GetNodeIdentifier(GalaxyAttributeInfo attr)
{
if (!attr.IsArray)
return attr.FullTagReference;
return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal)
? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
: attr.FullTagReference;
}
/// <summary>
/// Node info for the address space tree.
/// </summary>
public class NodeInfo
{
/// <summary>
/// Gets or sets the Galaxy object identifier represented by this address-space node.
/// </summary>
public int GobjectId { get; set; }
/// <summary>
/// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
/// </summary>
public string TagName { get; set; } = "";
/// <summary>
/// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
/// </summary>
public string BrowseName { get; set; } = "";
/// <summary>
/// Gets or sets the parent Galaxy object identifier used to assemble the tree.
/// </summary>
public int ParentGobjectId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the node represents a Galaxy area folder.
/// </summary>
public bool IsArea { get; set; }
/// <summary>
/// Gets or sets the attribute nodes published beneath this object.
/// </summary>
public List<AttributeNodeInfo> Attributes { get; set; } = new();
/// <summary>
/// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
/// </summary>
public List<NodeInfo> Children { get; set; } = new();
}
/// <summary>
/// Lightweight description of an attribute node that will become an OPC UA variable.
/// </summary>
public class AttributeNodeInfo
{
/// <summary>
/// Gets or sets the Galaxy attribute name published under the object.
/// </summary>
public string AttributeName { get; set; } = "";
/// <summary>
/// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
/// </summary>
public string FullTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is modeled as an array.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the declared array length when the attribute is a fixed-size array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the primitive name that groups the attribute under a sub-object node.
/// Empty for root-level attributes.
/// </summary>
public string PrimitiveName { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
/// </summary>
public int SecurityClassification { get; set; } = 1;
/// <summary>
/// Gets or sets a value indicating whether the attribute is historized.
/// </summary>
public bool IsHistorized { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the attribute is an alarm.
/// </summary>
public bool IsAlarm { get; set; }
}
/// <summary>
/// Result of building the address space model.
/// </summary>
public class AddressSpaceModel
{
/// <summary>
/// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
/// </summary>
public List<NodeInfo> RootNodes { get; set; } = new();
/// <summary>
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
/// </summary>
public Dictionary<string, string> NodeIdToTagReference { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets the number of non-area Galaxy objects included in the model.
/// </summary>
public int ObjectCount { get; set; }
/// <summary>
/// Gets or sets the number of variable nodes created from Galaxy attributes.
/// </summary>
public int VariableCount { get; set; }
}
}
}

View File

@@ -1,132 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes.
/// </summary>
public static class AddressSpaceDiff
{
/// <summary>
/// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference.
/// </summary>
/// <param name="oldHierarchy">The previously published Galaxy object hierarchy snapshot.</param>
/// <param name="oldAttributes">The previously published Galaxy attribute snapshot keyed to the old hierarchy.</param>
/// <param name="newHierarchy">The latest Galaxy object hierarchy snapshot pulled from the repository.</param>
/// <param name="newAttributes">The latest Galaxy attribute snapshot that should be reflected in the OPC UA namespace.</param>
public static HashSet<int> FindChangedGobjectIds(
List<GalaxyObjectInfo> oldHierarchy, List<GalaxyAttributeInfo> oldAttributes,
List<GalaxyObjectInfo> newHierarchy, List<GalaxyAttributeInfo> newAttributes)
{
var changed = new HashSet<int>();
var oldObjects = oldHierarchy.ToDictionary(h => h.GobjectId);
var newObjects = newHierarchy.ToDictionary(h => h.GobjectId);
// Added objects
foreach (var id in newObjects.Keys)
if (!oldObjects.ContainsKey(id))
changed.Add(id);
// Removed objects
foreach (var id in oldObjects.Keys)
if (!newObjects.ContainsKey(id))
changed.Add(id);
// Modified objects
foreach (var kvp in newObjects)
if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value))
changed.Add(kvp.Key);
// Attribute changes — group by gobject_id and compare
var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
var newAttrsByObj = newAttributes.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
// All gobject_ids that have attributes in either old or new
var allAttrGobjectIds = new HashSet<int>(oldAttrsByObj.Keys);
allAttrGobjectIds.UnionWith(newAttrsByObj.Keys);
foreach (var id in allAttrGobjectIds)
{
if (changed.Contains(id))
continue;
oldAttrsByObj.TryGetValue(id, out var oldAttrs);
newAttrsByObj.TryGetValue(id, out var newAttrs);
if (!AttributeSetsEqual(oldAttrs, newAttrs))
changed.Add(id);
}
return changed;
}
/// <summary>
/// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy.
/// </summary>
/// <param name="changed">The root Galaxy objects that were detected as changed between snapshots.</param>
/// <param name="hierarchy">The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt.</param>
public static HashSet<int> ExpandToSubtrees(HashSet<int> changed, List<GalaxyObjectInfo> hierarchy)
{
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
.ToDictionary(g => g.Key, g => g.Select(h => h.GobjectId).ToList());
var expanded = new HashSet<int>(changed);
var queue = new Queue<int>(changed);
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (childrenByParent.TryGetValue(id, out var children))
foreach (var childId in children)
if (expanded.Add(childId))
queue.Enqueue(childId);
}
return expanded;
}
private static bool ObjectsEqual(GalaxyObjectInfo a, GalaxyObjectInfo b)
{
return a.TagName == b.TagName
&& a.BrowseName == b.BrowseName
&& a.ContainedName == b.ContainedName
&& a.ParentGobjectId == b.ParentGobjectId
&& a.IsArea == b.IsArea;
}
private static bool AttributeSetsEqual(List<GalaxyAttributeInfo>? a, List<GalaxyAttributeInfo>? b)
{
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (a.Count != b.Count) return false;
// Sort by a stable key and compare pairwise
var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
var sortedB = b.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
for (var i = 0; i < sortedA.Count; i++)
if (!AttributesEqual(sortedA[i], sortedB[i]))
return false;
return true;
}
private static bool AttributesEqual(GalaxyAttributeInfo a, GalaxyAttributeInfo b)
{
return a.AttributeName == b.AttributeName
&& a.FullTagReference == b.FullTagReference
&& a.MxDataType == b.MxDataType
&& a.IsArray == b.IsArray
&& a.ArrayDimension == b.ArrayDimension
&& a.PrimitiveName == b.PrimitiveName
&& a.SecurityClassification == b.SecurityClassification
&& a.IsHistorized == b.IsHistorized
&& a.IsAlarm == b.IsAlarm;
}
}
}

View File

@@ -1,92 +0,0 @@
using System;
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Converts between domain Vtq and OPC UA DataValue. Handles all data_type_mapping.md types. (OPC-005, OPC-007)
/// </summary>
public static class DataValueConverter
{
/// <summary>
/// Converts a bridge VTQ snapshot into an OPC UA data value.
/// </summary>
/// <param name="vtq">The VTQ snapshot to convert.</param>
/// <returns>An OPC UA data value suitable for reads and subscriptions.</returns>
public static DataValue FromVtq(Vtq vtq)
{
var statusCode = new StatusCode(QualityMapper.MapToOpcUaStatusCode(vtq.Quality));
var dataValue = new DataValue
{
Value = ConvertToOpcUaValue(vtq.Value),
StatusCode = statusCode,
SourceTimestamp = vtq.Timestamp.Kind == DateTimeKind.Utc
? vtq.Timestamp
: vtq.Timestamp.ToUniversalTime(),
ServerTimestamp = DateTime.UtcNow
};
return dataValue;
}
/// <summary>
/// Converts an OPC UA data value back into a bridge VTQ snapshot.
/// </summary>
/// <param name="dataValue">The OPC UA data value to convert.</param>
/// <returns>A VTQ snapshot containing the converted value, timestamp, and derived quality.</returns>
public static Vtq ToVtq(DataValue dataValue)
{
var quality = MapStatusCodeToQuality(dataValue.StatusCode);
var timestamp = dataValue.SourceTimestamp != DateTime.MinValue
? dataValue.SourceTimestamp
: DateTime.UtcNow;
return new Vtq(dataValue.Value, timestamp, quality);
}
private static object? ConvertToOpcUaValue(object? value)
{
if (value == null) return null;
return value switch
{
bool _ => value,
int _ => value,
float _ => value,
double _ => value,
string _ => value,
DateTime dt => dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(),
TimeSpan ts => ts.TotalSeconds, // ElapsedTime → Double seconds
short s => (int)s,
long l => l,
byte b => (int)b,
bool[] _ => value,
int[] _ => value,
float[] _ => value,
double[] _ => value,
string[] _ => value,
DateTime[] _ => value,
_ => value.ToString()
};
}
private static Quality MapStatusCodeToQuality(StatusCode statusCode)
{
var code = statusCode.Code;
if (StatusCode.IsGood(statusCode)) return Quality.Good;
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
return code switch
{
StatusCodes.BadNotConnected => Quality.BadNotConnected,
StatusCodes.BadCommunicationError => Quality.BadCommFailure,
StatusCodes.BadConfigurationError => Quality.BadConfigError,
StatusCodes.BadOutOfService => Quality.BadOutOfService,
StatusCodes.BadWaitingForInitialData => Quality.BadWaitingForInitialData,
_ => Quality.Bad
};
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,528 +0,0 @@
using System;
using System.Collections.Generic;
using Opc.Ua;
using Opc.Ua.Server;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.Historian;
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Custom OPC UA server that creates the LmxNodeManager, handles user authentication,
/// and exposes redundancy state through the standard server object. (OPC-001, OPC-012)
/// </summary>
public class LmxOpcUaServer : StandardServer
{
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
private readonly bool _alarmTrackingEnabled;
private readonly AlarmObjectFilter? _alarmObjectFilter;
private readonly string? _applicationUri;
private readonly AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider;
private readonly string _galaxyName;
private readonly IHistorianDataSource? _historianDataSource;
private readonly PerformanceMetrics _metrics;
private readonly IMxAccessClient _mxAccessClient;
private readonly RedundancyConfiguration _redundancyConfig;
private readonly ServiceLevelCalculator _serviceLevelCalculator = new();
private NodeId? _alarmAckRoleId;
// Resolved custom role NodeIds (populated in CreateMasterNodeManager)
private NodeId? _readOnlyRoleId;
private NodeId? _writeConfigureRoleId;
private NodeId? _writeOperateRoleId;
private NodeId? _writeTuneRoleId;
private readonly bool _runtimeStatusProbesEnabled;
private readonly int _runtimeStatusUnknownTimeoutSeconds;
private readonly int _mxAccessRequestTimeoutSeconds;
private readonly int _historianRequestTimeoutSeconds;
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
IHistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null,
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null,
AlarmObjectFilter? alarmObjectFilter = null,
bool runtimeStatusProbesEnabled = false,
int runtimeStatusUnknownTimeoutSeconds = 15,
int mxAccessRequestTimeoutSeconds = 30,
int historianRequestTimeoutSeconds = 60)
{
_galaxyName = galaxyName;
_mxAccessClient = mxAccessClient;
_metrics = metrics;
_historianDataSource = historianDataSource;
_alarmTrackingEnabled = alarmTrackingEnabled;
_alarmObjectFilter = alarmObjectFilter;
_authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider;
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
_applicationUri = applicationUri;
_runtimeStatusProbesEnabled = runtimeStatusProbesEnabled;
_runtimeStatusUnknownTimeoutSeconds = runtimeStatusUnknownTimeoutSeconds;
_mxAccessRequestTimeoutSeconds = mxAccessRequestTimeoutSeconds;
_historianRequestTimeoutSeconds = historianRequestTimeoutSeconds;
}
/// <summary>
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
/// </summary>
public LmxNodeManager? NodeManager { get; private set; }
/// <summary>
/// Gets the number of active OPC UA sessions currently connected to the server.
/// </summary>
public int ActiveSessionCount
{
get
{
try
{
return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0;
}
catch
{
return 0;
}
}
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server,
ApplicationConfiguration configuration)
{
// Resolve custom role NodeIds from the roles namespace
ResolveRoleNodeIds(server);
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
NodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
_writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId,
_alarmObjectFilter,
_runtimeStatusProbesEnabled, _runtimeStatusUnknownTimeoutSeconds,
_mxAccessRequestTimeoutSeconds, _historianRequestTimeoutSeconds);
var nodeManagers = new List<INodeManager> { NodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
}
private void ResolveRoleNodeIds(IServerInternal server)
{
var nsIndex = server.NamespaceUris.GetIndexOrAppend(LmxRoleIds.NamespaceUri);
_readOnlyRoleId = new NodeId(LmxRoleIds.ReadOnly, nsIndex);
_writeOperateRoleId = new NodeId(LmxRoleIds.WriteOperate, nsIndex);
_writeTuneRoleId = new NodeId(LmxRoleIds.WriteTune, nsIndex);
_writeConfigureRoleId = new NodeId(LmxRoleIds.WriteConfigure, nsIndex);
_alarmAckRoleId = new NodeId(LmxRoleIds.AlarmAck, nsIndex);
Log.Debug("Resolved custom role NodeIds in namespace index {NsIndex}", nsIndex);
}
/// <inheritdoc />
protected override void OnServerStarted(IServerInternal server)
{
base.OnServerStarted(server);
server.SessionManager.ImpersonateUser += OnImpersonateUser;
ConfigureRedundancy(server);
ConfigureHistoryCapabilities(server);
ConfigureServerCapabilities(server);
}
private void ConfigureRedundancy(IServerInternal server)
{
var mode = RedundancyModeResolver.Resolve(_redundancyConfig.Mode, _redundancyConfig.Enabled);
try
{
// Set RedundancySupport via the diagnostics node manager
var redundancySupportNodeId = VariableIds.Server_ServerRedundancy_RedundancySupport;
var redundancySupportNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
redundancySupportNodeId, typeof(BaseVariableState)) as BaseVariableState;
if (redundancySupportNode != null)
{
redundancySupportNode.Value = (int)mode;
redundancySupportNode.ClearChangeMasks(server.DefaultSystemContext, false);
Log.Information("Set RedundancySupport to {Mode}", mode);
}
// Set ServerUriArray for non-transparent redundancy
if (_redundancyConfig.Enabled && _redundancyConfig.ServerUris.Count > 0)
{
var serverUriArrayNodeId = VariableIds.Server_ServerRedundancy_ServerUriArray;
var serverUriArrayNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
serverUriArrayNodeId, typeof(BaseVariableState)) as BaseVariableState;
if (serverUriArrayNode != null)
{
serverUriArrayNode.Value = _redundancyConfig.ServerUris.ToArray();
serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false);
Log.Information("Set ServerUriArray to [{Uris}]",
string.Join(", ", _redundancyConfig.ServerUris));
}
else
{
Log.Warning(
"ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type");
}
}
// Set initial ServiceLevel
var initialLevel = CalculateCurrentServiceLevel(true, true);
SetServiceLevelValue(server, initialLevel);
Log.Information("Initial ServiceLevel set to {ServiceLevel}", initialLevel);
}
catch (Exception ex)
{
Log.Warning(ex,
"Failed to configure redundancy nodes — redundancy state may not be visible to clients");
}
}
private void ConfigureHistoryCapabilities(IServerInternal server)
{
if (_historianDataSource == null)
return;
try
{
var dnm = server.DiagnosticsNodeManager;
var ctx = server.DefaultSystemContext;
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability, true);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability,
_alarmTrackingEnabled);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_MaxReturnDataValues,
(uint)(_historianDataSource != null ? 10000 : 0));
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_MaxReturnEventValues, (uint)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_InsertDataCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_ReplaceDataCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_UpdateDataCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_DeleteRawCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_InsertEventCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_ReplaceEventCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_UpdateEventCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_DeleteEventCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_InsertAnnotationCapability, false);
SetPredefinedVariable(dnm, ctx,
VariableIds.HistoryServerCapabilities_ServerTimestampSupported, true);
// Add aggregate function references under the AggregateFunctions folder
var aggFolderNode = dnm?.FindPredefinedNode(
ObjectIds.HistoryServerCapabilities_AggregateFunctions,
typeof(FolderState)) as FolderState;
if (aggFolderNode != null)
{
var aggregateIds = new[]
{
ObjectIds.AggregateFunction_Average,
ObjectIds.AggregateFunction_Minimum,
ObjectIds.AggregateFunction_Maximum,
ObjectIds.AggregateFunction_Count,
ObjectIds.AggregateFunction_Start,
ObjectIds.AggregateFunction_End,
ObjectIds.AggregateFunction_StandardDeviationPopulation
};
foreach (var aggId in aggregateIds)
{
var aggNode = dnm?.FindPredefinedNode(aggId, typeof(BaseObjectState)) as BaseObjectState;
if (aggNode != null)
{
try
{
aggFolderNode.AddReference(ReferenceTypeIds.Organizes, false, aggNode.NodeId);
}
catch (ArgumentException)
{
// Reference already exists — skip
}
try
{
aggNode.AddReference(ReferenceTypeIds.Organizes, true, aggFolderNode.NodeId);
}
catch (ArgumentException)
{
// Reference already exists — skip
}
}
}
Log.Information("HistoryServerCapabilities configured with {Count} aggregate functions",
aggregateIds.Length);
}
else
{
Log.Warning("AggregateFunctions folder not found in predefined nodes");
}
}
catch (Exception ex)
{
Log.Warning(ex,
"Failed to configure HistoryServerCapabilities — history discovery may not work for clients");
}
}
private void ConfigureServerCapabilities(IServerInternal server)
{
try
{
var dnm = server.DiagnosticsNodeManager;
var ctx = server.DefaultSystemContext;
// Server profiles
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_ServerProfileArray,
new[] { "http://opcfoundation.org/UA-Profile/Server/StandardUA2017" });
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_LocaleIdArray,
new[] { "en" });
// Limits
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MinSupportedSampleRate, 100.0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxBrowseContinuationPoints, (ushort)100);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxQueryContinuationPoints, (ushort)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxHistoryContinuationPoints, (ushort)100);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxArrayLength, (uint)65535);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxStringLength, (uint)(4 * 1024 * 1024));
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_MaxByteStringLength, (uint)(4 * 1024 * 1024));
// OperationLimits
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRegisterNodes, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerTranslateBrowsePathsToNodeIds,
(uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerMethodCall, (uint)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerNodeManagement, (uint)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents, (uint)1000);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateData, (uint)0);
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateEvents, (uint)0);
// Diagnostics
SetPredefinedVariable(dnm, ctx,
VariableIds.Server_ServerDiagnostics_EnabledFlag, true);
Log.Information(
"ServerCapabilities configured (OperationLimits, diagnostics enabled)");
}
catch (Exception ex)
{
Log.Warning(ex,
"Failed to configure ServerCapabilities — capability discovery may not work for clients");
}
}
private static void SetPredefinedVariable(DiagnosticsNodeManager? dnm, ServerSystemContext ctx,
NodeId variableId, object value)
{
var node = dnm?.FindPredefinedNode(variableId, typeof(BaseVariableState)) as BaseVariableState;
if (node != null)
{
node.Value = value;
node.ClearChangeMasks(ctx, false);
}
}
/// <summary>
/// Updates the server's ServiceLevel based on current runtime health.
/// Called by the service layer when MXAccess or DB health changes.
/// </summary>
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
{
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
try
{
if (ServerInternal != null) SetServiceLevelValue(ServerInternal, level);
}
catch (Exception ex)
{
Log.Debug(ex, "Failed to update ServiceLevel node");
}
}
private byte CalculateCurrentServiceLevel(bool mxAccessConnected, bool dbConnected)
{
if (!_redundancyConfig.Enabled)
return 255; // SDK default when redundancy is not configured
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
var baseLevel = isPrimary
? _redundancyConfig.ServiceLevelBase
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
return _serviceLevelCalculator.Calculate(baseLevel, mxAccessConnected, dbConnected);
}
private static void SetServiceLevelValue(IServerInternal server, byte level)
{
var serviceLevelNodeId = VariableIds.Server_ServiceLevel;
var serviceLevelNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
serviceLevelNodeId, typeof(BaseVariableState)) as BaseVariableState;
if (serviceLevelNode != null)
{
serviceLevelNode.Value = level;
serviceLevelNode.ClearChangeMasks(server.DefaultSystemContext, false);
}
}
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
{
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
{
if (!_authConfig.AllowAnonymous)
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is disabled");
args.Identity = new RoleBasedIdentity(
new UserIdentity(anonymousToken),
new List<Role> { Role.Anonymous });
Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite);
return;
}
if (args.NewIdentity is UserNameIdentityToken userNameToken)
{
var password = userNameToken.DecryptedPassword ?? "";
if (_authProvider == null || !_authProvider.ValidateCredentials(userNameToken.UserName, password))
{
Log.Warning("AUDIT: Authentication FAILED for user {Username} from session {SessionId}",
userNameToken.UserName, session?.Id);
throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password");
}
var roles = new List<Role> { Role.AuthenticatedUser };
if (_authProvider is IRoleProvider roleProvider)
{
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
foreach (var appRole in appRoles)
switch (appRole)
{
case AppRoles.ReadOnly:
if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
break;
case AppRoles.WriteOperate:
if (_writeOperateRoleId != null)
roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate));
break;
case AppRoles.WriteTune:
if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune));
break;
case AppRoles.WriteConfigure:
if (_writeConfigureRoleId != null)
roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure));
break;
case AppRoles.AlarmAck:
if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck));
break;
}
Log.Information("AUDIT: Authentication SUCCESS for user {Username} with roles [{Roles}] session {SessionId}",
userNameToken.UserName, string.Join(", ", appRoles), session?.Id);
}
else
{
Log.Information("AUDIT: Authentication SUCCESS for user {Username} session {SessionId}",
userNameToken.UserName, session?.Id);
}
args.Identity = new RoleBasedIdentity(
new UserIdentity(userNameToken), roles);
return;
}
if (args.NewIdentity is X509IdentityToken x509Token)
{
var cert = x509Token.Certificate;
var subject = cert?.Subject ?? "Unknown";
// Extract CN from certificate subject for display
var cn = subject;
var cnStart = subject.IndexOf("CN=", StringComparison.OrdinalIgnoreCase);
if (cnStart >= 0)
{
cn = subject.Substring(cnStart + 3);
var commaIdx = cn.IndexOf(',');
if (commaIdx >= 0)
cn = cn.Substring(0, commaIdx);
}
var roles = new List<Role> { Role.AuthenticatedUser };
// X.509 authenticated users get ReadOnly role by default
if (_readOnlyRoleId != null)
roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
args.Identity = new RoleBasedIdentity(
new UserIdentity(x509Token), roles);
Log.Information("X509 certificate authenticated: CN={CN}, Subject={Subject}, Thumbprint={Thumbprint}",
cn, subject, cert?.Thumbprint);
return;
}
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type");
}
/// <inheritdoc />
protected override ServerProperties LoadServerProperties()
{
var properties = new ServerProperties
{
ManufacturerName = "ZB MOM",
ProductName = "LmxOpcUa Server",
ProductUri = $"urn:{_galaxyName}:LmxOpcUa",
SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0",
BuildNumber = "1",
BuildDate = DateTime.UtcNow
};
return properties;
}
}
}

View File

@@ -1,33 +0,0 @@
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Maps domain Quality to OPC UA StatusCodes for the OPC UA server layer. (OPC-005)
/// </summary>
public static class OpcUaQualityMapper
{
/// <summary>
/// Converts bridge quality values into OPC UA status codes.
/// </summary>
/// <param name="quality">The bridge quality value.</param>
/// <returns>The OPC UA status code to publish.</returns>
public static StatusCode ToStatusCode(Quality quality)
{
return new StatusCode(QualityMapper.MapToOpcUaStatusCode(quality));
}
/// <summary>
/// Converts an OPC UA status code back into a bridge quality category.
/// </summary>
/// <param name="statusCode">The OPC UA status code to interpret.</param>
/// <returns>The bridge quality category represented by the status code.</returns>
public static Quality FromStatusCode(StatusCode statusCode)
{
if (StatusCode.IsGood(statusCode)) return Quality.Good;
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
return Quality.Bad;
}
}
}

View File

@@ -1,325 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.Configuration;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Domain;
using ZB.MOM.WW.OtOpcUa.Host.Historian;
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
{
/// <summary>
/// Manages the OPC UA ApplicationInstance lifecycle. Programmatic config, no XML. (OPC-001, OPC-012, OPC-013)
/// </summary>
public class OpcUaServerHost : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaServerHost>();
private readonly AlarmObjectFilter? _alarmObjectFilter;
private readonly AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider;
private readonly OpcUaConfiguration _config;
private readonly IHistorianDataSource? _historianDataSource;
private readonly PerformanceMetrics _metrics;
private readonly IMxAccessClient _mxAccessClient;
private readonly RedundancyConfiguration _redundancyConfig;
private readonly SecurityProfileConfiguration _securityConfig;
private ApplicationInstance? _application;
private LmxOpcUaServer? _server;
/// <summary>
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
/// </summary>
/// <param name="config">The endpoint and session settings for the OPC UA host.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live reads, writes, and subscriptions.</param>
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
/// <param name="historianDataSource">The optional historian adapter that enables OPC UA history read support.</param>
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
IHistorianDataSource? historianDataSource = null,
AuthenticationConfiguration? authConfig = null,
IUserAuthenticationProvider? authProvider = null,
SecurityProfileConfiguration? securityConfig = null,
RedundancyConfiguration? redundancyConfig = null,
AlarmObjectFilter? alarmObjectFilter = null,
MxAccessConfiguration? mxAccessConfig = null,
HistorianConfiguration? historianConfig = null)
{
_config = config;
_mxAccessClient = mxAccessClient;
_metrics = metrics;
_historianDataSource = historianDataSource;
_authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider;
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
_alarmObjectFilter = alarmObjectFilter;
_mxAccessConfig = mxAccessConfig ?? new MxAccessConfiguration();
_historianConfig = historianConfig ?? new HistorianConfiguration();
}
private readonly MxAccessConfiguration _mxAccessConfig;
private readonly HistorianConfiguration _historianConfig;
/// <summary>
/// Gets the active node manager that holds the published Galaxy namespace.
/// </summary>
public LmxNodeManager? NodeManager => _server?.NodeManager;
/// <summary>
/// Gets the number of currently connected OPC UA client sessions.
/// </summary>
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
/// <summary>
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
/// </summary>
public bool IsRunning => _server != null;
/// <summary>
/// Gets the list of opc.tcp base addresses the server is currently listening on.
/// Returns an empty list when the server has not started.
/// </summary>
public IReadOnlyList<string> BaseAddresses
{
get
{
var addrs = _application?.ApplicationConfiguration?.ServerConfiguration?.BaseAddresses;
return addrs != null ? addrs.ToList() : Array.Empty<string>();
}
}
/// <summary>
/// Gets the list of active security policies advertised to clients (SecurityMode + PolicyUri).
/// Returns an empty list when the server has not started.
/// </summary>
public IReadOnlyList<ServerSecurityPolicy> SecurityPolicies
{
get
{
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.SecurityPolicies;
return policies != null ? policies.ToList() : Array.Empty<ServerSecurityPolicy>();
}
}
/// <summary>
/// Gets the list of user token policy names advertised to clients (Anonymous, UserName, Certificate).
/// Returns an empty list when the server has not started.
/// </summary>
public IReadOnlyList<string> UserTokenPolicies
{
get
{
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.UserTokenPolicies;
return policies != null ? policies.Select(p => p.TokenType.ToString()).ToList() : Array.Empty<string>();
}
}
/// <summary>
/// Stops the host and releases server resources.
/// </summary>
public void Dispose()
{
Stop();
}
/// <summary>
/// Updates the OPC UA ServiceLevel based on current runtime health.
/// </summary>
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
{
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
}
/// <summary>
/// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured
/// endpoint.
/// </summary>
public async Task StartAsync()
{
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
var applicationUri = _config.ApplicationUri ?? namespaceUri;
// Resolve configured security profiles
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
foreach (var sp in securityPolicies)
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
// Build PKI paths
var pkiRoot = _securityConfig.PkiRootPath ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OPC Foundation", "pki");
var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost";
var serverConfig = new ServerConfiguration
{
BaseAddresses = { $"opc.tcp://{_config.BindAddress}:{_config.Port}{_config.EndpointPath}" },
MaxSessionCount = _config.MaxSessions,
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
MinSessionTimeout = 10000,
UserTokenPolicies = BuildUserTokenPolicies()
};
foreach (var policy in securityPolicies)
serverConfig.SecurityPolicies.Add(policy);
var secConfig = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "own"),
SubjectName = certSubject
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "issuer")
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "trusted")
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "rejected")
},
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
MinimumCertificateKeySize = (ushort)_securityConfig.MinimumCertificateKeySize
};
var appConfig = new ApplicationConfiguration
{
ApplicationName = _config.ServerName,
ApplicationUri = applicationUri,
ApplicationType = ApplicationType.Server,
ProductUri = namespaceUri,
ServerConfiguration = serverConfig,
SecurityConfiguration = secConfig,
TransportQuotas = new TransportQuotas
{
OperationTimeout = 120000,
MaxStringLength = 4 * 1024 * 1024,
MaxByteStringLength = 4 * 1024 * 1024,
MaxArrayLength = 65535,
MaxMessageSize = 4 * 1024 * 1024,
MaxBufferSize = 65535,
ChannelLifetime = 600000,
SecurityTokenLifetime = 3600000
},
TraceConfiguration = new TraceConfiguration
{
OutputFilePath = null,
TraceMasks = 0
}
};
await appConfig.Validate(ApplicationType.Server);
// Hook certificate validation logging
appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation;
_application = new ApplicationInstance
{
ApplicationName = _config.ServerName,
ApplicationType = ApplicationType.Server,
ApplicationConfiguration = appConfig
};
// Check/create application certificate
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
var certLifetimeMonths = (ushort)_securityConfig.CertificateLifetimeMonths;
var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
if (!certOk)
{
Log.Warning("Application certificate check failed, attempting to create...");
certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
}
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
_config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri,
_alarmObjectFilter,
_mxAccessConfig.RuntimeStatusProbesEnabled,
_mxAccessConfig.RuntimeStatusUnknownTimeoutSeconds,
_mxAccessConfig.RequestTimeoutSeconds,
_historianConfig.RequestTimeoutSeconds);
await _application.Start(_server);
Log.Information(
"OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})",
_config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri);
}
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)
{
var cert = e.Certificate;
var subject = cert?.Subject ?? "Unknown";
var thumbprint = cert?.Thumbprint ?? "N/A";
if (_securityConfig.AutoAcceptClientCertificates)
{
e.Accept = true;
Log.Warning(
"Client certificate auto-accepted: Subject={Subject}, Thumbprint={Thumbprint}, ValidTo={ValidTo}",
subject, thumbprint, cert?.NotAfter.ToString("yyyy-MM-dd"));
}
else
{
Log.Warning(
"Client certificate validation: Error={Error}, Subject={Subject}, Thumbprint={Thumbprint}, Accepted={Accepted}",
e.Error?.StatusCode, subject, thumbprint, e.Accept);
}
}
/// <summary>
/// Stops the OPC UA application instance and releases its in-memory server objects.
/// </summary>
public void Stop()
{
try
{
_server?.Stop();
Log.Information("OPC UA server stopped");
}
catch (Exception ex)
{
Log.Warning(ex, "Error stopping OPC UA server");
}
finally
{
_server = null;
_application = null;
}
}
private UserTokenPolicyCollection BuildUserTokenPolicies()
{
var policies = new UserTokenPolicyCollection();
if (_authConfig.AllowAnonymous)
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
if (_authConfig.Ldap.Enabled || _authProvider != null)
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
// X.509 certificate authentication is always available when security is configured
if (_securityConfig.Profiles.Any(p =>
!p.Equals("None", StringComparison.OrdinalIgnoreCase)))
policies.Add(new UserTokenPolicy(UserTokenType.Certificate));
if (policies.Count == 0)
{
Log.Warning("No authentication methods configured — adding Anonymous as fallback");
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
}
return policies;
}
}
}

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