9 Commits

Author SHA1 Message Date
Joseph Doherty 64e3fbe035 docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
2026-05-28 08:10:17 -04:00
Joseph Doherty f9fc7dd2e1 feat(host): wire UseWindowsService so sc.exe-installed service runs cleanly
v2-ci / build (push) Failing after 45s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
v2-e2e / e2e (push) Failing after 37s
The v2 plan's blessed install path (scripts/install/Install-Services.ps1)
registers the host via `sc.exe create binPath=...OtOpcUa.Host.exe`, but the
binary never called `UseWindowsService`. Without it, the Service Control
Manager waits ~30s for the process to call SetServiceStatus(Running) and
then kills it — the install script's design was incomplete.

Two changes:

- Host.csproj: drop the `IsOSPlatform('Windows')` condition on the
  Microsoft.Extensions.Hosting.WindowsServices package reference so the
  package is always available. The runtime helper used by
  UseWindowsService gates on WindowsServiceHelpers.IsWindowsService()
  internally, so it's a no-op when running as a console app or under
  Linux/macOS — the binary stays cross-platform-buildable.

- Program.cs: call builder.Host.UseWindowsService(options =>
  options.ServiceName = "OtOpcUaHost") immediately after CreateBuilder.
  When the host is launched by SCM, WindowsServiceLifetime takes over
  the IHostLifetime slot and reports START/STOP correctly. When launched
  by `dotnet run` or `OtOpcUa.Host.exe` from a console, it's a no-op.

Verified end-to-end on wonder-app-vd03.zmr.zimmer.com: `sc.exe create`
followed by `sc.exe start OtOpcUaHost` transitions from START_PENDING to
RUNNING; /login + /health/ready + /health/active all return 200; service
survives SSH session close and auto-starts on boot per the AUTO_START
flag set by the installer script.
2026-05-26 17:07:52 -04:00
Joseph Doherty 7dfbca6469 feat(opcua): materialise SystemPlatform tags (Galaxy) as OPC UA variables
v2-ci / build (push) Failing after 47s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Closes the gap where Tag rows with EquipmentId=NULL + Namespace.Kind=SystemPlatform
(Galaxy hierarchy) existed in ConfigDb but were never surfaced in the OPC UA
address space. Now they materialise as Variable nodes under a folder named for
their FolderPath, browseable through any OPC UA client.

Layers touched:

- IOpcUaAddressSpaceSink: new EnsureVariable(nodeId, parentFolderId, displayName,
  dataType) signature on the sink interface, NullSink, DeferredSink, SdkSink.
- OtOpcUaNodeManager.EnsureVariable: creates a BaseDataVariableState parented
  under the named folder (or root), initial Value=null +
  StatusCode=BadWaitingForInitialData; resolves Tag.DataType strings to the
  matching OPC UA built-in NodeId. Idempotent.
- Phase7CompositionResult: new GalaxyTags collection of GalaxyTagPlan records
  carrying (TagId, DriverInstanceId, FolderPath, DisplayName, DataType,
  MxAccessRef). Constructor overloads keep existing call sites compiling.
- Phase7Composer.Compose: now takes Tag + Namespace inputs, filters for
  SystemPlatform-namespace tags with EquipmentId=NULL, emits GalaxyTagPlan
  rows with MXAccess ref "FolderPath.Name".
- Phase7Plan: new AddedGalaxyTags / RemovedGalaxyTags / ChangedGalaxyTags
  collections + GalaxyTagDelta record; IsEmpty + needsRebuild updated.
- Phase7Planner.Compute: diffs GalaxyTags by TagId via existing DiffById helper.
- DeploymentArtifact.ParseComposition: reads the Tags + Namespaces +
  DriverInstances arrays the ConfigComposer already emits, applies the same
  SystemPlatform filter, returns the same GalaxyTagPlan list as the composer
  so artifact-side and compose-side plans agree.
- Phase7Applier: new MaterialiseGalaxyTags pass that ensures one folder per
  distinct FolderPath then one Variable per tag. NodeId for the variable is
  "<FolderPath>.<Name>" matching the MXAccess ref so the future Galaxy
  SubscribeBulk wiring can address them directly.
- OpcUaPublishActor.RebuildAddressSpace: invokes MaterialiseGalaxyTags after
  MaterialiseHierarchy. _lastApplied initialiser updated for the new ctor.
- seed-clusters.sql: pre-existing TestMachine_001.TestAlarm001..003 rows
  needed no change — the composer/applier now picks them up automatically.

Verified end-to-end via docker-dev: deploy click → driver-a logs
"Phase7Applier: Galaxy tags materialised (tags=3, folders=1)" → OPC UA Client
CLI browses the three Variable nodes under TestMachine_001 folder. Reads
return BadWaitingForInitialData status (expected — Galaxy driver's
SubscribeBulk wiring to push values into the nodes is the remaining
follow-up).
2026-05-26 15:43:22 -04:00
Joseph Doherty 44b8a9c7ff fix(deploy): ClusterNode NodeId uses host:port + Traefik sticky cookie
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Two bring-up issues found while clicking through the operator Deploy flow
on the docker-dev stack:

- ConfigPublishCoordinator computes expected-ack NodeIds from
  Akka.Cluster.State.Members as "{host}:{port}" (e.g. "driver-a:4053") to
  match ClusterRoleInfo's NodeId derivation. The seed had been using the
  bare service name ("driver-a"), so NodeDeploymentState INSERT hit FK
  violation 547 on NodeDeploymentState.NodeId → ClusterNode.NodeId. Seed
  now writes the full host:port form for every ClusterNode row.

- Blazor Server uses SignalR (WebSocket upgrade after the initial GET).
  Without sticky sessions, Traefik round-robins admin-a/admin-b and the
  WebSocket upgrade lands on the wrong backend, returning "No Connection
  with that ID: Status code '404'" so @onclick handlers never fire on the
  client. Added sticky.cookie (otopcua_lb, SameSite=Lax) to all three
  Traefik service loadBalancers so each session pins to one node.

Verified end-to-end: clicked "Deploy current configuration" on
/deployments → Deployment row sealed in ~70ms → driver-a + driver-b
spawn GalaxyMxGateway driver (stub=False) → GalaxyDriver connects to
http://10.100.0.48:5120 with the seeded ApiKeySecretRef=env:GALAXY_MXGW_API_KEY.
2026-05-26 15:10:11 -04:00
Joseph Doherty 60beb9128e feat(deploy,runtime): wire mxaccessgw connection — endpoint, key, seed row
v2-ci / build (push) Failing after 37s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
User confirmed the mxaccessgw client (Galaxy driver) doesn't need Windows
— only the gateway worker has that constraint. This wires the Galaxy
driver into the docker-dev fleet:

- docker-compose.yml: GALAXY_MXGW_API_KEY env var on every host service
  (admin nodes harmlessly ignore it; driver-role nodes pick it up when
  the seeded DriverInstance resolves ApiKeySecretRef=env:GALAXY_MXGW_API_KEY).
  Default value matches the key the operator provided; override via shell
  env (GALAXY_MXGW_API_KEY=... docker compose up -d) to rotate without
  editing compose.
- seed-clusters.sql: now creates a SystemPlatform Namespace
  (MAIN-galaxy, urn:zb:docker-dev:galaxy) plus a GalaxyMxGateway
  DriverInstance (MAIN-galaxy-mxgw) in the MAIN cluster pointing at
  http://10.100.0.48:5120 with UseTls=false. Idempotent via IF NOT EXISTS.
- DriverInstanceActor.ShouldStub: clarified the doc comment — only the
  legacy "Galaxy" type name and "Historian.Wonderware" are Windows-only;
  the v2 "GalaxyMxGateway" driver is .NET 10 cross-platform (gRPC to an
  external gateway) and is NOT stubbed.
- README: documents the final operator step — sign in, click "Deploy
  current configuration" on /deployments to materialise the seeded
  Galaxy driver into a running gRPC connection. Raw DriverInstance rows
  don't spawn drivers on their own; the v2 lifecycle requires a sealed
  Deployment first.
2026-05-26 14:58:02 -04:00
Joseph Doherty 6884de9774 revert(adminui): restore 'OtOpcUa Admin' login title
v2-ci / build (push) Failing after 44s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
User chose to revert the MxAccess Gateway rebrand on the login card. Keep
the layout fix from c064ec1 (no panel-head top strip; inline h1.login-title)
and just put the original product name back.
2026-05-26 14:50:06 -04:00
Joseph Doherty c064ec16cf fix(security,adminui): logout redirects to /login + restyle login card
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Two small UX fixes:

- AuthEndpoints.LogoutAsync now redirects browser callers to /login after
  SignOutAsync instead of returning 204 NoContent. 204 was correct for the
  REST contract but left browsers stuck on the page they came from (the
  cookie was cleared but no navigation happened, so "Sign out" appeared
  to do nothing). API callers can still opt into the status-only behavior
  by sending `Accept: application/json`.

- Login.razor drops the .panel-head top strip; the sign-in card now reads
  as a self-contained form with an inline title "MxAccess Gateway Admin —
  sign in". Added a .login-title CSS class to site.css that matches the
  panel-head's typographic weight without the bar.
2026-05-26 14:47:53 -04:00
Joseph Doherty ed1c17bc7b fix(deploy,host): docker-dev bring-up — anon health probes, robust seeder
v2-ci / build (push) Failing after 32s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Two fixes surfaced while bringing up the docker-dev stack end-to-end:

- HealthEndpoints.MapOtOpcUaHealth now calls .AllowAnonymous() on /health/ready,
  /health/active, /healthz. Without it the AddOtOpcUaAuth fallback policy 401s
  every probe and Traefik marks every backend unhealthy → all three cluster
  routes return 503.

- cluster-seed entrypoint no longer attempts to apply Migrate-To-V2.sql via
  sqlcmd. The EF-generated idempotent script puts CREATE PROCEDURE inside
  IF NOT EXISTS BEGIN ... END blocks (procs must be first in their batch),
  so sqlcmd fails with "Must declare the scalar variable @FromGenerationId".
  EF's own runner handles this; sqlcmd doesn't. The seed now just waits for
  the schema and applies row inserts. Migrations remain the operator's job:
      dotnet ef database update --project src/Core/.../Configuration \
                                --startup-project src/Server/.../Host

Also: LDAP service removed (bitnami/openldap:2.6 image retired, legacy tag
crashes mid-setup with exit 68); every host now runs with
Authentication__Ldap__DevStubMode=true. Bumped LDAP+Traefik dashboard host
ports to avoid collisions with the sister scadalink dev stack (3893→3894,
8080→8089).

Confirmed working end-to-end: all three Traefik routes return HTTP 200,
cluster-seed populates ServerCluster (MAIN/SITE-A/SITE-B) + ClusterNode
(driver-a/b, site-a-1/2, site-b-1/2) rows on first boot.
2026-05-26 14:37:01 -04:00
Joseph Doherty 1e64488c0d Merge branch 'v2-gap-closeout' — close audit gaps + dev-UX polish
v2-ci / build (push) Failing after 47s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Closes the four gaps from the 2026-05-26 hosting-alignment audit and
adds the supporting dev infrastructure that surfaced while smoke-testing
the fused Host:

Audit-gap closure:
  - feat(host): per-role appsettings overlays for admin / driver / admin-driver
  - feat(opcua): OpcUaApplicationHost.PeerApplicationUris populates Server.ServerArray
    via IServerInternal.ServerUris.Append; unit test + new OpcUaServer.IntegrationTests
    project carrying DualEndpointTests (real OPCFoundation client reads both peer URIs)
  - refactor(test): rename FailoverScenarioTests → FailoverDuringDeployTests
  - chore(cleanup): remove stale bin/obj shells for deleted v1 Server/Admin projects
  - ci(v2): integration matrix now runs both Host.IntegrationTests and
    OpcUaServer.IntegrationTests

Doc audit + refresh:
  - 3 commits rewriting stale paths and adding v2 architecture coverage across
    Redundancy / ServiceHosting / Cluster / OpcUaServer / security / Architecture-v2
    / v2-release-readiness / phase-7-status / README and 7 driver-touched doc files

Dev-UX (surfaced while smoke-testing in Chrome):
  - fix(host,security): UseStaticWebAssets, MapStaticAssets().AllowAnonymous,
    AddCascadingAuthenticationState, ILdapAuthService Scoped→Singleton,
    /auth/login Content-Type dispatch + DisableAntiforgery, real LdapOptions.DevStubMode
  - feat(adminui): ScadaLink-style sidebar — drop the top app-bar, brand in side rail,
    collapsible NavSection sections with cookie state (otopcua_nav), new LoginLayout
    (no rail), NavSidebar as the interactive island so MainLayout stays static-rendered
  - fix(adminui): refresh stale F9 stub copy on /alerts page

docker-dev deployment:
  - feat(deploy): add site-a + site-b 2-node clusters (fused admin+driver) — three
    isolated Akka meshes (disjoint seed lists) sharing the single OtOpcUa ConfigDb;
    Traefik routes via Host(`site-a.localhost`) / Host(`site-b.localhost`)
  - feat(deploy): one-shot cluster-seed Compose service applies an idempotent SQL
    seed (3 ServerCluster rows + 6 ClusterNode rows) so operators don't have to
    pre-populate via the Admin UI on every fresh bring-up

19 commits, all conventional-commits format. Branch was pushed and reviewed on
gitea before the merge.
2026-05-26 14:08:29 -04:00
765 changed files with 10421 additions and 194 deletions
+12 -12
View File
@@ -9,8 +9,9 @@ Mac-friendly multi-cluster OtOpcUa fleet for manual UI exercise + integration sm
| Service | Role | Ports |
|---|---|---|
| `sql` | SQL Server 2022 — single `OtOpcUa` ConfigDb shared by all three clusters | host `14330` → container `1433` |
| `ldap` | OpenLDAP with dev users `alice` / `bob` | host `3893` → container `1389` |
| `traefik` | Routes :80 by Host header / PathPrefix | host `80`, dashboard `8080` |
| `traefik` | Routes :80 by Host header / PathPrefix | host `80`, dashboard `8089` |
Authentication runs in `DevStubMode` — every host container has `Authentication__Ldap__DevStubMode=true` set, so the LDAP service is not part of the dev compose right now (the `bitnami/openldap:2.6` image was retired and the legacy tag crashes mid-setup with exit 68). Any non-empty username/password signs in as `FleetAdmin`. To restore a real LDAP service, drop the env var and add an `openldap`-compatible image back to compose.
### Main cluster — split admin/driver roles
@@ -59,6 +60,12 @@ The SQL lives at `seed/seed-clusters.sql`; the wait-and-apply wrapper lives at `
docker compose -f docker-dev/docker-compose.yml run --rm cluster-seed
```
### Galaxy / MxAccess gateway
The seed also pre-creates a `SystemPlatform` Namespace + a `GalaxyMxGateway` DriverInstance in the MAIN cluster pointing at `http://10.100.0.48:5120`. The API key is resolved from the `GALAXY_MXGW_API_KEY` env var set on every driver-role container in compose; override via `GALAXY_MXGW_API_KEY=... docker compose up -d` to swap keys without editing the compose file.
The DriverHost actor doesn't spawn drivers from raw DriverInstance rows on its own — the v2 deploy lifecycle requires a *sealed Deployment* before drivers materialise. After first bring-up, sign in to the Admin UI and click **Deploy current configuration** on `/deployments` to compose the seeded rows into an artifact and dispatch it. The Galaxy driver instance will start its gRPC connection to the gateway on the next deploy ack.
## Bring up
```bash
@@ -70,7 +77,7 @@ docker compose -f docker-dev/docker-compose.yml up -d --build
open http://localhost # main cluster admin UI
open http://site-a.localhost # site A admin UI
open http://site-b.localhost # site B admin UI
open http://localhost:8080 # Traefik dashboard
open http://localhost:8089 # Traefik dashboard
```
On macOS, `*.localhost` resolves to `127.0.0.1` automatically. On Linux add `127.0.0.1 site-a.localhost site-b.localhost` to `/etc/hosts` if your resolver doesn't.
@@ -79,14 +86,7 @@ The first build takes a few minutes (.NET SDK image + restore + publish). Subseq
## Auth (dev only)
Use one of the LDAP dev users from `LDAP_USERS` in `docker-compose.yml`:
| Username | Password |
|---|---|
| `alice` | `alice123` |
| `bob` | `bob123` |
The compose mounts everyone into `ou=FleetAdmin` so the dev role mapping resolves to `FleetAdmin`.
`Authentication__Ldap__DevStubMode=true` is set on every host container, so any non-empty username/password signs in as a `FleetAdmin` user without contacting an LDAP server. **Do not** ship this configuration to production — set `DevStubMode=false` and wire a real LDAP backend before any non-dev deployment.
## Tear down
@@ -98,7 +98,7 @@ The `-v` drops the SQL + LDAP volumes; remove it to keep ConfigDb state across r
## Failover smoke
1. Watch the Traefik dashboard at `http://localhost:8080`. Both `admin-a` and `admin-b` should be listed as healthy in the `otopcua-admin` service.
1. Watch the Traefik dashboard at `http://localhost:8089`. Both `admin-a` and `admin-b` should be listed as healthy in the `otopcua-admin` service.
2. `docker compose -f docker-dev/docker-compose.yml stop admin-a``admin-b` should pick up the admin role-leader within ~15 s (Akka split-brain stable-after). Traefik will route traffic to `admin-b` once its `/health/active` returns 200.
3. `docker compose -f docker-dev/docker-compose.yml start admin-a``admin-a` rejoins as a follower; `admin-b` keeps the leader role until something disturbs it.
+24 -31
View File
@@ -35,7 +35,7 @@
# open http://localhost # main cluster Blazor admin UI
# open http://site-a.localhost # site A admin UI
# open http://site-b.localhost # site B admin UI
# open http://localhost:8080 # Traefik dashboard
# open http://localhost:8089 # Traefik dashboard (8080 is the sister scadalink stack)
#
# Tear-down: docker compose -f docker-dev/docker-compose.yml down -v
@@ -71,17 +71,12 @@ services:
entrypoint: ["/bin/bash", "/seed/entrypoint.sh"]
restart: "no"
ldap:
image: bitnami/openldap:2.6
environment:
LDAP_ROOT: "dc=lmxopcua,dc=local"
LDAP_ADMIN_USERNAME: "admin"
LDAP_ADMIN_PASSWORD: "ldapadmin"
LDAP_USERS: "alice,bob"
LDAP_PASSWORDS: "alice123,bob123"
LDAP_USER_DC: "ou=FleetAdmin"
ports:
- "3893:1389"
# OpenLDAP was previously here but the bitnami/openldap:2.6 image was retired
# (manifest gone) and bitnamilegacy/openldap:2.6 crashes during LDIF setup with
# exit 68. For the dev compose every host container now runs with
# Authentication__Ldap__DevStubMode=true, so any non-empty username/password
# signs in as `FleetAdmin`. Restore a real LDAP service when there's a need
# for end-to-end LDAP coverage (the host code path is unchanged).
admin-a: &otopcua-host
build:
@@ -102,9 +97,8 @@ services:
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
Authentication__Ldap__Server: "ldap"
Authentication__Ldap__Port: "1389"
Authentication__Ldap__AllowInsecureLdap: "true"
Authentication__Ldap__DevStubMode: "true"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
admin-b:
<<: *otopcua-host
@@ -120,9 +114,8 @@ services:
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
Authentication__Ldap__Server: "ldap"
Authentication__Ldap__Port: "1389"
Authentication__Ldap__AllowInsecureLdap: "true"
Authentication__Ldap__DevStubMode: "true"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
driver-a:
<<: *otopcua-host
@@ -134,6 +127,9 @@ services:
Cluster__PublicHostname: "driver-a"
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
Cluster__Roles__0: "driver"
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
ports:
- "4840:4840"
@@ -147,6 +143,7 @@ services:
Cluster__PublicHostname: "driver-b"
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
Cluster__Roles__0: "driver"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
ports:
- "4841:4840"
@@ -170,9 +167,8 @@ services:
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
Authentication__Ldap__Server: "ldap"
Authentication__Ldap__Port: "1389"
Authentication__Ldap__AllowInsecureLdap: "true"
Authentication__Ldap__DevStubMode: "true"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
ports:
- "4842:4840"
@@ -194,9 +190,8 @@ services:
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
Authentication__Ldap__Server: "ldap"
Authentication__Ldap__Port: "1389"
Authentication__Ldap__AllowInsecureLdap: "true"
Authentication__Ldap__DevStubMode: "true"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
ports:
- "4843:4840"
@@ -217,9 +212,8 @@ services:
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
Authentication__Ldap__Server: "ldap"
Authentication__Ldap__Port: "1389"
Authentication__Ldap__AllowInsecureLdap: "true"
Authentication__Ldap__DevStubMode: "true"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
ports:
- "4844:4840"
@@ -241,9 +235,8 @@ services:
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
Authentication__Ldap__Server: "ldap"
Authentication__Ldap__Port: "1389"
Authentication__Ldap__AllowInsecureLdap: "true"
Authentication__Ldap__DevStubMode: "true"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
ports:
- "4845:4840"
@@ -256,7 +249,7 @@ services:
- --api.insecure=true
ports:
- "80:80"
- "8080:8080"
- "8089:8080" # 8080 conflicts with the sister scadalink dev stack
volumes:
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
depends_on:
+26 -13
View File
@@ -1,35 +1,48 @@
#!/usr/bin/env bash
# docker-dev cluster-seed entrypoint. Waits for the host containers to finish
# their EF Core auto-migration (which creates the ServerCluster table), then
# applies the idempotent seed script.
# docker-dev cluster-seed entrypoint. Waits for the OtOpcUa ConfigDb schema to
# be in place, then applies the idempotent row seed.
#
# Image: mcr.microsoft.com/mssql-tools (Debian + sqlcmd at /opt/mssql-tools18/bin).
# IMPORTANT: this container does NOT run EF migrations — sqlcmd can't execute
# the V2 migration script cleanly because it contains CREATE PROCEDURE
# statements inside IF NOT EXISTS BEGIN ... END blocks (procs must be the
# first statement in their batch). Migrations are owned by the operator:
#
# dotnet ef database update \
# --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration \
# --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host
#
# (with ConnectionStrings__ConfigDb pointing at Server=localhost,14330;...).
# Once the schema is in place, restart the cluster-seed container — or just
# `docker compose up -d` and the seed will pick up where it left off thanks to
# the IF NOT EXISTS guards in seed-clusters.sql.
set -euo pipefail
SQLCMD="/opt/mssql-tools18/bin/sqlcmd"
SQLCMD="/opt/mssql-tools/bin/sqlcmd"
SERVER="${SQL_HOST:-sql},1433"
USER="${SQL_USER:-sa}"
PASS="${SQL_PASSWORD:-OtOpcUa!Dev123}"
DB="${SQL_DATABASE:-OtOpcUa}"
run_sql() {
"$SQLCMD" -S "$SERVER" -U "$USER" -P "$PASS" -d "$DB" -No -b -h -1 "$@"
run_sql_in() {
local target_db="$1"; shift
# -I forces SET QUOTED_IDENTIFIER ON (needed for filtered indexes if you
# ever extend this script to touch them).
"$SQLCMD" -S "$SERVER" -U "$USER" -P "$PASS" -d "$target_db" -b -h -1 -I "$@"
}
echo "[cluster-seed] waiting for SQL Server to accept connections..."
until run_sql -Q "SELECT 1" >/dev/null 2>&1; do
until run_sql_in master -Q "SELECT 1" >/dev/null 2>&1; do
sleep 2
done
echo "[cluster-seed] SQL Server up."
echo "[cluster-seed] waiting for $DB.ServerCluster (host containers must finish EF migration)..."
until run_sql -Q "IF OBJECT_ID('dbo.ServerCluster') IS NULL THROW 50001, 'missing', 1; SELECT 1" >/dev/null 2>&1; do
echo "[cluster-seed] waiting for ${DB} database + dbo.ServerCluster table (operator must run dotnet ef database update)..."
until run_sql_in "$DB" -Q "IF OBJECT_ID('dbo.ServerCluster') IS NULL THROW 50001, 'missing', 1; SELECT 1" >/dev/null 2>&1; do
sleep 3
done
echo "[cluster-seed] schema ready."
echo "[cluster-seed] applying seed-clusters.sql..."
run_sql -i /seed/seed-clusters.sql
echo "[cluster-seed] applying seed-clusters.sql (ServerCluster + ClusterNode rows)..."
run_sql_in "$DB" -i /seed/seed-clusters.sql
echo "[cluster-seed] done."
+101 -12
View File
@@ -55,45 +55,130 @@ IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'SITE-B')
------------------------------------------------------------------------------
-- ClusterNode — main cluster OPC UA publishers
--
-- NodeId is "<compose-service>:4053" so it matches what ClusterRoleInfo +
-- ConfigPublishCoordinator derive from Akka.Cluster.Get(system).State.Members
-- (member.Address.Host:Port). NodeDeploymentState.NodeId is FK-bound to
-- ClusterNode.NodeId; mismatched values cause FK 547 on deploy.
------------------------------------------------------------------------------
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-a')
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-a:4053')
INSERT INTO dbo.ClusterNode
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES ('driver-a', 'MAIN', 'driver-a', 4840, 8081, 'urn:OtOpcUa:driver-a', 200, 1, 'docker-dev-seed');
VALUES ('driver-a:4053', 'MAIN', 'driver-a', 4840, 8081, 'urn:OtOpcUa:driver-a', 200, 1, 'docker-dev-seed');
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-b')
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-b:4053')
INSERT INTO dbo.ClusterNode
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES ('driver-b', 'MAIN', 'driver-b', 4840, 8081, 'urn:OtOpcUa:driver-b', 150, 1, 'docker-dev-seed');
VALUES ('driver-b:4053', 'MAIN', 'driver-b', 4840, 8081, 'urn:OtOpcUa:driver-b', 150, 1, 'docker-dev-seed');
------------------------------------------------------------------------------
-- ClusterNode — site A
------------------------------------------------------------------------------
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-1')
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-1:4053')
INSERT INTO dbo.ClusterNode
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES ('site-a-1', 'SITE-A', 'site-a-1', 4840, 8081, 'urn:OtOpcUa:site-a-1', 200, 1, 'docker-dev-seed');
VALUES ('site-a-1:4053', 'SITE-A', 'site-a-1', 4840, 8081, 'urn:OtOpcUa:site-a-1', 200, 1, 'docker-dev-seed');
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-2')
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-2:4053')
INSERT INTO dbo.ClusterNode
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES ('site-a-2', 'SITE-A', 'site-a-2', 4840, 8081, 'urn:OtOpcUa:site-a-2', 150, 1, 'docker-dev-seed');
VALUES ('site-a-2:4053', 'SITE-A', 'site-a-2', 4840, 8081, 'urn:OtOpcUa:site-a-2', 150, 1, 'docker-dev-seed');
------------------------------------------------------------------------------
-- ClusterNode — site B
------------------------------------------------------------------------------
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-1')
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-1:4053')
INSERT INTO dbo.ClusterNode
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES ('site-b-1', 'SITE-B', 'site-b-1', 4840, 8081, 'urn:OtOpcUa:site-b-1', 200, 1, 'docker-dev-seed');
VALUES ('site-b-1:4053', 'SITE-B', 'site-b-1', 4840, 8081, 'urn:OtOpcUa:site-b-1', 200, 1, 'docker-dev-seed');
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2')
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2:4053')
INSERT INTO dbo.ClusterNode
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES ('site-b-2', 'SITE-B', 'site-b-2', 4840, 8081, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
------------------------------------------------------------------------------
-- Galaxy MxAccess gateway — MAIN cluster
--
-- Namespace.Kind=SystemPlatform is required for Galaxy/MXAccess data per
-- decision #107; raw equipment drivers use Equipment. DriverInstance points
-- at the external mxaccessgw process. The driver code lives in this repo
-- (.NET 10, cross-platform); only the gateway worker needs Windows.
--
-- ApiKeySecretRef = env:GALAXY_MXGW_API_KEY → resolved at runtime by
-- GalaxyDriver.ResolveApiKey. The env var is set on every driver-role
-- container in docker-compose.yml.
------------------------------------------------------------------------------
IF NOT EXISTS (SELECT 1 FROM dbo.Namespace WHERE NamespaceId = 'MAIN-galaxy')
INSERT INTO dbo.Namespace
(NamespaceRowId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
VALUES
(NEWID(), 'MAIN-galaxy', 'MAIN', 'SystemPlatform',
'urn:zb:docker-dev:galaxy', 1,
'docker-dev seed — Galaxy / MXAccess namespace served by the MAIN cluster.');
IF NOT EXISTS (SELECT 1 FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-galaxy-mxgw')
INSERT INTO dbo.DriverInstance
(DriverInstanceRowId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
VALUES
(NEWID(), 'MAIN-galaxy-mxgw', 'MAIN', 'MAIN-galaxy',
'MxAccess gateway (10.100.0.48:5120)', 'GalaxyMxGateway', 1,
N'{
"Gateway": {
"Endpoint": "http://10.100.0.48:5120",
"ApiKeySecretRef": "env:GALAXY_MXGW_API_KEY",
"UseTls": false,
"ConnectTimeoutSeconds": 10,
"DefaultCallTimeoutSeconds": 30
},
"MxAccess": {
"ClientName": "OtOpcUa-MAIN-docker-dev",
"PublishingIntervalMs": 1000
},
"Repository": {
"DiscoverPageSize": 5000,
"WatchDeployEvents": true
},
"Reconnect": {
"InitialBackoffMs": 500,
"MaxBackoffMs": 30000,
"ReplayOnSessionLost": true
}
}');
------------------------------------------------------------------------------
-- Galaxy test tags — TestMachine_001.TestAlarm001..003
--
-- SystemPlatform-namespace tags have EquipmentId=NULL and use FolderPath +
-- Name to address the MXAccess item. The Galaxy driver subscribes via the
-- "FolderPath.Name" MXAccess reference form; OPC UA browse path is the
-- equivalent "FolderPath/Name" under the SystemPlatform namespace.
------------------------------------------------------------------------------
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm001')
INSERT INTO dbo.Tag
(TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
VALUES
(NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm001', 'MAIN-galaxy-mxgw', NULL, NULL,
'TestAlarm001', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}');
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm002')
INSERT INTO dbo.Tag
(TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
VALUES
(NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm002', 'MAIN-galaxy-mxgw', NULL, NULL,
'TestAlarm002', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}');
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm003')
INSERT INTO dbo.Tag
(TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
VALUES
(NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm003', 'MAIN-galaxy-mxgw', NULL, NULL,
'TestAlarm003', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}');
COMMIT TRANSACTION;
@@ -104,3 +189,7 @@ COMMIT TRANSACTION;
SELECT ClusterId, Name, NodeCount, RedundancyMode FROM dbo.ServerCluster ORDER BY ClusterId;
SELECT NodeId, ClusterId, Host, OpcUaPort, ApplicationUri, ServiceLevelBase
FROM dbo.ClusterNode ORDER BY ClusterId, NodeId;
SELECT NamespaceId, ClusterId, Kind, NamespaceUri FROM dbo.Namespace ORDER BY ClusterId, NamespaceId;
SELECT DriverInstanceId, ClusterId, DriverType, NamespaceId, Name
FROM dbo.DriverInstance ORDER BY ClusterId, DriverInstanceId;
SELECT TagId, DriverInstanceId, FolderPath, Name, DataType FROM dbo.Tag ORDER BY DriverInstanceId, FolderPath, Name;
+24
View File
@@ -28,6 +28,14 @@ http:
services:
otopcua-admin:
loadBalancer:
# Blazor Server uses SignalR; the WebSocket upgrade must hit the same
# backend that owns the circuit ID. Sticky cookie keeps each session
# pinned to one node so the post-handshake WebSocket doesn't 404.
sticky:
cookie:
name: otopcua_lb
httpOnly: true
sameSite: lax
servers:
- url: "http://admin-a:9000"
- url: "http://admin-b:9000"
@@ -38,6 +46,14 @@ http:
otopcua-site-a:
loadBalancer:
# Blazor Server uses SignalR; the WebSocket upgrade must hit the same
# backend that owns the circuit ID. Sticky cookie keeps each session
# pinned to one node so the post-handshake WebSocket doesn't 404.
sticky:
cookie:
name: otopcua_lb
httpOnly: true
sameSite: lax
servers:
- url: "http://site-a-1:9000"
- url: "http://site-a-2:9000"
@@ -48,6 +64,14 @@ http:
otopcua-site-b:
loadBalancer:
# Blazor Server uses SignalR; the WebSocket upgrade must hit the same
# backend that owns the circuit ID. Sticky cookie keeps each session
# pinned to one node so the post-handshake WebSocket doesn't 404.
sticky:
cookie:
name: otopcua_lb
httpOnly: true
sameSite: lax
servers:
- url: "http://site-b-1:9000"
- url: "http://site-b-2:9000"
@@ -42,6 +42,7 @@ public class AlarmsCommand : CommandBase
/// Connects to the server, subscribes to alarm events, and streams operator-facing alarm state changes to the console.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -36,10 +36,7 @@ public class BrowseCommand : CommandBase
[CommandOption("recursive", 'r', Description = "Browse recursively (uses --depth as max depth)")]
public bool Recursive { get; init; }
/// <summary>
/// Connects to the server and prints a tree view of the requested address-space branch.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -15,10 +15,7 @@ public class ConnectCommand : CommandBase
{
}
/// <summary>
/// Connects to the server and prints the negotiated endpoint details for operator verification.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -56,10 +56,7 @@ public class HistoryReadCommand : CommandBase
[CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
public double IntervalMs { get; init; } = 3600000;
/// <summary>
/// Connects to the server and prints raw or processed historical values for the requested node.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -24,10 +24,7 @@ public class ReadCommand : CommandBase
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=2;s=MyNode)", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Connects to the server and prints the current value, status, and timestamps for the requested node.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -15,10 +15,8 @@ public class RedundancyCommand : CommandBase
{
}
/// <summary>
/// Connects to the server and prints redundancy mode, service level, and partner-server identity data.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <summary>Connects to the server and prints redundancy mode, service level, and partner-server identity data.</summary>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -67,11 +67,7 @@ public class SubscribeCommand : CommandBase
[CommandOption("summary-file", Description = "Write summary to this file path on exit (in addition to stdout)")]
public string? SummaryFile { get; init; }
/// <summary>
/// Connects to the server, subscribes to <see cref="NodeId" /> (or its subtree when recursive),
/// streams data-change notifications to the console, and prints a summary when the command exits.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -35,6 +35,7 @@ public class WriteCommand : CommandBase
/// Connects to the server, converts the supplied value to the node's current data type, and issues the write.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -12,6 +12,9 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
{
private static readonly ILogger Logger = Log.ForContext<DefaultApplicationConfigurationFactory>();
/// <summary>Creates an OPC UA application configuration from the provided connection settings.</summary>
/// <param name="settings">The connection settings to use.</param>
/// <param name="ct">Token to cancel the operation.</param>
public async Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct)
{
// Resolve the canonical PKI path lazily on first use so constructing a
@@ -11,6 +11,10 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
{
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
/// <summary>Selects an OPC UA endpoint matching the requested security mode.</summary>
/// <param name="config">The application configuration.</param>
/// <param name="endpointUrl">The endpoint URL to query.</param>
/// <param name="requestedMode">The requested message security mode.</param>
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode)
{
@@ -11,6 +11,14 @@ internal sealed class DefaultSessionFactory : ISessionFactory
{
private static readonly ILogger Logger = Log.ForContext<DefaultSessionFactory>();
/// <summary>Creates a new OPC UA session.</summary>
/// <param name="config">The OPC UA application configuration.</param>
/// <param name="endpoint">The endpoint description to connect to.</param>
/// <param name="sessionName">The name for the session.</param>
/// <param name="sessionTimeoutMs">The session timeout in milliseconds.</param>
/// <param name="identity">The user identity for the session.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>An adapter wrapping the created session.</returns>
public async Task<ISessionAdapter> CreateSessionAsync(
ApplicationConfiguration config,
EndpointDescription endpoint,
@@ -11,5 +11,7 @@ internal interface IApplicationConfigurationFactory
/// <summary>
/// Creates a validated ApplicationConfiguration for the given connection settings.
/// </summary>
/// <param name="settings">The connection settings to configure.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct = default);
}
@@ -11,6 +11,9 @@ internal interface IEndpointDiscovery
/// Discovers endpoints at the given URL and returns the best match for the requested security mode.
/// Also rewrites the endpoint URL hostname to match the requested URL when they differ.
/// </summary>
/// <param name="config">The OPC UA application configuration.</param>
/// <param name="endpointUrl">The endpoint URL to discover.</param>
/// <param name="requestedMode">The requested message security mode.</param>
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode);
}
@@ -11,6 +11,8 @@ public static class AggregateTypeMapper
/// <summary>
/// Returns the OPC UA NodeId for the specified aggregate type.
/// </summary>
/// <param name="aggregate">The aggregate type to map to a NodeId.</param>
/// <returns>The OPC UA NodeId for the aggregate function.</returns>
public static NodeId ToNodeId(AggregateType aggregate)
{
return aggregate switch
@@ -8,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
/// </summary>
public static class SecurityModeMapper
{
/// <summary>
/// Converts a <see cref="SecurityMode" /> to an OPC UA <see cref="MessageSecurityMode" />.
/// </summary>
/// <summary>Converts a SecurityMode to an OPC UA MessageSecurityMode.</summary>
/// <param name="mode">The security mode to convert.</param>
/// <returns>The corresponding message security mode.</returns>
public static MessageSecurityMode ToMessageSecurityMode(SecurityMode mode)
{
return mode switch
@@ -5,5 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
/// </summary>
public interface IOpcUaClientServiceFactory
{
/// <summary>Creates a new OPC UA client service instance.</summary>
/// <returns>A new <see cref="IOpcUaClientService"/> instance.</returns>
IOpcUaClientService Create();
}
@@ -5,6 +5,20 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// </summary>
public sealed class AlarmEventArgs : EventArgs
{
/// <summary>Initializes a new instance of the <see cref="AlarmEventArgs"/> class.</summary>
/// <param name="sourceName">The name of the source object that raised the alarm.</param>
/// <param name="conditionName">The condition type name.</param>
/// <param name="severity">The alarm severity (0-1000).</param>
/// <param name="message">Human-readable alarm message.</param>
/// <param name="retain">Whether the alarm should be retained in the display.</param>
/// <param name="activeState">Whether the alarm condition is currently active.</param>
/// <param name="ackedState">Whether the alarm has been acknowledged.</param>
/// <param name="time">The time the event occurred.</param>
/// <param name="eventId">The EventId used for alarm acknowledgment.</param>
/// <param name="conditionNodeId">The NodeId of the condition instance.</param>
/// <param name="operatorComment">Operator-supplied comment on acknowledgment transitions.</param>
/// <param name="originalRaiseTimestampUtc">When the alarm originally entered the active state.</param>
/// <param name="alarmCategory">Upstream alarm taxonomy bucket (e.g. Process, Safety, Diagnostics).</param>
public AlarmEventArgs(
string sourceName,
string conditionName,
@@ -5,6 +5,11 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// </summary>
public sealed class BrowseResult
{
/// <summary>Initializes a new instance of the BrowseResult class.</summary>
/// <param name="nodeId">The string representation of the node's NodeId.</param>
/// <param name="displayName">The display name of the node.</param>
/// <param name="nodeClass">The node class (e.g., "Object", "Variable", "Method").</param>
/// <param name="hasChildren">Whether the node has child references.</param>
public BrowseResult(string nodeId, string displayName, string nodeClass, bool hasChildren)
{
NodeId = nodeId;
@@ -5,6 +5,13 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// </summary>
public sealed class ConnectionInfo
{
/// <summary>Initializes a new instance of the ConnectionInfo with session details.</summary>
/// <param name="endpointUrl">The endpoint URL of the connected server.</param>
/// <param name="serverName">The server application name.</param>
/// <param name="securityMode">The security mode in use.</param>
/// <param name="securityPolicyUri">The security policy URI.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="sessionName">The session name.</param>
public ConnectionInfo(
string endpointUrl,
string serverName,
@@ -5,6 +5,10 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// </summary>
public sealed class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>Initializes a new instance of the ConnectionStateChangedEventArgs class.</summary>
/// <param name="oldState">The previous connection state.</param>
/// <param name="newState">The new connection state.</param>
/// <param name="endpointUrl">The endpoint URL associated with the state change.</param>
public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string endpointUrl)
{
OldState = oldState;
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// </summary>
public sealed class DataChangedEventArgs : EventArgs
{
/// <summary>Initializes a new instance of the DataChangedEventArgs class.</summary>
/// <param name="nodeId">The node ID that changed.</param>
/// <param name="value">The new data value.</param>
public DataChangedEventArgs(string nodeId, DataValue value)
{
NodeId = nodeId;
@@ -5,6 +5,11 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// </summary>
public sealed class RedundancyInfo
{
/// <summary>Initializes a new instance of the RedundancyInfo class.</summary>
/// <param name="mode">The redundancy mode (e.g., "None", "Cold", "Warm", "Hot").</param>
/// <param name="serviceLevel">The server's current service level (0-255).</param>
/// <param name="serverUris">URIs of all servers in the redundant set.</param>
/// <param name="applicationUri">The application URI of the connected server.</param>
public RedundancyInfo(string mode, byte serviceLevel, string[] serverUris, string applicationUri)
{
Mode = mode;
@@ -5,6 +5,8 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
/// </summary>
public sealed class OpcUaClientServiceFactory : IOpcUaClientServiceFactory
{
/// <summary>Creates a new OPC UA client service instance with production adapters.</summary>
/// <returns>A new OpcUaClientService instance.</returns>
public IOpcUaClientService Create()
{
return new OpcUaClientService();
@@ -10,11 +10,13 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI;
public class App : Application
{
/// <inheritdoc />
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
/// <inheritdoc />
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
@@ -32,35 +32,41 @@ public partial class DateTimeRangePicker : UserControl
private bool _isUpdating;
/// <summary>Initializes a new instance of the <see cref="DateTimeRangePicker"/> class.</summary>
public DateTimeRangePicker()
{
InitializeComponent();
}
/// <summary>Gets or sets the start date and time.</summary>
public DateTimeOffset? StartDateTime
{
get => GetValue(StartDateTimeProperty);
set => SetValue(StartDateTimeProperty, value);
}
/// <summary>Gets or sets the end date and time.</summary>
public DateTimeOffset? EndDateTime
{
get => GetValue(EndDateTimeProperty);
set => SetValue(EndDateTimeProperty, value);
}
/// <summary>Gets or sets the start date/time as formatted text.</summary>
public string StartText
{
get => GetValue(StartTextProperty);
set => SetValue(StartTextProperty, value);
}
/// <summary>Gets or sets the end date/time as formatted text.</summary>
public string EndText
{
get => GetValue(EndTextProperty);
set => SetValue(EndTextProperty, value);
}
/// <inheritdoc />
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
@@ -82,6 +88,7 @@ public partial class DateTimeRangePicker : UserControl
if (lastWeek != null) lastWeek.Click += (_, _) => ApplyPreset(TimeSpan.FromDays(7));
}
/// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Helpers;
/// </summary>
internal static class StatusCodeFormatter
{
/// <summary>Formats an OPC UA status code as a hexadecimal code with description.</summary>
/// <param name="statusCode">The OPC UA status code to format.</param>
/// <returns>A formatted string in the form "0xHEX (description)".</returns>
public static string Format(StatusCode statusCode)
{
var code = statusCode.Code;
@@ -7,6 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Helpers;
/// </summary>
internal static class ValueFormatter
{
/// <summary>Formats an OPC UA value for display, handling arrays and enumerables specially.</summary>
/// <param name="value">The value to format, or null.</param>
/// <returns>A string representation of the value suitable for display.</returns>
public static string Format(object? value)
{
if (value is null) return "(null)";
@@ -4,8 +4,11 @@ using ZB.MOM.WW.OtOpcUa.Client.Shared;
namespace ZB.MOM.WW.OtOpcUa.Client.UI;
/// <summary>Entry point for the OPC UA client UI application.</summary>
public class Program
{
/// <summary>Main entry point for the application.</summary>
/// <param name="args">Command-line arguments passed to the application.</param>
[STAThread]
public static void Main(string[] args)
{
@@ -21,6 +24,8 @@ public class Program
}
}
/// <summary>Builds the Avalonia AppBuilder with platform-specific configuration.</summary>
/// <returns>Configured AppBuilder for desktop lifetime.</returns>
public static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
@@ -7,6 +7,8 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary>
public sealed class AvaloniaUiDispatcher : IUiDispatcher
{
/// <summary>Posts an action to the Avalonia UI thread for execution.</summary>
/// <param name="action">The action to execute on the UI thread.</param>
public void Post(Action action)
{
Dispatcher.UIThread.Post(action);
@@ -5,6 +5,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary>
public interface ISettingsService
{
/// <summary>Loads user settings from persistent storage.</summary>
UserSettings Load();
/// <summary>Saves user settings to persistent storage.</summary>
/// <param name="settings">The settings to save.</param>
void Save(UserSettings settings);
}
@@ -8,5 +8,6 @@ public interface IUiDispatcher
/// <summary>
/// Posts an action to be executed on the UI thread.
/// </summary>
/// <param name="action">The action to execute on the UI thread.</param>
void Post(Action action);
}
@@ -19,6 +19,8 @@ public sealed class JsonSettingsService : ISettingsService
WriteIndented = true
};
/// <summary>Loads user settings from the settings file.</summary>
/// <returns>The loaded user settings, or a new default instance if load fails.</returns>
public UserSettings Load()
{
try
@@ -35,6 +37,8 @@ public sealed class JsonSettingsService : ISettingsService
}
}
/// <summary>Saves user settings to the settings file.</summary>
/// <param name="settings">The user settings to save.</param>
public void Save(UserSettings settings)
{
try
@@ -6,6 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary>
public sealed class SynchronousUiDispatcher : IUiDispatcher
{
/// <summary>Executes the action synchronously on the calling thread.</summary>
/// <param name="action">The action to execute.</param>
public void Post(Action action)
{
action();
@@ -44,6 +44,9 @@ public partial class AlarmsViewModel : ObservableObject
[ObservableProperty] private int _activeAlarmCount;
/// <summary>Initializes a new instance of the AlarmsViewModel class.</summary>
/// <param name="service">The OPC UA client service.</param>
/// <param name="dispatcher">The UI dispatcher for thread-safe operations.</param>
public AlarmsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
_service = service;
@@ -168,6 +171,9 @@ public partial class AlarmsViewModel : ObservableObject
/// <summary>
/// Acknowledges an alarm and returns (success, message).
/// </summary>
/// <param name="alarm">The alarm event to acknowledge.</param>
/// <param name="comment">Optional comment for the acknowledgment.</param>
/// <returns>A tuple with success flag and message.</returns>
public async Task<(bool Success, string Message)> AcknowledgeAlarmAsync(AlarmEventViewModel alarm, string comment)
{
if (!IsConnected || alarm.EventId == null || alarm.ConditionNodeId == null)
@@ -197,6 +203,8 @@ public partial class AlarmsViewModel : ObservableObject
/// <summary>
/// Restores an alarm subscription and requests a condition refresh.
/// </summary>
/// <param name="sourceNodeId">The source node ID to restore the subscription for.</param>
/// <returns>A task that completes when the restore operation finishes.</returns>
public async Task RestoreAlarmSubscriptionAsync(string? sourceNodeId)
{
if (!IsConnected || string.IsNullOrWhiteSpace(sourceNodeId)) return;
@@ -13,6 +13,11 @@ public class BrowseTreeViewModel : ObservableObject
private readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
/// <summary>
/// Initializes a new instance of the <see cref="BrowseTreeViewModel"/> class.
/// </summary>
/// <param name="service">The OPC UA client service.</param>
/// <param name="dispatcher">The UI dispatcher for marshaling updates.</param>
public BrowseTreeViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
_service = service;
@@ -7,6 +7,11 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
/// </summary>
public class HistoryValueViewModel : ObservableObject
{
/// <summary>Initializes a new instance of the <see cref="HistoryValueViewModel"/> class.</summary>
/// <param name="value">The historical value.</param>
/// <param name="status">The status code or text.</param>
/// <param name="sourceTimestamp">The source timestamp in string format.</param>
/// <param name="serverTimestamp">The server timestamp in string format.</param>
public HistoryValueViewModel(string value, string status, string sourceTimestamp, string serverTimestamp)
{
Value = value;
@@ -15,8 +20,12 @@ public class HistoryValueViewModel : ObservableObject
ServerTimestamp = serverTimestamp;
}
/// <summary>Gets the historical value.</summary>
public string Value { get; }
/// <summary>Gets the status code or text.</summary>
public string Status { get; }
/// <summary>Gets the source timestamp in string format.</summary>
public string SourceTimestamp { get; }
/// <summary>Gets the server timestamp in string format.</summary>
public string ServerTimestamp { get; }
}
@@ -34,6 +34,9 @@ public partial class HistoryViewModel : ObservableObject
[ObservableProperty] private DateTimeOffset? _startTime = DateTimeOffset.UtcNow.AddHours(-1);
/// <summary>Initializes a new instance of the HistoryViewModel.</summary>
/// <param name="service">The OPC UA client service.</param>
/// <param name="dispatcher">The UI dispatcher for thread marshalling.</param>
public HistoryViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
_service = service;
@@ -53,6 +56,7 @@ public partial class HistoryViewModel : ObservableObject
AggregateType.StandardDeviation
];
/// <summary>Gets a value indicating whether an aggregate read is selected.</summary>
public bool IsAggregateRead => SelectedAggregateType != null;
/// <summary>History read results.</summary>
@@ -36,12 +36,16 @@ public partial class ReadWriteViewModel : ObservableObject
[ObservableProperty] private string? _writeValue;
/// <summary>Initializes a new instance of the ReadWriteViewModel class.</summary>
/// <param name="service">The OPC UA client service for read/write operations.</param>
/// <param name="dispatcher">The UI dispatcher for posting updates to the UI thread.</param>
public ReadWriteViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
{
_service = service;
_dispatcher = dispatcher;
}
/// <summary>Gets a value indicating whether a node is currently selected.</summary>
public bool IsNodeSelected => !string.IsNullOrEmpty(SelectedNodeId);
partial void OnSelectedNodeIdChanged(string? value)
@@ -13,6 +13,9 @@ public partial class SubscriptionItemViewModel : ObservableObject
[ObservableProperty] private string? _value;
/// <summary>Initializes a new subscription item with the specified node ID and interval.</summary>
/// <param name="nodeId">The OPC UA NodeId to subscribe to.</param>
/// <param name="intervalMs">The subscription interval in milliseconds.</param>
public SubscriptionItemViewModel(string nodeId, int intervalMs)
{
NodeId = nodeId;
@@ -31,6 +31,13 @@ public partial class TreeNodeViewModel : ObservableObject
HasChildren = false;
}
/// <summary>Initializes a new tree node view model.</summary>
/// <param name="nodeId">The OPC UA node identifier.</param>
/// <param name="displayName">The display name for this node.</param>
/// <param name="nodeClass">The OPC UA node class.</param>
/// <param name="hasChildren">Whether this node has child nodes.</param>
/// <param name="service">The OPC UA client service for browsing.</param>
/// <param name="dispatcher">The UI dispatcher for thread-safe updates.</param>
public TreeNodeViewModel(
string nodeId,
string displayName,
@@ -10,6 +10,7 @@ public partial class AckAlarmWindow : Window
private readonly AlarmsViewModel _alarmsVm;
private readonly AlarmEventViewModel _alarm;
/// <summary>Initializes a new instance of the AckAlarmWindow class for XAML designer support.</summary>
public AckAlarmWindow()
{
InitializeComponent();
@@ -17,6 +18,9 @@ public partial class AckAlarmWindow : Window
_alarm = null!;
}
/// <summary>Initializes a new instance of the AckAlarmWindow class with alarm context.</summary>
/// <param name="alarmsVm">The alarms view model.</param>
/// <param name="alarm">The alarm event to acknowledge.</param>
public AckAlarmWindow(AlarmsViewModel alarmsVm, AlarmEventViewModel alarm)
{
InitializeComponent();
@@ -16,11 +16,13 @@ public partial class AlarmsView : UserControl
private static readonly IBrush HighBrush = new SolidColorBrush(Color.Parse("#FEE2E2")); // light red (666-899)
private static readonly IBrush CriticalBrush = new SolidColorBrush(Color.Parse("#FECACA")); // red (900-1000)
/// <summary>Initializes a new instance of the <see cref="AlarmsView"/> class.</summary>
public AlarmsView()
{
InitializeComponent();
}
/// <inheritdoc />
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Views;
public partial class BrowseTreeView : UserControl
{
/// <summary>Initializes a new instance of the BrowseTreeView.</summary>
public BrowseTreeView()
{
InitializeComponent();
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Views;
public partial class HistoryView : UserControl
{
/// <summary>Initializes a new instance of the HistoryView control.</summary>
public HistoryView()
{
InitializeComponent();
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Views;
public partial class MainWindow : Window
{
/// <summary>Initializes a new instance of the MainWindow, loading the application icon.</summary>
public MainWindow()
{
InitializeComponent();
@@ -51,6 +52,7 @@ public partial class MainWindow : Window
}
}
/// <inheritdoc />
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
@@ -157,6 +159,7 @@ public partial class MainWindow : Window
vm.CertificateStorePath = picked;
}
/// <inheritdoc />
protected override void OnClosing(WindowClosingEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
@@ -4,6 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Views;
public partial class ReadWriteView : UserControl
{
/// <summary>Initializes a new instance of the ReadWriteView class.</summary>
public ReadWriteView()
{
InitializeComponent();
@@ -8,11 +8,13 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Views;
public partial class SubscriptionsView : UserControl
{
/// <summary>Initializes a new instance of the <see cref="SubscriptionsView"/> class.</summary>
public SubscriptionsView()
{
InitializeComponent();
}
/// <inheritdoc />
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
@@ -10,6 +10,7 @@ public partial class WriteValueWindow : Window
private readonly SubscriptionsViewModel _subscriptionsVm;
private readonly string _nodeId;
/// <summary>Initializes a default instance of the WriteValueWindow for XAML designer support.</summary>
public WriteValueWindow()
{
InitializeComponent();
@@ -17,6 +18,10 @@ public partial class WriteValueWindow : Window
_nodeId = string.Empty;
}
/// <summary>Initializes a WriteValueWindow with the node to write and its current value.</summary>
/// <param name="subscriptionsVm">The subscriptions view model for write operations.</param>
/// <param name="nodeId">The OPC UA node ID to write to.</param>
/// <param name="currentValue">The current value of the node, or null if unknown.</param>
public WriteValueWindow(SubscriptionsViewModel subscriptionsVm, string nodeId, string? currentValue)
{
InitializeComponent();
@@ -4,8 +4,13 @@ public sealed class AkkaClusterOptions
{
public const string SectionName = "Cluster";
/// <summary>Gets or sets the Akka system name.</summary>
public string SystemName { get; set; } = "otopcua";
/// <summary>Gets or sets the hostname to bind to (default 0.0.0.0).</summary>
public string Hostname { get; set; } = "0.0.0.0";
/// <summary>Gets or sets the port to listen on (default 4053).</summary>
public int Port { get; set; } = 4053;
/// <summary>
@@ -15,6 +20,7 @@ public sealed class AkkaClusterOptions
/// </summary>
public string PublicHostname { get; set; } = "127.0.0.1";
/// <summary>Gets or sets the seed nodes for cluster bootstrapping.</summary>
public string[] SeedNodes { get; set; } = Array.Empty<string>();
/// <summary>
@@ -25,6 +25,10 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
private readonly Dictionary<string, HashSet<Member>> _membersByRole = new(StringComparer.Ordinal);
private IActorRef? _subscriber;
/// <summary>Initializes a new instance of the ClusterRoleInfo class.</summary>
/// <param name="system">The Akka actor system.</param>
/// <param name="options">The cluster configuration options.</param>
/// <param name="logger">The logger instance.</param>
public ClusterRoleInfo(ActorSystem system, IOptions<AkkaClusterOptions> options, ILogger<ClusterRoleInfo> logger)
{
_cluster = Akka.Cluster.Cluster.Get(system);
@@ -39,12 +43,20 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
}
/// <summary>Gets the local cluster node identifier.</summary>
public CommonsNodeId LocalNode => _localNode;
/// <summary>Gets the set of roles assigned to the local node.</summary>
public IReadOnlySet<string> LocalRoles => _localRoles;
/// <summary>Checks if the local node has a specific role.</summary>
/// <param name="role">The role name to check.</param>
/// <returns>True if the local node has the specified role; otherwise false.</returns>
public bool HasRole(string role) => _localRoles.Contains(role);
/// <summary>Gets all cluster members that have a specific role.</summary>
/// <param name="role">The role name.</param>
/// <returns>A read-only list of node IDs with the specified role.</returns>
public IReadOnlyList<CommonsNodeId> MembersWithRole(string role)
{
lock (_lock)
@@ -56,6 +68,9 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
}
}
/// <summary>Gets the current leader node for a specific role.</summary>
/// <param name="role">The role name.</param>
/// <returns>The node ID of the current role leader, or null if no leader is elected.</returns>
public CommonsNodeId? RoleLeader(string role)
{
lock (_lock)
@@ -66,6 +81,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
}
}
/// <summary>Occurs when the leader for a role changes.</summary>
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged;
private void SeedFromCurrentState()
@@ -91,6 +107,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
}
}
/// <summary>Handles a cluster member event (member up/removed).</summary>
/// <param name="evt">The member event from the cluster.</param>
internal void HandleMemberEvent(ClusterEvent.IMemberEvent evt)
{
lock (_lock)
@@ -114,6 +132,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
}
}
/// <summary>Handles a role leader change event.</summary>
/// <param name="evt">The role leader changed event from the cluster.</param>
internal void HandleRoleLeaderChanged(ClusterEvent.RoleLeaderChanged evt)
{
CommonsNodeId? previous = null;
@@ -156,6 +176,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
private static CommonsNodeId ToNodeId(Akka.Actor.Address address) =>
CommonsNodeId.Parse($"{address.Host ?? string.Empty}:{address.Port ?? 0}");
/// <summary>Disposes the ClusterRoleInfo and stops the subscriber actor.</summary>
public void Dispose()
{
_subscriber?.Tell(PoisonPill.Instance);
@@ -164,6 +185,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
private sealed class SubscriberActor : ReceiveActor
{
/// <summary>Initializes a new instance of the SubscriberActor class.</summary>
/// <param name="owner">The ClusterRoleInfo instance to forward events to.</param>
public SubscriberActor(ClusterRoleInfo owner)
{
Receive<ClusterEvent.IMemberEvent>(e => owner.HandleMemberEvent(e));
@@ -172,6 +195,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
Receive<ClusterEvent.CurrentClusterState>(_ => { /* seeded from initial snapshot */ });
}
/// <inheritdoc />
protected override void PreStart()
{
Akka.Cluster.Cluster.Get(Context.System).Subscribe(
@@ -182,6 +206,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
typeof(ClusterEvent.RoleLeaderChanged));
}
/// <inheritdoc />
protected override void PostStop() =>
Akka.Cluster.Cluster.Get(Context.System).Unsubscribe(Self);
}
@@ -1,9 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>Loads embedded HOCON configuration resources.</summary>
public static class HoconLoader
{
private const string ResourceName = "ZB.MOM.WW.OtOpcUa.Cluster.Resources.akka.conf";
/// <summary>Loads the base Akka configuration from embedded resources.</summary>
/// <returns>The loaded HOCON configuration as a string.</returns>
public static string LoadBaseConfig()
{
using var stream = typeof(HoconLoader).Assembly.GetManifestResourceStream(ResourceName)
@@ -7,6 +7,8 @@ public static class RoleParser
"admin", "driver", "dev",
};
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
/// <param name="raw">The raw role string to parse.</param>
public static string[] Parse(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
@@ -16,6 +16,8 @@ public static class ServiceCollectionExtensions
/// configurator via <see cref="WithOtOpcUaClusterBootstrap"/> — keeping the entire Akka graph
/// under Akka.Hosting's management so cluster singletons land on the same ActorSystem.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="configuration">The application configuration containing cluster options.</param>
public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<AkkaClusterOptions>()
@@ -41,6 +43,8 @@ public static class ServiceCollectionExtensions
/// });
/// </code>
/// </summary>
/// <param name="builder">The Akka configuration builder to configure.</param>
/// <param name="serviceProvider">The service provider for resolving cluster options.</param>
public static AkkaConfigurationBuilder WithOtOpcUaClusterBootstrap(
this AkkaConfigurationBuilder builder,
IServiceProvider serviceProvider)
@@ -10,7 +10,14 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
/// </summary>
public interface IAlarmActorStateStore
{
/// <summary>Loads the persisted state snapshot for an alarm actor.</summary>
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The alarm state snapshot if found; null if the alarm has no persisted state.</returns>
Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct);
/// <summary>Saves the alarm actor state snapshot.</summary>
/// <param name="snapshot">The state snapshot to persist.</param>
/// <param name="ct">Cancellation token.</param>
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
}
@@ -34,8 +41,14 @@ public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
{
public static readonly NullAlarmActorStateStore Instance = new();
private NullAlarmActorStateStore() { }
/// <summary>Always returns null, indicating no persisted state.</summary>
/// <param name="alarmId">The alarm identifier (unused).</param>
/// <param name="ct">Cancellation token (unused).</param>
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
Task.FromResult<AlarmActorStateSnapshot?>(null);
/// <summary>Completes immediately without persisting anything.</summary>
/// <param name="snapshot">The state snapshot (ignored).</param>
/// <param name="ct">Cancellation token (unused).</param>
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
Task.CompletedTask;
}
@@ -8,6 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
/// </summary>
public interface IScriptedAlarmEvaluator
{
/// <summary>Evaluates an alarm predicate against the provided dependencies.</summary>
/// <param name="alarmId">The unique identifier of the alarm being evaluated.</param>
/// <param name="predicate">The predicate expression to evaluate.</param>
/// <param name="dependencies">Read-only dictionary of variable names to values for predicate evaluation.</param>
/// <returns>Result containing success flag, alarm active state, and optional failure reason.</returns>
ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies);
}
@@ -15,7 +20,14 @@ public interface IScriptedAlarmEvaluator
/// <c>Success</c> is true; on failure the caller should keep the prior state and log Reason.</summary>
public sealed record ScriptedAlarmEvalResult(bool Success, bool Active, string? Reason)
{
/// <summary>Creates a successful alarm evaluation result with the given active state.</summary>
/// <param name="active">Whether the alarm condition is active.</param>
/// <returns>A successful evaluation result.</returns>
public static ScriptedAlarmEvalResult Ok(bool active) => new(true, active, null);
/// <summary>Creates a failed alarm evaluation result with the given reason.</summary>
/// <param name="reason">Description of the evaluation failure cause.</param>
/// <returns>A failed evaluation result.</returns>
public static ScriptedAlarmEvalResult Failure(string reason) => new(false, false, reason);
}
@@ -25,6 +37,11 @@ public sealed class NullScriptedAlarmEvaluator : IScriptedAlarmEvaluator
{
public static readonly NullScriptedAlarmEvaluator Instance = new();
private NullScriptedAlarmEvaluator() { }
/// <summary>Returns an inactive alarm result for every evaluation (safe no-op behavior).</summary>
/// <param name="alarmId">The alarm identifier (ignored).</param>
/// <param name="predicate">The predicate expression (ignored).</param>
/// <param name="dependencies">The variable dependencies (ignored).</param>
/// <returns>Always returns an inactive alarm result.</returns>
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
=> ScriptedAlarmEvalResult.Ok(active: false);
}
@@ -13,6 +13,10 @@ public interface IVirtualTagEvaluator
/// <paramref name="dependencies"/>. Implementations must not throw — script failures
/// are reported via <see cref="VirtualTagEvalResult.Failure"/>.
/// </summary>
/// <param name="virtualTagId">The unique identifier of the virtual tag being evaluated.</param>
/// <param name="expression">The expression string to evaluate.</param>
/// <param name="dependencies">Read-only dictionary of variable names to values for expression evaluation.</param>
/// <returns>Result containing success flag, evaluated value, and optional failure reason.</returns>
VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies);
}
@@ -21,7 +25,15 @@ public interface IVirtualTagEvaluator
public sealed record VirtualTagEvalResult(bool Success, object? Value, string? Reason)
{
public static readonly VirtualTagEvalResult NoChange = new(true, null, "no-change");
/// <summary>Creates a successful evaluation result with the given value.</summary>
/// <param name="value">The evaluated value.</param>
/// <returns>A successful evaluation result.</returns>
public static VirtualTagEvalResult Ok(object? value) => new(true, value, null);
/// <summary>Creates a failed evaluation result with the given reason.</summary>
/// <param name="reason">Description of the failure cause.</param>
/// <returns>A failed evaluation result.</returns>
public static VirtualTagEvalResult Failure(string reason) => new(false, null, reason);
}
@@ -31,6 +43,11 @@ public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
{
public static readonly NullVirtualTagEvaluator Instance = new();
private NullVirtualTagEvaluator() { }
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> for every evaluation.</summary>
/// <param name="virtualTagId">The virtual tag identifier (ignored).</param>
/// <param name="expression">The expression string (ignored).</param>
/// <param name="dependencies">The variable dependencies (ignored).</param>
/// <returns>Always returns <see cref="VirtualTagEvalResult.NoChange"/>.</returns>
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> VirtualTagEvalResult.NoChange;
}
@@ -9,5 +9,9 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
/// </summary>
public interface IAdminOperationsClient
{
/// <summary>Starts a new deployment on the cluster-singleton admin operations actor.</summary>
/// <param name="createdBy">The user or system identifier triggering the deployment.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation containing the deployment start result.</returns>
Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct);
}
@@ -10,11 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
/// </summary>
public interface IClusterRoleInfo
{
/// <summary>Gets the local cluster node identifier.</summary>
NodeId LocalNode { get; }
/// <summary>Gets the set of roles assigned to the local node.</summary>
IReadOnlySet<string> LocalRoles { get; }
/// <summary>Checks if the local node has the specified role.</summary>
/// <param name="role">Role name to check.</param>
/// <returns>True if the local node has the role; otherwise, false.</returns>
bool HasRole(string role);
/// <summary>Gets all nodes assigned to the specified role.</summary>
/// <param name="role">Role name to query.</param>
/// <returns>List of node identifiers with the role.</returns>
IReadOnlyList<NodeId> MembersWithRole(string role);
/// <summary>Gets the leader node for the specified role, or null if no leader is elected.</summary>
/// <param name="role">Role name to query.</param>
/// <returns>The leader node identifier, or null if no leader exists.</returns>
NodeId? RoleLeader(string role);
/// <summary>Occurs when the leader of a role changes.</summary>
event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged;
}
@@ -8,5 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
/// </summary>
public interface IFleetDiagnosticsClient
{
/// <summary>Gets diagnostics for the specified node.</summary>
/// <param name="nodeId">The node ID to retrieve diagnostics for.</param>
/// <param name="ct">The cancellation token.</param>
Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct);
}
@@ -2,9 +2,15 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
/// <summary>Event arguments for role leader change notifications.</summary>
public sealed class RoleLeaderChangedEventArgs : EventArgs
{
/// <summary>Gets the role name that changed leadership.</summary>
public required string Role { get; init; }
/// <summary>Gets the previous leader node ID, or null if there was no previous leader.</summary>
public required NodeId? PreviousLeader { get; init; }
/// <summary>Gets the new leader node ID, or null if the role is now leaderless.</summary>
public required NodeId? NewLeader { get; init; }
}
@@ -68,6 +68,7 @@ public static class OtOpcUaTelemetry
/// Starts a deploy span tagged with the deployment id. Caller disposes to close. Returns
/// null when no listener is attached so the call site stays cheap on undecorated builds.
/// </summary>
/// <param name="deploymentId">The deployment identifier to tag the span with.</param>
public static Activity? StartDeployApplySpan(string deploymentId)
{
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
@@ -18,17 +18,41 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
/// <summary>Swap in the production sink. Pass <c>null</c> to revert to the null sink
/// (used during graceful shutdown so post-stop writes don't hit a half-disposed manager).</summary>
/// <param name="sink">The sink implementation to use, or null to use the null sink.</param>
public void SetSink(IOpcUaAddressSpaceSink? sink) =>
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
/// <summary>Writes a value to the OPC UA address space through the inner sink.</summary>
/// <param name="nodeId">The node ID of the variable.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
/// <summary>Writes an alarm state through the inner sink.</summary>
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
/// <summary>Ensures a folder exists in the address space through the inner sink.</summary>
/// <param name="folderNodeId">The node ID of the folder.</param>
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
/// <summary>Ensures a variable exists in the address space through the inner sink.</summary>
/// <param name="variableNodeId">The node ID of the variable.</param>
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA data type of the variable.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
/// <summary>Rebuilds the address space through the inner sink.</summary>
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
}
@@ -12,8 +12,11 @@ public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
/// <summary>Swap the underlying publisher. Pass null to revert to the Null no-op.</summary>
/// <param name="inner">The publisher implementation to use, or null to use the null publisher.</param>
public void SetInner(IServiceLevelPublisher? inner) =>
_inner = inner ?? NullServiceLevelPublisher.Instance;
/// <summary>Publishes a service level value to the inner publisher.</summary>
/// <param name="serviceLevel">The service level to publish.</param>
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
}
@@ -9,9 +9,17 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
public interface IOpcUaAddressSpaceSink
{
/// <summary>Write a Variable node's current value + quality + source timestamp.</summary>
/// <param name="nodeId">The OPC UA node ID of the variable.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The quality status of the value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
/// <param name="alarmNodeId">The OPC UA node ID of the alarm.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
/// <summary>
@@ -20,8 +28,24 @@ public interface IOpcUaAddressSpaceSink
/// <paramref name="parentNodeId"/> is null the folder is parented under the namespace
/// root. Idempotent: calling twice with the same id is safe.
/// </summary>
/// <param name="folderNodeId">The OPC UA node ID for the folder.</param>
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
/// <param name="displayName">The display name for the folder.</param>
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
/// <summary>
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
/// <paramref name="parentFolderNodeId"/> (or the namespace root when null). Created with
/// Bad quality + null value; subsequent <see cref="WriteValue"/> calls update both.
/// Used by <c>Phase7Applier</c> to materialise Galaxy / SystemPlatform tags ahead of any
/// driver-side subscribe so OPC UA clients can browse them. Idempotent.
/// </summary>
/// <param name="variableNodeId">The OPC UA node ID for the variable.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, or null for namespace root.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.).</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType);
/// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
/// successful deployment apply so the node manager reflects the new config. Idempotent.
@@ -39,8 +63,19 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
{
public static readonly NullOpcUaAddressSpaceSink Instance = new();
private NullOpcUaAddressSpaceSink() { }
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <inheritdoc />
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <inheritdoc />
public void RebuildAddressSpace() { }
}
@@ -8,6 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// </summary>
public interface IServiceLevelPublisher
{
/// <summary>Publishes the service level value to the OPC UA Server object.</summary>
/// <param name="serviceLevel">The service level value (0-255).</param>
void Publish(byte serviceLevel);
}
@@ -17,6 +19,11 @@ public sealed class NullServiceLevelPublisher : IServiceLevelPublisher
{
public static readonly NullServiceLevelPublisher Instance = new();
private NullServiceLevelPublisher() { }
/// <summary>Gets the last published service level value.</summary>
public byte LastPublished { get; private set; }
/// <summary>Records the service level value without publishing.</summary>
/// <param name="serviceLevel">The service level value (0-255).</param>
public void Publish(byte serviceLevel) => LastPublished = serviceLevel;
}
@@ -2,9 +2,16 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
public readonly record struct CorrelationId(Guid Value)
{
/// <summary>Creates a new CorrelationId with a randomly generated GUID.</summary>
public static CorrelationId NewId() => new(Guid.NewGuid());
/// <inheritdoc />
public override string ToString() => Value.ToString("N");
/// <summary>Parses a lowercase hex string without hyphens into a CorrelationId.</summary>
/// <param name="s">The string to parse.</param>
public static CorrelationId Parse(string s) => new(Guid.ParseExact(s, "N"));
/// <summary>Attempts to parse a lowercase hex string without hyphens into a CorrelationId.</summary>
/// <param name="s">The string to parse, or null.</param>
/// <param name="id">The resulting CorrelationId if parsing succeeds.</param>
public static bool TryParse(string? s, out CorrelationId id)
{
if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; }
@@ -2,9 +2,22 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
public readonly record struct DeploymentId(Guid Value)
{
/// <summary>Creates a new deployment ID with a random GUID.</summary>
/// <returns>A new DeploymentId.</returns>
public static DeploymentId NewId() => new(Guid.NewGuid());
/// <inheritdoc />
public override string ToString() => Value.ToString("N");
/// <summary>Parses a deployment ID from a hex string without hyphens.</summary>
/// <param name="s">The hex string to parse.</param>
/// <returns>The parsed DeploymentId.</returns>
public static DeploymentId Parse(string s) => new(Guid.ParseExact(s, "N"));
/// <summary>Attempts to parse a deployment ID from a hex string without hyphens.</summary>
/// <param name="s">The hex string to parse, or null.</param>
/// <param name="id">The parsed DeploymentId if successful, or default.</param>
/// <returns>True if parsing succeeded; false otherwise.</returns>
public static bool TryParse(string? s, out DeploymentId id)
{
if (Guid.TryParseExact(s, "N", out var g)) { id = new DeploymentId(g); return true; }
@@ -2,9 +2,22 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
public readonly record struct ExecutionId(Guid Value)
{
/// <summary>Creates a new execution ID with a randomly generated GUID.</summary>
/// <returns>A new ExecutionId instance.</returns>
public static ExecutionId NewId() => new(Guid.NewGuid());
/// <inheritdoc />
public override string ToString() => Value.ToString("N");
/// <summary>Parses the specified string into an ExecutionId in format N.</summary>
/// <param name="s">The string to parse.</param>
/// <returns>The parsed ExecutionId.</returns>
public static ExecutionId Parse(string s) => new(Guid.ParseExact(s, "N"));
/// <summary>Tries to parse the specified string into an ExecutionId in format N.</summary>
/// <param name="s">The string to parse, or null.</param>
/// <param name="id">The parsed ExecutionId, or default if parsing fails.</param>
/// <returns>true if parsing succeeded; otherwise, false.</returns>
public static bool TryParse(string? s, out ExecutionId id)
{
if (Guid.TryParseExact(s, "N", out var g)) { id = new ExecutionId(g); return true; }
@@ -7,11 +7,22 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// </summary>
public readonly record struct NodeId(string Value)
{
/// <inheritdoc />
public override string ToString() => Value;
/// <summary>Parses a string into a NodeId.</summary>
/// <param name="s">The string to parse.</param>
/// <returns>A new NodeId instance.</returns>
/// <exception cref="ArgumentException">Thrown when the string is null, empty, or whitespace.</exception>
public static NodeId Parse(string s) =>
string.IsNullOrWhiteSpace(s)
? throw new ArgumentException("NodeId value cannot be empty.", nameof(s))
: new NodeId(s);
/// <summary>Attempts to parse a string into a NodeId.</summary>
/// <param name="s">The string to parse.</param>
/// <param name="id">The parsed NodeId if successful.</param>
/// <returns>True if the parse succeeded; otherwise false.</returns>
public static bool TryParse(string? s, out NodeId id)
{
if (!string.IsNullOrWhiteSpace(s)) { id = new NodeId(s); return true; }
@@ -6,11 +6,24 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// </summary>
public readonly record struct RevisionHash(string Value)
{
/// <inheritdoc />
public override string ToString() => Value;
/// <summary>
/// Parses a string into a <see cref="RevisionHash"/>.
/// </summary>
/// <param name="s">The string to parse.</param>
/// <returns>A <see cref="RevisionHash"/> instance.</returns>
/// <exception cref="ArgumentException">Thrown if the input is null, empty, or whitespace.</exception>
public static RevisionHash Parse(string s) =>
string.IsNullOrWhiteSpace(s)
? throw new ArgumentException("RevisionHash value cannot be empty.", nameof(s))
: new RevisionHash(s);
/// <summary>
/// Attempts to parse a string into a <see cref="RevisionHash"/>.
/// </summary>
/// <param name="s">The string to parse.</param>
/// <param name="hash">The parsed hash, or default if parsing fails.</param>
/// <returns>True if parsing succeeded; otherwise false.</returns>
public static bool TryParse(string? s, out RevisionHash hash)
{
if (!string.IsNullOrWhiteSpace(s)) { hash = new RevisionHash(s); return true; }
@@ -17,6 +17,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration;
/// </remarks>
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Creates a new DbContext instance for design-time operations.</summary>
/// <param name="args">Command-line arguments (unused).</param>
/// <returns>The configured DbContext instance.</returns>
public OtOpcUaConfigDbContext CreateDbContext(string[] args)
{
var connection = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_CONNECTION");
@@ -6,13 +6,16 @@ public sealed class ClusterNode
/// <summary>Stable per-machine logical ID, e.g. "LINE3-OPCUA-A".</summary>
public required string NodeId { get; set; }
/// <summary>The unique identifier of the cluster this node belongs to.</summary>
public required string ClusterId { get; set; }
/// <summary>Machine hostname / IP.</summary>
public required string Host { get; set; }
/// <summary>The OPC UA server port (default 4840).</summary>
public int OpcUaPort { get; set; } = 4840;
/// <summary>The dashboard HTTP port (default 8081).</summary>
public int DashboardPort { get; set; } = 8081;
/// <summary>
@@ -32,15 +35,21 @@ public sealed class ClusterNode
/// </summary>
public string? DriverConfigOverridesJson { get; set; }
/// <summary>Gets or sets a value indicating whether this node is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Gets or sets the timestamp when this node was last seen.</summary>
public DateTime? LastSeenAt { get; set; }
/// <summary>Gets or sets the timestamp when this node was created.</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Gets or sets the username of who created this node.</summary>
public required string CreatedBy { get; set; }
// Navigation
/// <summary>Gets or sets the cluster this node belongs to.</summary>
public ServerCluster? Cluster { get; set; }
/// <summary>Gets or sets the credentials associated with this node.</summary>
public ICollection<ClusterNodeCredential> Credentials { get; set; } = [];
}
@@ -8,22 +8,30 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class ClusterNodeCredential
{
/// <summary>Gets or sets the credential identifier.</summary>
public Guid CredentialId { get; set; }
/// <summary>Gets or sets the node identifier this credential binds to.</summary>
public required string NodeId { get; set; }
/// <summary>Gets or sets the credential kind (login, certificate, etc.).</summary>
public required CredentialKind Kind { get; set; }
/// <summary>Login name / cert thumbprint / SID / gMSA name.</summary>
/// <summary>Gets or sets the credential value (login name / cert thumbprint / SID / gMSA name).</summary>
public required string Value { get; set; }
/// <summary>Gets or sets a value indicating whether the credential is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Gets or sets the date/time when the credential was last rotated.</summary>
public DateTime? RotatedAt { get; set; }
/// <summary>Gets or sets the date/time when the credential was created.</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Gets or sets the user who created the credential.</summary>
public required string CreatedBy { get; set; }
/// <summary>Gets or sets the related cluster node.</summary>
public ClusterNode? Node { get; set; }
}
@@ -6,21 +6,28 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class ConfigAuditLog
{
/// <summary>Gets or sets the unique audit log identifier.</summary>
public long AuditId { get; set; }
/// <summary>Gets or sets the timestamp of the audit event.</summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>Gets or sets the principal (user or service) that initiated the event.</summary>
public required string Principal { get; set; }
/// <summary>DraftCreated | DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled | ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt | OpcUaAccessDenied | …</summary>
public required string EventType { get; set; }
/// <summary>Gets or sets the cluster identifier associated with the event, if applicable.</summary>
public string? ClusterId { get; set; }
/// <summary>Gets or sets the node identifier associated with the event, if applicable.</summary>
public string? NodeId { get; set; }
/// <summary>Gets or sets the generation identifier associated with the event, if applicable.</summary>
public long? GenerationId { get; set; }
/// <summary>Gets or sets additional event details in JSON format.</summary>
public string? DetailsJson { get; set; }
/// <summary>
@@ -7,21 +7,27 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class ConfigEdit
{
/// <summary>Gets the unique identifier for this edit.</summary>
public Guid EditId { get; init; } = Guid.NewGuid();
/// <summary>Gets the type of entity that was edited.</summary>
public required string EntityType { get; init; }
/// <summary>Gets the identifier of the entity that was edited.</summary>
public Guid EntityId { get; init; }
/// <summary>JSON payload of the column-name → new-value pairs touched by this edit.</summary>
/// <summary>Gets the JSON payload of the column-name → new-value pairs touched by this edit.</summary>
public required string FieldsJson { get; init; }
/// <summary>Optional correlation across edits inside a single admin operation.</summary>
/// <summary>Gets the optional correlation identifier across edits inside a single admin operation.</summary>
public Guid? ExecutionId { get; init; }
/// <summary>Gets the username of the user who performed the edit.</summary>
public required string EditedBy { get; init; }
/// <summary>Gets the UTC timestamp when the edit was performed.</summary>
public DateTime EditedAtUtc { get; init; } = DateTime.UtcNow;
/// <summary>Gets the node identifier of the admin instance that performed the edit.</summary>
public required string SourceNode { get; init; }
}
@@ -10,21 +10,30 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class Deployment
{
/// <summary>Gets or sets the unique deployment identifier.</summary>
public Guid DeploymentId { get; init; } = Guid.NewGuid();
/// <summary>Gets or sets the revision hash of the deployment artifact.</summary>
public required string RevisionHash { get; init; }
/// <summary>Gets or sets the deployment status.</summary>
public DeploymentStatus Status { get; set; } = DeploymentStatus.Dispatching;
/// <summary>Gets or sets the name of the user who created the deployment.</summary>
public required string CreatedBy { get; init; }
/// <summary>Gets or sets the UTC timestamp when the deployment was created.</summary>
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
/// <summary>Gets or sets the serialized artifact blob containing the configuration.</summary>
public byte[] ArtifactBlob { get; init; } = Array.Empty<byte>();
/// <summary>Gets or sets the row version for optimistic concurrency control.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
/// <summary>Gets or sets the failure reason if the deployment failed.</summary>
public string? FailureReason { get; set; }
/// <summary>Gets or sets the UTC timestamp when the deployment was sealed.</summary>
public DateTime? SealedAtUtc { get; set; }
}
@@ -3,15 +3,27 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers.</summary>
public sealed class Device
{
/// <summary>
/// Gets or sets the unique database row identifier for the device.
/// </summary>
public Guid DeviceRowId { get; set; }
/// <summary>
/// Gets or sets the device identifier.
/// </summary>
public required string DeviceId { get; set; }
/// <summary>Logical FK to <see cref="DriverInstance.DriverInstanceId"/>.</summary>
public required string DriverInstanceId { get; set; }
/// <summary>
/// Gets or sets the device name.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the device is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>Schemaless per-driver-type device config (host, port, unit ID, slot, etc.).</summary>
@@ -39,6 +39,7 @@ public sealed class DriverHostStatus
/// </summary>
public required string HostName { get; set; }
/// <summary>Gets or sets the current connectivity state of the host.</summary>
public DriverHostState State { get; set; } = DriverHostState.Unknown;
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
@@ -3,10 +3,13 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>One driver instance in a cluster's generation. JSON config is schemaless per-driver-type.</summary>
public sealed class DriverInstance
{
/// <summary>Gets or sets the row ID for this driver instance.</summary>
public Guid DriverInstanceRowId { get; set; }
/// <summary>Gets or sets the unique driver instance identifier.</summary>
public required string DriverInstanceId { get; set; }
/// <summary>Gets or sets the cluster ID this driver instance belongs to.</summary>
public required string ClusterId { get; set; }
/// <summary>
@@ -15,11 +18,13 @@ public sealed class DriverInstance
/// </summary>
public required string NamespaceId { get; set; }
/// <summary>Gets or sets the friendly name of this driver instance.</summary>
public required string Name { get; set; }
/// <summary>Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient</summary>
public required string DriverType { get; set; }
/// <summary>Gets or sets a value indicating whether this driver instance is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
@@ -46,5 +51,6 @@ public sealed class DriverInstance
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
/// <summary>Gets or sets the related server cluster for navigation.</summary>
public ServerCluster? Cluster { get; set; }
}
@@ -15,7 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </remarks>
public sealed class DriverInstanceResilienceStatus
{
/// <summary>Gets or sets the driver instance identifier.</summary>
public required string DriverInstanceId { get; set; }
/// <summary>Gets or sets the host name.</summary>
public required string HostName { get; set; }
/// <summary>Most recent time the circuit breaker for this (instance, host) opened; null if never.</summary>
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class Equipment
{
/// <summary>Gets or sets the row identifier for this equipment.</summary>
public Guid EquipmentRowId { get; set; }
/// <summary>
@@ -43,19 +44,29 @@ public sealed class Equipment
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
// All nullable so equipment can be added before identity is fully captured.
/// <summary>Gets or sets the manufacturer name for this equipment.</summary>
public string? Manufacturer { get; set; }
/// <summary>Gets or sets the model number or designation for this equipment.</summary>
public string? Model { get; set; }
/// <summary>Gets or sets the serial number for this equipment.</summary>
public string? SerialNumber { get; set; }
/// <summary>Gets or sets the hardware revision level for this equipment.</summary>
public string? HardwareRevision { get; set; }
/// <summary>Gets or sets the software revision level for this equipment.</summary>
public string? SoftwareRevision { get; set; }
/// <summary>Gets or sets the year of construction for this equipment.</summary>
public short? YearOfConstruction { get; set; }
/// <summary>Gets or sets the asset location information for this equipment.</summary>
public string? AssetLocation { get; set; }
/// <summary>Gets or sets the manufacturer URI for this equipment.</summary>
public string? ManufacturerUri { get; set; }
/// <summary>Gets or sets the device manual URI for this equipment.</summary>
public string? DeviceManualUri { get; set; }
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
public string? EquipmentClassRef { get; set; }
/// <summary>Gets or sets whether this equipment is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
@@ -17,15 +17,31 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </remarks>
public sealed class EquipmentImportBatch
{
/// <summary>Gets or sets the unique identifier for this batch.</summary>
public Guid Id { get; set; }
/// <summary>Gets or sets the cluster identifier.</summary>
public required string ClusterId { get; set; }
/// <summary>Gets or sets the user name who created this batch.</summary>
public required string CreatedBy { get; set; }
/// <summary>Gets or sets the UTC timestamp when this batch was created.</summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>Gets or sets the total number of rows staged in this batch.</summary>
public int RowsStaged { get; set; }
/// <summary>Gets or sets the number of rows accepted in this batch.</summary>
public int RowsAccepted { get; set; }
/// <summary>Gets or sets the number of rows rejected in this batch.</summary>
public int RowsRejected { get; set; }
/// <summary>Gets or sets the UTC timestamp when this batch was finalised, or null if still in staging.</summary>
public DateTime? FinalisedAtUtc { get; set; }
/// <summary>Gets or sets the collection of staged rows in this batch.</summary>
public ICollection<EquipmentImportRow> Rows { get; set; } = [];
}
@@ -37,32 +53,74 @@ public sealed class EquipmentImportBatch
/// </summary>
public sealed class EquipmentImportRow
{
/// <summary>Gets or sets the unique identifier for this row.</summary>
public Guid Id { get; set; }
/// <summary>Gets or sets the parent batch identifier.</summary>
public Guid BatchId { get; set; }
/// <summary>Gets or sets the line number in the source file.</summary>
public int LineNumberInFile { get; set; }
/// <summary>Gets or sets a value indicating whether this row was accepted.</summary>
public bool IsAccepted { get; set; }
/// <summary>Gets or sets the reason this row was rejected, if applicable.</summary>
public string? RejectReason { get; set; }
// Required (decision #117)
/// <summary>Gets or sets the Z tag identifier.</summary>
public required string ZTag { get; set; }
/// <summary>Gets or sets the machine code.</summary>
public required string MachineCode { get; set; }
/// <summary>Gets or sets the SAP identifier.</summary>
public required string SAPID { get; set; }
/// <summary>Gets or sets the equipment identifier.</summary>
public required string EquipmentId { get; set; }
/// <summary>Gets or sets the equipment UUID.</summary>
public required string EquipmentUuid { get; set; }
/// <summary>Gets or sets the equipment name.</summary>
public required string Name { get; set; }
/// <summary>Gets or sets the UNS area name.</summary>
public required string UnsAreaName { get; set; }
/// <summary>Gets or sets the UNS line name.</summary>
public required string UnsLineName { get; set; }
// Optional (decision #139 — OPC 40010 Identification)
/// <summary>Gets or sets the manufacturer name.</summary>
public string? Manufacturer { get; set; }
/// <summary>Gets or sets the equipment model.</summary>
public string? Model { get; set; }
/// <summary>Gets or sets the serial number.</summary>
public string? SerialNumber { get; set; }
/// <summary>Gets or sets the hardware revision.</summary>
public string? HardwareRevision { get; set; }
/// <summary>Gets or sets the software revision.</summary>
public string? SoftwareRevision { get; set; }
/// <summary>Gets or sets the year of construction.</summary>
public string? YearOfConstruction { get; set; }
/// <summary>Gets or sets the asset location.</summary>
public string? AssetLocation { get; set; }
/// <summary>Gets or sets the manufacturer URI.</summary>
public string? ManufacturerUri { get; set; }
/// <summary>Gets or sets the device manual URI.</summary>
public string? DeviceManualUri { get; set; }
/// <summary>Gets or sets the parent batch.</summary>
public EquipmentImportBatch? Batch { get; set; }
}
@@ -9,10 +9,13 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class ExternalIdReservation
{
/// <summary>Gets or sets the unique reservation identifier.</summary>
public Guid ReservationId { get; set; }
/// <summary>Gets or sets the kind of reservation (ZTag or SAPID).</summary>
public required ReservationKind Kind { get; set; }
/// <summary>Gets or sets the reserved external ID value.</summary>
public required string Value { get; set; }
/// <summary>The equipment that owns this reservation. Stays bound even when equipment is disabled.</summary>
@@ -21,16 +24,21 @@ public sealed class ExternalIdReservation
/// <summary>First cluster to publish this reservation.</summary>
public required string ClusterId { get; set; }
/// <summary>Gets or sets the timestamp when the reservation was first published.</summary>
public DateTime FirstPublishedAt { get; set; } = DateTime.UtcNow;
/// <summary>Gets or sets the identifier of the user or system that first published the reservation.</summary>
public required string FirstPublishedBy { get; set; }
/// <summary>Gets or sets the timestamp of the most recent publication.</summary>
public DateTime LastPublishedAt { get; set; } = DateTime.UtcNow;
/// <summary>Non-null when explicitly released by FleetAdmin (audit-logged, requires reason).</summary>
public DateTime? ReleasedAt { get; set; }
/// <summary>Gets or sets the identifier of the user or system that released the reservation.</summary>
public string? ReleasedBy { get; set; }
/// <summary>Gets or sets the reason for releasing the reservation.</summary>
public string? ReleaseReason { get; set; }
}
@@ -8,24 +8,30 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class Namespace
{
/// <summary>Gets or sets the row identifier for this namespace.</summary>
public Guid NamespaceRowId { get; set; }
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA-equipment". Globally unique in v2.</summary>
public required string NamespaceId { get; set; }
/// <summary>Gets or sets the cluster identifier.</summary>
public required string ClusterId { get; set; }
/// <summary>Gets or sets the namespace kind.</summary>
public required NamespaceKind Kind { get; set; }
/// <summary>E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation.</summary>
public required string NamespaceUri { get; set; }
/// <summary>Gets or sets a value indicating whether the namespace is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Gets or sets optional notes about the namespace.</summary>
public string? Notes { get; set; }
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
/// <summary>Gets or sets the associated server cluster.</summary>
public ServerCluster? Cluster { get; set; }
}
@@ -8,14 +8,19 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class NodeAcl
{
/// <summary>Gets or sets the database row ID for this ACL entry.</summary>
public Guid NodeAclRowId { get; set; }
/// <summary>Gets or sets the logical ID of this ACL entry.</summary>
public required string NodeAclId { get; set; }
/// <summary>Gets or sets the cluster ID for this ACL entry.</summary>
public required string ClusterId { get; set; }
/// <summary>Gets or sets the LDAP group for this ACL entry.</summary>
public required string LdapGroup { get; set; }
/// <summary>Gets or sets the scope kind for this ACL entry.</summary>
public required NodeAclScopeKind ScopeKind { get; set; }
/// <summary>NULL when <see cref="ScopeKind"/> = <see cref="NodeAclScopeKind.Cluster"/>; otherwise the scoped entity's logical ID.</summary>
@@ -24,6 +29,7 @@ public sealed class NodeAcl
/// <summary>Bitmask of <see cref="NodePermissions"/>. Stored as int in SQL.</summary>
public required NodePermissions PermissionFlags { get; set; }
/// <summary>Gets or sets optional notes for this ACL entry.</summary>
public string? Notes { get; set; }
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
@@ -10,20 +10,29 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class NodeDeploymentState
{
/// <summary>Gets or sets the cluster node identifier.</summary>
public required string NodeId { get; init; }
/// <summary>Gets or sets the deployment identifier.</summary>
public Guid DeploymentId { get; init; }
/// <summary>Gets or sets the deployment status on this node.</summary>
public NodeDeploymentStatus Status { get; set; } = NodeDeploymentStatus.Applying;
/// <summary>Gets or sets the UTC timestamp when the deployment application started.</summary>
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
/// <summary>Gets or sets the UTC timestamp when the deployment was successfully applied, or null if not yet applied.</summary>
public DateTime? AppliedAtUtc { get; set; }
/// <summary>Gets or sets the failure reason if the deployment failed, or null if successful.</summary>
public string? FailureReason { get; set; }
/// <summary>Gets or sets the row version for optimistic concurrency control.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
/// <summary>Gets or sets the cluster node entity reference.</summary>
public ClusterNode? Node { get; set; }
/// <summary>Gets or sets the deployment entity reference.</summary>
public Deployment? Deployment { get; set; }
}
@@ -3,14 +3,19 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Driver-scoped polling group. Tags reference it via <see cref="Tag.PollGroupId"/>.</summary>
public sealed class PollGroup
{
/// <summary>Gets or sets the database row identifier for the polling group.</summary>
public Guid PollGroupRowId { get; set; }
/// <summary>Gets or sets the unique identifier for the polling group.</summary>
public required string PollGroupId { get; set; }
/// <summary>Gets or sets the driver instance that owns this polling group.</summary>
public required string DriverInstanceId { get; set; }
/// <summary>Gets or sets the display name of the polling group.</summary>
public required string Name { get; set; }
/// <summary>Gets or sets the poll interval in milliseconds.</summary>
public int IntervalMs { get; set; }
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </remarks>
public sealed class Script
{
/// <summary>Gets or sets the script row identifier.</summary>
public Guid ScriptRowId { get; set; }
/// <summary>Stable logical id. Globally unique in v2.</summary>
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </remarks>
public sealed class ScriptedAlarm
{
/// <summary>Gets or sets the database row identifier for this scripted alarm.</summary>
public Guid ScriptedAlarmRowId { get; set; }
/// <summary>Stable logical id — drives <c>AlarmConditionType.ConditionName</c>. Globally unique in v2.</summary>
@@ -52,6 +53,7 @@ public sealed class ScriptedAlarm
/// </summary>
public bool Retain { get; set; } = true;
/// <summary>Gets or sets a value indicating whether this alarm is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
@@ -45,13 +45,16 @@ public sealed class ScriptedAlarmState
/// <summary>Operator-supplied ack comment. Null if no comment or never acked.</summary>
public string? LastAckComment { get; set; }
/// <summary>Gets or sets the UTC timestamp of the last acknowledgment.</summary>
public DateTime? LastAckUtc { get; set; }
/// <summary>User who last confirmed.</summary>
public string? LastConfirmUser { get; set; }
/// <summary>Gets or sets the operator-supplied confirm comment. Null if no comment or never confirmed.</summary>
public string? LastConfirmComment { get; set; }
/// <summary>Gets or sets the UTC timestamp of the last confirmation.</summary>
public DateTime? LastConfirmUtc { get; set; }
/// <summary>JSON array of operator comments, append-only (GxP audit).</summary>
@@ -11,6 +11,7 @@ public sealed class ServerCluster
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA".</summary>
public required string ClusterId { get; set; }
/// <summary>Gets or sets the display name for the server cluster.</summary>
public required string Name { get; set; }
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
@@ -19,23 +20,33 @@ public sealed class ServerCluster
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
public required string Site { get; set; }
/// <summary>Gets or sets the number of nodes in the cluster.</summary>
public byte NodeCount { get; set; }
/// <summary>Gets or sets the redundancy mode for the cluster.</summary>
public required RedundancyMode RedundancyMode { get; set; }
/// <summary>Gets or sets a value indicating whether the cluster is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Gets or sets optional notes about the cluster.</summary>
public string? Notes { get; set; }
/// <summary>Gets or sets the UTC timestamp when the cluster was created.</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Gets or sets the user who created the cluster.</summary>
public required string CreatedBy { get; set; }
/// <summary>Gets or sets the UTC timestamp when the cluster was last modified.</summary>
public DateTime? ModifiedAt { get; set; }
/// <summary>Gets or sets the user who last modified the cluster.</summary>
public string? ModifiedBy { get; set; }
// Navigation
/// <summary>Gets or sets the collection of cluster nodes.</summary>
public ICollection<ClusterNode> Nodes { get; set; } = [];
/// <summary>Gets or sets the collection of namespaces in the cluster.</summary>
public ICollection<Namespace> Namespaces { get; set; } = [];
}
@@ -9,12 +9,24 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
public sealed class Tag
{
/// <summary>
/// Gets or sets the unique database row identifier for the tag.
/// </summary>
public Guid TagRowId { get; set; }
/// <summary>
/// Gets or sets the tag identifier.
/// </summary>
public required string TagId { get; set; }
/// <summary>
/// Gets or sets the driver instance identifier for this tag.
/// </summary>
public required string DriverInstanceId { get; set; }
/// <summary>
/// Gets or sets the device identifier.
/// </summary>
public string? DeviceId { get; set; }
/// <summary>
@@ -23,6 +35,9 @@ public sealed class Tag
/// </summary>
public string? EquipmentId { get; set; }
/// <summary>
/// Gets or sets the tag name.
/// </summary>
public required string Name { get; set; }
/// <summary>Only used when <see cref="EquipmentId"/> is NULL (SystemPlatform namespace).</summary>
@@ -31,11 +46,17 @@ public sealed class Tag
/// <summary>OPC UA built-in type name (Boolean / Int32 / Float / etc.).</summary>
public required string DataType { get; set; }
/// <summary>
/// Gets or sets the access level for this tag.
/// </summary>
public required TagAccessLevel AccessLevel { get; set; }
/// <summary>Per decisions #4445 — opt-in for write retry eligibility.</summary>
public bool WriteIdempotent { get; set; }
/// <summary>
/// Gets or sets the poll group identifier for batching read/write operations.
/// </summary>
public string? PollGroupId { get; set; }
/// <summary>Register address / scaling / poll group / byte-order / etc. — schemaless per driver type.</summary>
@@ -3,19 +3,24 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
public sealed class UnsArea
{
/// <summary>Gets or sets the unique row identifier.</summary>
public Guid UnsAreaRowId { get; set; }
/// <summary>Gets or sets the UNS area identifier.</summary>
public required string UnsAreaId { get; set; }
/// <summary>Gets or sets the cluster identifier.</summary>
public required string ClusterId { get; set; }
/// <summary>UNS level 3 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
public required string Name { get; set; }
/// <summary>Gets or sets optional notes for the area.</summary>
public string? Notes { get; set; }
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
/// <summary>Gets or sets the associated server cluster.</summary>
public ServerCluster? Cluster { get; set; }
}
@@ -3,8 +3,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
public sealed class UnsLine
{
/// <summary>Gets or sets the unique row identifier for this UNS line.</summary>
public Guid UnsLineRowId { get; set; }
/// <summary>Gets or sets the unique identifier for this UNS line.</summary>
public required string UnsLineId { get; set; }
/// <summary>Logical FK to <see cref="UnsArea.UnsAreaId"/>.</summary>
@@ -13,6 +15,7 @@ public sealed class UnsLine
/// <summary>UNS level 4 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
public required string Name { get; set; }
/// <summary>Gets or sets optional notes describing this UNS line.</summary>
public string? Notes { get; set; }
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
@@ -20,9 +20,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </remarks>
public sealed class VirtualTag
{
/// <summary>Gets or sets the database row ID (primary key).</summary>
public Guid VirtualTagRowId { get; set; }
/// <summary>Stable logical id. Globally unique in v2.</summary>
/// <summary>Gets or sets the stable logical identifier, globally unique in v2.</summary>
public required string VirtualTagId { get; set; }
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this virtual tag.</summary>
@@ -43,9 +44,10 @@ public sealed class VirtualTag
/// <summary>Timer re-evaluation cadence in milliseconds. <c>null</c> = no timer.</summary>
public int? TimerIntervalMs { get; set; }
/// <summary>Per plan decision #10 — checkbox to route this tag's values through <c>IHistoryWriter</c>.</summary>
/// <summary>Gets or sets a value indicating whether this tag's values should be historized.</summary>
public bool Historize { get; set; }
/// <summary>Gets or sets a value indicating whether this virtual tag is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>

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