57 Commits

Author SHA1 Message Date
Joseph Doherty adf1bd2693 build: drop orphaned AspNetCore.HealthChecks.UI.Client ref (UIResponseWriter removed) 2026-06-01 13:56:12 -04:00
Joseph Doherty bbff1d19b5 feat: adopt shared ZB.MOM.WW.Health probes; add /healthz; canonical writer 2026-06-01 13:46:49 -04:00
Joseph Doherty 2a7ff03718 feat: bridge ActorSystem into DI (transient) for shared health checks 2026-06-01 13:37:21 -04:00
Joseph Doherty 38e48299a4 build: reference ZB.MOM.WW.Health packages from the Gitea feed 2026-06-01 13:30:33 -04:00
Joseph Doherty 43228185b4 docs: convert standard diagrams from draw.io PNGs to inline Mermaid
Gitea renders mermaid inline, so the flow/state/hierarchy/DAG diagrams
move to text-in-markdown: auto-layout (removes the manual overlap-prone
draw.io step), diffable source, no committed binaries, and a dark-text
theme so labels stay legible. Keep draw.io PNGs only for the two complex
bespoke diagrams (logical architecture, env2 topology) where pixel
control still wins. All 24 mermaid blocks validated by rendering.
2026-06-01 00:23:00 -04:00
Joseph Doherty e3ca5ac0cf docs(spike): darken Mermaid label text for readability
Add explicit dark text color (per-class color + base theme override) to
the store-and-forward mermaid diagram so node/edge labels read clearly
regardless of gitea's page theme.
2026-06-01 00:08:08 -04:00
Joseph Doherty 4c5e7eb917 docs(spike): inline Mermaid for store-and-forward lifecycle
Swap the store-and-forward Message Lifecycle PNG embed for an inline
mermaid block to verify whether gitea renders mermaid in markdown. If it
does, the standard flow/state/hierarchy diagrams can move to inline
mermaid (text-only, auto-layout) instead of draw.io source + PNG.
2026-05-31 23:53:04 -04:00
Joseph Doherty bdee12f4e9 docs: render architecture & flow diagrams as draw.io charts
Replace ASCII-art diagrams across the README and docs/ with editable
.drawio sources plus exported PNGs, so the diagrams render clearly in
rendered markdown and can be maintained/regenerated instead of being
hand-edited as fragile text art. Non-diagram blocks (code, folder
trees, UI wireframes) were left as text.
2026-05-31 23:32:53 -04:00
Joseph Doherty 3763f6d2d8 docs: reframe README as the ScadaBridge implementation project
Retitle from 'SCADA System — Design Documentation' to ScadaBridge; the
overview now describes the repo as the full implementation (src/tests/docker
+ design docs as spec) rather than design docs only. Add Repository Layout
and Build/Test/Run sections. Component table + architecture diagrams unchanged.
2026-05-31 22:12:16 -04:00
Joseph Doherty 300841b205 chore: mark rename plan complete (all 7 tasks done) 2026-05-31 22:05:13 -04:00
Joseph Doherty 3797af7f0f chore: mark rename plan tasks 0-4 complete 2026-05-31 21:59:57 -04:00
Joseph Doherty a47317d010 docs: record git-ignored deploy/ scrub gap + resolution (incl. LDAP directory rename) 2026-05-31 21:58:49 -04:00
Joseph Doherty c899cb162c refactor: scrub residual ScadaLink refs → ScadaBridge (env vars, config keys, assembly name, SQL login)
Renames the 13 SCADALINK_* runtime env vars → SCADABRIDGE_*, the ScadaLink__
.NET config keys → ScadaBridge__, the stale ScadaLink.Host.exe assembly name
→ ZB.MOM.WW.ScadaBridge.Host.exe, the scadalink_app SQL login → scadabridge_app,
and residual identifiers/comments/docs. Migration records (prior rename
tooling/design, DB-rename helper, this scrub script) carved out.

Adds tools/scrub-scadalink-refs.sh.
2026-05-31 21:50:38 -04:00
Joseph Doherty d69031dd08 docs: reorganize CLAUDE.md project-structure section 2026-05-31 21:41:29 -04:00
Joseph Doherty d317c07ea5 docs: add folder/repo rename implementation plan + task file 2026-05-31 21:39:23 -04:00
Joseph Doherty e01f3bdabe docs: add folder/repo rename design doc (scadalink-design → ScadaBridge) 2026-05-31 21:32:07 -04:00
Joseph Doherty f428804bef docs: close native-alarm spec gaps surfaced by docs audit
The native alarms feature merged with 7 component docs updated, but the
spec layer drifted: HighLevelReqs, Commons, and ManagementService had no
native-alarm coverage and the README table flagged it on only one row.
Add HighLevelReqs §3.4.2 (+ validation), document the Commons
types/entities/messages and the 7 ManagementService commands, sync the
README rows + link the TreeView sub-component, fix 2 broken plan links,
and drop the one-off native-alarms RESUME scratchpad.
2026-05-31 07:41:49 -04:00
Joseph Doherty 9afcdc996e chore(plans): native alarms COMPLETE (28/28) + T28 live-integration notes 2026-05-31 03:29:37 -04:00
Joseph Doherty add7210d9e fix(dcl): route native alarm subscribe/unsubscribe through DataConnectionManagerActor
The NativeAlarmActor sends SubscribeAlarmsRequest to the DCL manager, but the
manager only routed tag/write/browse messages to the per-connection
DataConnectionActor — alarm subscribe/unsubscribe were unhandled and dead-lettered,
so native alarms never subscribed at runtime. Caught by live T28 deployment.
Mirrors the existing HandleRoute forwarding.
2026-05-31 03:25:28 -04:00
Joseph Doherty f4ae44aacc fix(docker-env2): instance deploy uses --id (not --instance-id) in native alarm seed 2026-05-31 03:15:09 -04:00
Joseph Doherty 27d5701d99 test(dcl): OPC UA A&C live smoke (skippable) + test-infra A&C note 2026-05-31 03:05:44 -04:00
Joseph Doherty d24c00ec2c chore(plans): native alarms execution progress (27/28) + batch-8 notes 2026-05-31 02:55:36 -04:00
Joseph Doherty 003e54c1fb docs: native alarm ingestion across component docs + CLAUDE.md 2026-05-31 02:55:00 -04:00
Joseph Doherty 2b7c765a58 chore(docker-env2): seed sample native alarm source binding + demo instance on site-x 2026-05-31 02:50:38 -04:00
Joseph Doherty 046797e699 feat(ui): instance configure native alarm source override panel 2026-05-31 02:46:54 -04:00
Joseph Doherty b03ab11d8a chore(plans): native alarms execution progress (24/28) + batch-7 notes 2026-05-31 02:41:45 -04:00
Joseph Doherty 60f8e2c9a7 feat(ui): template editor Native Alarm Sources subsection 2026-05-31 02:40:52 -04:00
Joseph Doherty 1f6c4207df feat(ui): enrich DebugView alarm table with severity + condition state + native metadata 2026-05-31 02:34:12 -04:00
Joseph Doherty a6dcbf62cd feat(cli): native-alarm-source commands (template add/list/remove + instance set/clear) 2026-05-31 02:30:05 -04:00
Joseph Doherty c4cea9eea5 chore(plans): native alarms execution progress (21/28) + batch-6 notes 2026-05-31 02:23:50 -04:00
Joseph Doherty 3bf1d26d79 feat(management): handlers for native alarm source CRUD 2026-05-31 02:23:17 -04:00
Joseph Doherty b1df6d5beb feat(commons): management command contracts for native alarm sources 2026-05-31 02:18:37 -04:00
Joseph Doherty 0c6f9a9cff feat(communication): map enriched alarm fields across gRPC (server + client) 2026-05-31 02:16:43 -04:00
Joseph Doherty 50176765fe chore(plans): native alarms execution progress (18/28) + batch-5 notes 2026-05-31 02:11:11 -04:00
Joseph Doherty 20b41b8965 feat(communication): extend AlarmStateUpdate proto with native alarm fields (regenerated) 2026-05-31 02:10:12 -04:00
Joseph Doherty bca21ffb95 test(siteruntime): assert computed alarms carry unified condition state 2026-05-31 02:07:54 -04:00
Joseph Doherty 6d318586d1 feat(siteruntime): InstanceActor spawns NativeAlarmActors + enriched alarm snapshot; clear native state on redeploy/undeploy 2026-05-31 02:06:39 -04:00
Joseph Doherty 376dac4895 chore(plans): native alarms execution progress (15/28) + batch-4 notes 2026-05-31 01:50:30 -04:00
Joseph Doherty fda7ac9c50 feat(siteruntime): NativeAlarmActor mirrors source alarms (snapshot swap, retention, persistence) 2026-05-31 01:49:28 -04:00
Joseph Doherty 24fd7bee53 feat(siteruntime): site SQLite native_alarm_state store 2026-05-31 01:44:40 -04:00
Joseph Doherty b44a844152 feat(siteruntime): native alarm cap + retry options 2026-05-31 01:42:41 -04:00
Joseph Doherty 3650a89fcd chore(plans): native alarms execution progress (12/28) + resume notes 2026-05-29 17:01:18 -04:00
Joseph Doherty c7411700dc feat(dcl): MxGateway StreamAlarms adapter (snapshot + live transitions, reconnecting)
Adds IAlarmSubscribableConnection to MxGatewayDataConnection (shared session-less
feed, ref-counted), IMxGatewayClient.RunAlarmStreamAsync over the package
StreamAlarmsAsync with internal reconnect, and MxGatewayAlarmMapper
(AlarmFeedMessage/OnAlarmTransitionEvent -> NativeAlarmTransition). Behavior
verified against a live gateway in Task 28; mapper unit-tested.
2026-05-29 16:49:25 -04:00
Joseph Doherty 0d30b7dec0 feat(dcl): OPC UA Alarms & Conditions adapter (event subscription + ConditionRefresh)
Adds IAlarmSubscribableConnection to OpcUaDataConnection, IOpcUaClient alarm
subscription methods, and RealOpcUaClient A&C event monitored-item +
EventFilter + ConditionRefresh snapshot, mapping fields via OpcUaAlarmMapper.
Behavior verified against a live A&C server in Task 28; mapper unit-tested.
2026-05-29 16:42:27 -04:00
Joseph Doherty 1fbb814daa feat(dcl): OPC UA A&C field mapper (Task 11 part 1 — pure, unit-tested) 2026-05-29 16:13:02 -04:00
Joseph Doherty d3b3d15018 feat(dcl): DataConnectionActor native alarm subscribe + source-ref routing + unavailable signal 2026-05-29 16:09:31 -04:00
Joseph Doherty ba278736af feat(templateengine): validate native alarm source connection + source reference 2026-05-29 16:04:01 -04:00
Joseph Doherty e5392d2c7b feat(templateengine): flatten native alarm sources (inherit/compose/override) 2026-05-29 16:00:10 -04:00
Joseph Doherty fc05ba1f6a feat(configdb): migration AddNativeAlarmSources 2026-05-29 15:57:29 -04:00
Joseph Doherty aedd17ca7f feat(configdb): native alarm source repository CRUD + eager-load includes 2026-05-29 15:56:35 -04:00
Joseph Doherty 63f1ec282f feat(configdb): EF mappings + DbSets for native alarm source entities 2026-05-29 15:52:33 -04:00
Joseph Doherty 913441972e feat(commons): native alarm source entities + ResolvedNativeAlarmSource 2026-05-29 15:43:24 -04:00
Joseph Doherty ea14ace150 feat(commons): IAlarmSubscribableConnection seam + DCL native alarm messages 2026-05-29 15:41:10 -04:00
Joseph Doherty edc2dacf6c feat(commons): enrich AlarmStateChanged with unified condition state (additive) 2026-05-29 15:40:20 -04:00
Joseph Doherty 696da92c3a feat(commons): native alarm core types (AlarmConditionState, NativeAlarmTransition, enums) 2026-05-29 15:39:20 -04:00
Joseph Doherty 09e19db4e9 docs(plans): native alarms implementation plan (28 tasks, TDD)
Bite-sized task plan with exact file paths, code, and TDD steps for native
OPC UA A&C + MxAccess GW alarm ingestion. Phases: Commons contracts ->
config/flatten/migration -> DCL ingestion (actor + 2 adapters) -> site
runtime (NativeAlarmActor + SQLite) -> gRPC proto -> management/CLI -> UI ->
seed/docs/integration. Includes .tasks.json for executing-plans resume.
2026-05-29 15:27:43 -04:00
Joseph Doherty dadebbe227 docs(plans): native OPC UA & MxAccess GW alarms design
Read-only mirror of native alarm sources into a unified A&C-style state
model (severity + active/acked/shelved/suppressed). Instance-bound source
discovery, site-only SQLite state with live central query (no central
tables), DebugView enrichment. OPC UA A&C events + ConditionRefresh and
MxGateway session-less StreamAlarms via a new IAlarmSubscribableConnection
seam routed connection-level by source reference; new NativeAlarmActor peer
to computed AlarmActor.
2026-05-29 15:14:01 -04:00
158 changed files with 11025 additions and 984 deletions
+16 -1
View File
@@ -6,17 +6,31 @@ When a change is requested, the default assumption is: update the design doc *an
## Project Structure
### Top-level directories
- `src/` — C#/.NET implementation, one project per component (e.g. `ZB.MOM.WW.ScadaBridge.AuditLog`, `ZB.MOM.WW.ScadaBridge.NotificationOutbox`, `ZB.MOM.WW.ScadaBridge.SiteCallAudit`, `ZB.MOM.WW.ScadaBridge.CentralUI`, `ZB.MOM.WW.ScadaBridge.Host`, …). Solution file: `ZB.MOM.WW.ScadaBridge.slnx`.
- `tests/` — Test projects (unit + integration).
- `docs/` — Design documentation: `docs/requirements/` (high-level + per-component specs), `docs/test_infra/` (test infrastructure), `docs/plans/` (design-decision and implementation-plan docs). The spec the code implements.
- `docker/` — 8-node cluster topology (2 central + 3 sites), `deploy.sh`, per-node `appsettings.*.json`. See [`docker/README.md`](docker/README.md) for setup, ports, and management commands. Rebuild + redeploy with `bash docker/deploy.sh`.
- `docker-env2/` — Minimal second cluster topology (2 central + 1 site × 2 nodes), runs concurrently with `docker/` on host ports 91XX. Built specifically for testing the Transport (#24) feature with two real environments. See [`docker-env2/README.md`](docker-env2/README.md). Rebuild + redeploy with `bash docker-env2/deploy.sh`.
- `infra/` — Docker Compose for local test services (LDAP, MS SQL, OPC UA, SMTP, REST API, Traefik).
- `deploy/` — Production/on-host deployment artifacts (e.g. `deploy/wonder-app-vd03/`: `appsettings.Central.json`, `appsettings.Site.json`, `install.ps1`/`uninstall.ps1`, `RUNBOOK.md`).
- `deployments/` — Deployment topology notes (`docker-cluster.md`, `docker-cluster-env2.md`, `README.md`).
- `code-reviews/` — Per-component code-review notes (one folder per component, plus `_template`).
- `tools/` — Repo maintenance/utility scripts (e.g. `rename-to-scadabridge.sh`).
- `AkkaDotNet/` — Akka.NET reference documentation and best-practices notes.
- `deprecated/` — Retired docs/notes kept for reference.
- `logs/` — Local runtime log output.
- `vendor/` — Vendored third-party assets (currently an empty placeholder).
- `.claude/` — Claude Code project config (settings, skills, agents).
### Key documents
- `README.md` — Master index with component table and architecture diagrams.
- `docs/requirements/HighLevelReqs.md` — Complete high-level requirements covering all functional areas.
- `docs/requirements/Component-*.md` — Individual component design documents (one per component) — the spec the code implements.
- `docs/test_infra/test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL, SMTP, REST API, Traefik).
- `docs/plans/` — Design decision and implementation-plan documents from refinement sessions.
- `AkkaDotNet/` — Akka.NET reference documentation and best practices notes.
## Sister Projects
@@ -99,6 +113,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
- All timestamps are UTC throughout the system.
- Inter-cluster communication uses two transports: ClusterClient for command/control (deployments, lifecycle, subscribe/unsubscribe handshake, snapshots) and gRPC server-streaming for real-time data (attribute values, alarm states). Both CentralCommunicationActor and SiteCommunicationActor registered with receptionist. Central creates one ClusterClient per site using NodeA/NodeB as contact points. Sites configure multiple central contact points for failover. Addresses cached in CentralCommunicationActor, refreshed periodically (60s) and on admin changes. Heartbeats serve health monitoring only.
- gRPC streaming channel: SiteStreamGrpcServer on each site node (Kestrel HTTP/2, port 8083); central creates per-site SiteStreamGrpcClient via SiteStreamGrpcClientFactory. Site entity has GrpcNodeAAddress/GrpcNodeBAddress fields. Proto: sitestream.proto with SiteStreamService, SiteStreamEvent (oneof: AttributeValueUpdate, AlarmStateUpdate). DebugStreamEvent message removed (no longer flows through ClusterClient).
- Native alarms: a read-only mirror of native alarms from OPC UA Alarms & Conditions servers and the MxAccess Gateway, unified onto an A&C-style condition model (`AlarmConditionState`: orthogonal Active/Acked/Confirmed/Shelved/Suppressed + 01000 severity) plus an `AlarmKind` discriminator (Computed/NativeOpcUa/NativeMxAccess). New DCL capability seam `IAlarmSubscribableConnection` (implemented by the OPC UA and MxGateway adapters); the `DataConnectionActor` opens ONE alarm feed per connection and routes transitions to instances by source-object reference. A `NativeAlarmActor` (peer to the computed `AlarmActor` under `InstanceActor`) mirrors one source binding: snapshot atomic-swap on (re)subscribe, retention (drops once inactive+acked), per-source cap, and site SQLite persistence (`native_alarm_state`, survives failover, cleared on redeploy/undeploy — mirrors static overrides). State streams to central over the additively-enriched gRPC `AlarmStateUpdate` (the existing computed `AlarmStateChanged` was enriched additively) and seeds via the DebugView snapshot. Authoring: `TemplateNativeAlarmSource` / `InstanceNativeAlarmSourceOverride` entities flatten to `ResolvedNativeAlarmSource` (inherit/compose/override); management commands + ManagementActor handlers + CLI (`template/instance native-alarm-source`) + Central UI (template editor tab + instance override panel) + enriched DebugView alarm table. Read-only — no ack-back; no central tables.
### External Integrations
- External System Gateway: HTTP/REST only, JSON serialization, API key + Basic Auth.
+3 -1
View File
@@ -15,7 +15,6 @@
<PackageVersion Include="Akka.Streams" Version="1.5.62" />
<PackageVersion Include="Akka.Streams.TestKit" Version="1.5.62" />
<PackageVersion Include="Akka.TestKit.Xunit2" Version="1.5.62" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageVersion Include="bunit" Version="2.0.33-preview" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="FluentAssertions" Version="8.3.0" />
@@ -73,6 +72,9 @@
to mark tests as Skipped (not silently Passed) when MSSQL is unreachable.
-->
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
</ItemGroup>
+88 -102
View File
@@ -1,8 +1,10 @@
# SCADA System — Design Documentation
# ScadaBridge
ScadaBridge is a centrally-managed, distributed SCADA configuration and deployment platform built on Akka.NET, running across a central cluster and multiple site clusters in a hub-and-spoke topology.
## Overview
This document serves as the master index for the SCADA system design. The system is a centrally-managed, distributed SCADA configuration and deployment platform built on Akka.NET, running across a central cluster and multiple site clusters in a hub-and-spoke topology.
This repository is the full **implementation** project for ScadaBridge — the C#/.NET source (`src/`), tests (`tests/`), deployable Docker topology (`docker/`, `docker-env2/`, `infra/`), and the design documentation (`docs/`) that the code implements. This README is the master index: it links the per-component **design specs** (the spec the code in `src/` implements) and shows the system architecture. The solution file is `ZB.MOM.WW.ScadaBridge.slnx`.
### Technology Stack
@@ -24,6 +26,38 @@ This document serves as the master index for the SCADA system design. The system
- Central cluster: 2-node active/standby behind a load balancer.
- Site clusters: 2-node active/standby, headless (no UI).
## Repository Layout
| Path | Contents |
|------|----------|
| `src/` | C#/.NET implementation — one project per component (`ZB.MOM.WW.ScadaBridge.<Component>`). Solution: `ZB.MOM.WW.ScadaBridge.slnx`. |
| `tests/` | Unit and integration test projects. |
| `docs/` | Design documentation — `docs/requirements/` (high-level + per-component specs, the spec the code implements), `docs/test_infra/`, `docs/plans/`. |
| `docker/` | Primary 8-node cluster topology (2 central + 3 sites × 2 nodes + Traefik) + `deploy.sh`. |
| `docker-env2/` | Minimal second cluster (2 central + 1 site) for exercising Transport (#24) against a real second environment. |
| `infra/` | Local test services (MS SQL, LDAP, OPC UA, SMTP, REST API, Traefik). |
| `deploy/` | Production/on-host deployment artifacts (e.g. `wonder-app-vd03/`). |
| `AkkaDotNet/` | Akka.NET reference notes. |
## Build, Test & Run
```bash
# Build the solution
dotnet build ZB.MOM.WW.ScadaBridge.slnx
# Run the tests
dotnet test ZB.MOM.WW.ScadaBridge.slnx
# Bring up the primary local cluster (builds the scadabridge:latest image + recreates containers)
bash docker/deploy.sh # central load balancer at http://localhost:9000
# Drive the system from the CLI (reads ~/.scadabridge/config.json; test user has all roles)
dotnet run --project src/ZB.MOM.WW.ScadaBridge.CLI -- \
--username multi-role --password password template list
```
See [`docker/README.md`](docker/README.md) for ports and management commands, and [`src/ZB.MOM.WW.ScadaBridge.CLI/README.md`](src/ZB.MOM.WW.ScadaBridge.CLI/README.md) for the full CLI reference.
## Local Test Environments
Two Docker-based cluster topologies are available for local development and testing:
@@ -42,11 +76,11 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
| # | Component | Document | Description |
|---|-----------|----------|-------------|
| 1 | Template Engine | [docs/requirements/Component-TemplateEngine.md](docs/requirements/Component-TemplateEngine.md) | Template modeling, inheritance, composition, path-qualified member addressing, override granularity, locking, alarms, flattening, semantic validation, revision hashing, diff calculation, and folder organization (nested folders, drag-drop). |
| 1 | Template Engine | [docs/requirements/Component-TemplateEngine.md](docs/requirements/Component-TemplateEngine.md) | Template modeling, inheritance, composition, path-qualified member addressing, override granularity, locking, alarms, native alarm source bindings, flattening, semantic validation, revision hashing, diff calculation, and folder organization (nested folders, drag-drop). |
| 2 | Deployment Manager | [docs/requirements/Component-DeploymentManager.md](docs/requirements/Component-DeploymentManager.md) | Central-side deployment pipeline with deployment ID/idempotency, per-instance operation lock, state transition matrix, all-or-nothing site apply, system-wide artifact deployment with per-site status. |
| 3 | Site Runtime | [docs/requirements/Component-SiteRuntime.md](docs/requirements/Component-SiteRuntime.md) | Site-side actor hierarchy with explicit supervision strategies, staggered startup, script trust model (constrained APIs), Tell/Ask conventions, concurrency serialization, and site-wide Akka stream with per-subscriber backpressure. |
| 4 | Data Connection Layer | [docs/requirements/Component-DataConnectionLayer.md](docs/requirements/Component-DataConnectionLayer.md) | Common data connection interface (OPC UA, MxGateway, custom), Become/Stash connection actor model, auto-reconnect, immediate bad quality on disconnect, transparent re-subscribe, synchronous write failures, tag path resolution retry, protocol-agnostic address-space browse. |
| 5 | CentralSite Communication | [docs/requirements/Component-Communication.md](docs/requirements/Component-Communication.md) | Dual transport: Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data). 9 message patterns with per-pattern timeouts, SiteStreamGrpcServer/Client, application-level correlation IDs, transport heartbeat config, gRPC keepalive, message ordering, connection failure behavior. |
| 3 | Site Runtime | [docs/requirements/Component-SiteRuntime.md](docs/requirements/Component-SiteRuntime.md) | Site-side actor hierarchy with explicit supervision strategies, staggered startup, script trust model (constrained APIs), Tell/Ask conventions, concurrency serialization, site-wide Akka stream with per-subscriber backpressure, and a read-only Native Alarm Actor (peer to the computed Alarm Actor) mirroring native OPC UA A&C / MxAccess alarms with site SQLite persistence. |
| 4 | Data Connection Layer | [docs/requirements/Component-DataConnectionLayer.md](docs/requirements/Component-DataConnectionLayer.md) | Common data connection interface (OPC UA, MxGateway, custom), Become/Stash connection actor model, auto-reconnect, immediate bad quality on disconnect, transparent re-subscribe, synchronous write failures, tag path resolution retry, protocol-agnostic address-space browse, and optional read-only native alarm mirroring (`IAlarmSubscribableConnection`, one alarm feed per connection with snapshot replay). |
| 5 | CentralSite Communication | [docs/requirements/Component-Communication.md](docs/requirements/Component-Communication.md) | Dual transport: Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data). 9 message patterns with per-pattern timeouts, SiteStreamGrpcServer/Client, application-level correlation IDs, transport heartbeat config, gRPC keepalive, message ordering, connection failure behavior. The gRPC stream additively carries the read-only native alarm mirror (computed + native OPC UA / MxAccess) via the enriched `AlarmStateUpdate`. |
| 6 | Store-and-Forward Engine | [docs/requirements/Component-StoreAndForward.md](docs/requirements/Component-StoreAndForward.md) | Buffering (transient failures only), fixed-interval retry, parking, async best-effort replication, SQLite persistence at sites. |
| 7 | External System Gateway | [docs/requirements/Component-ExternalSystemGateway.md](docs/requirements/Component-ExternalSystemGateway.md) | HTTP/REST + JSON, API key/Basic Auth, per-system timeout, dual call modes (Call/CachedCall), transient/permanent error classification, dedicated blocking I/O dispatcher, ADO.NET connection pooling. |
| 8 | Notification Service | [docs/requirements/Component-NotificationService.md](docs/requirements/Component-NotificationService.md) | Central-only — manages typed notification-list and SMTP definitions, supplies per-type delivery adapters (SMTP with OAuth2 (M365) or Basic Auth, BCC, plain text); delivery performed by the Notification Outbox. |
@@ -57,8 +91,8 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
| 13 | Cluster Infrastructure | [docs/requirements/Component-ClusterInfrastructure.md](docs/requirements/Component-ClusterInfrastructure.md) | Akka.NET cluster, keep-oldest SBR with down-if-alone, min-nr-of-members=1, 2s/10s/15s failure detection, CoordinatedShutdown, automatic dual-node recovery. The `ClusterInfrastructure` project owns the `ClusterOptions` config model; the Akka bootstrap/SBR/CoordinatedShutdown wiring lives in the Host. |
| 14 | Inbound API | [docs/requirements/Component-InboundAPI.md](docs/requirements/Component-InboundAPI.md) | POST /api/{methodName}, X-API-Key header, flat JSON, extended type system (Object/List), script-based implementations, failures-only logging. |
| 15 | Host | [docs/requirements/Component-Host.md](docs/requirements/Component-Host.md) | Single deployable binary, role-based component registration, per-component config binding (Options pattern), readiness gating, dead letter monitoring, Akka.NET bootstrap, ASP.NET Core hosting for central. |
| 16 | Commons | [docs/requirements/Component-Commons.md](docs/requirements/Component-Commons.md) | Namespace/folder convention (Types/Interfaces/Entities/Messages), shared data types, POCOs, repository interfaces, message contracts with additive-only versioning, UTC timestamp convention. |
| 17 | Configuration Database | [docs/requirements/Component-ConfigurationDatabase.md](docs/requirements/Component-ConfigurationDatabase.md) | EF Core data access, per-component repositories, unit-of-work, optimistic concurrency on deployment status, audit logging (IAuditService), migration management. |
| 16 | Commons | [docs/requirements/Component-Commons.md](docs/requirements/Component-Commons.md) | Namespace/folder convention (Types/Interfaces/Entities/Messages), shared data types, POCOs, repository interfaces, message contracts with additive-only versioning, UTC timestamp convention, the unified read-only alarm condition model (`AlarmConditionState`/`AlarmKind`), and native alarm source entities + the `IAlarmSubscribableConnection` capability seam. |
| 17 | Configuration Database | [docs/requirements/Component-ConfigurationDatabase.md](docs/requirements/Component-ConfigurationDatabase.md) | EF Core data access, per-component repositories, unit-of-work, optimistic concurrency on deployment status, audit logging (IAuditService), migration management (incl. the `AddNativeAlarmSources` migration + native alarm source repository CRUD). |
| 18 | Management Service | [docs/requirements/Component-ManagementService.md](docs/requirements/Component-ManagementService.md) | Akka.NET ManagementActor on central, ClusterClientReceptionist registration, programmatic access to all admin operations, CLI interface. |
| 19 | CLI | [docs/requirements/Component-CLI.md](docs/requirements/Component-CLI.md) | Standalone command-line tool, System.CommandLine, HTTP transport via Management API, JSON/table output, mirrors all Management Service operations. |
| 20 | Traefik Proxy | [docs/requirements/Component-TraefikProxy.md](docs/requirements/Component-TraefikProxy.md) | Reverse proxy/load balancer fronting central cluster, active node routing via `/health/active`, automatic failover. |
@@ -67,6 +101,8 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
| 23 | Audit Log | [docs/requirements/Component-AuditLog.md](docs/requirements/Component-AuditLog.md) | New central append-only AuditLog spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site-local SQLite hot-path append + gRPC telemetry + central reconciliation; combined telemetry packet with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API middleware; monthly partitioning, 365-day default retention. |
| 24 | Transport | [docs/requirements/Component-Transport.md](docs/requirements/Component-Transport.md) | Bundle export/import for templates, shared scripts, external systems, central-only artifacts. AES-256-GCM encryption; per-conflict resolution on import; correlated audit trail. |
**Shared UI sub-component** (not a top-level component): [TreeView](docs/requirements/Component-TreeView.md) — reusable hierarchical tree/grid Blazor component used by the Central UI (#9) for the templates folder hierarchy, data-connection browse, and tag pickers.
### Reference Documentation
- [AkkaDotNet/](AkkaDotNet/) — Akka.NET reference notes covering actors, remoting, clustering, persistence, streams, serialization, hosting, testing, and best practices.
@@ -74,102 +110,52 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
### Architecture Diagram (Logical)
```
Users (Blazor Server)
Load Balancer
┌────────────────────────┼────────────────────────────┐
│ CENTRAL CLUSTER │
│ (2-node active/standby) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Template │ │Deployment│ │ Central │ │
│ │ Engine │ │ Manager │ │ UI │ Blazor Svr │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Security │ │ Config │ │ Health │ │
│ │ & Auth │ │ DB │ │ Monitor │ │
│ │ (JWT/LDAP)│ │ (EF+IAud)│ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ │
│ │ Inbound │ ◄── External Systems (X-API-Key) │
│ │ API │ POST /api/{method}, JSON │
│ └──────────┘ │
│ ┌──────────┐ │
│ │ Mgmt │ ◄── CLI (ClusterClient) │
│ │ Service │ ManagementActor + Receptionist │
│ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Ntf │ │ Site │ │ Audit │ Observ. / │
│ │ Outbox │ │ Call │ │ Log │ Audit area │
│ │ (#21) │ │ Audit │ │ (#23) │ │
│ │ │ │ (#22) │ │ │ │
│ └────▲─────┘ └────▲─────┘ └────▲─────┘ │
│ │ ingests │ ingests │ ingests │
│ │ (S&F) │ (telemetry)│ (telemetry + │
│ │ │ │ direct-write │
│ │ │ │ from Ntf Outbox │
│ │ │ │ & Inbound API) │
│ ┌───────────────────────────────────┐ │
│ │ Akka.NET Communication Layer │ │
│ │ ClusterClient: command/control │ │
│ │ gRPC Client: real-time streams │ │
│ │ (correlation IDs, per-pattern │ │
│ │ timeouts, message ordering) │ │
│ └──────────────┬────────────────────┘ │
│ ┌──────────────┴────────────────────┐ │
│ │ Configuration Database (EF) │──► MS SQL │
│ └───────────────────────────────────┘ (Config DB)│
│ │ Machine Data DB│
└─────────────────┼───────────────────────────────────┘
│ Akka.NET Remoting (command/control)
│ gRPC HTTP/2 (real-time data, port 8083)
┌────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ SITE A │ │ SITE B │ │ SITE N │
│ (2-node)│ │ (2-node)│ │ (2-node)│
│ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │
│ │Data │ │ │ │Data │ │ │ │Data │ │
│ │Conn │ │ │ │Conn │ │ │ │Conn │ │
│ │Layer │ │ │ │Layer │ │ │ │Layer │ │
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
│ │Site │ │ │ │Site │ │ │ │Site │ │
│ │Runtm│ │ │ │Runtm│ │ │ │Runtm│ │
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
│ │gRPC │ │ │ │gRPC │ │ │ │gRPC │ │
│ │Srvr │ │ │ │Srvr │ │ │ │Srvr │ │
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
│ │S&F │ │ │ │S&F │ │ │ │S&F │ │
│ │Engine│ │ │ │Engine│ │ │ │Engine│ │
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
│ │ExtSys│ │ │ │ExtSys│ │ │ │ExtSys│ │
│ │Gatwy │ │ │ │Gatwy │ │ │ │Gatwy │ │
│ └─────┘ │ │ └─────┘ │ │ └─────┘ │
│ SQLite │ │ SQLite │ │ SQLite │
└─────────┘ └─────────┘ └─────────┘
│ │ │
OPC UA / OPC UA / OPC UA /
Custom Custom Custom
Protocol Protocol Protocol
```
![Logical architecture](diagrams/architecture-logical.png)
<!-- source: diagrams/architecture-logical.drawio — edit, then re-export with export-drawio.sh -->
### Site Runtime Actor Hierarchy
```
Deployment Manager Singleton (Cluster Singleton)
├── Instance Actor (one per deployed, enabled instance)
├── Script Actor (coordinator, one per instance script)
│ │ └── Script Execution Actor (short-lived, per invocation)
│ ├── Alarm Actor (coordinator, one per alarm definition)
│ │ └── Alarm Execution Actor (short-lived, per on-trigger invocation)
│ └── ... (more Script/Alarm Actors)
├── Instance Actor
│ └── ...
└── ... (more Instance Actors)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
DMS["Deployment Manager Singleton<br/>(Cluster Singleton)"]
IA["Instance Actor<br/>(one per deployed, enabled instance)"]
IA2["Instance Actor<br/>( … )"]
MOREIA["… more Instance Actors"]
DMS --> IA
DMS --> IA2
DMS -.-> MOREIA
Site-Wide Akka Stream (attribute + alarm state changes)
├── All Instance Actors publish to the stream
└── Debug view subscribes with instance-level filtering
SA["Script Actor<br/>(coordinator, one per instance script)"]
AA["Alarm Actor<br/>(coordinator, one per alarm definition)"]
MORE1["… more Script /<br/>Alarm Actors"]
IA --> SA
IA --> AA
IA -.-> MORE1
SEA["Script Execution Actor<br/>(short-lived, per invocation)"]
AEA["Alarm Execution Actor<br/>(short-lived, per on-trigger invocation)"]
IA2C["… (Script / Alarm Actors)"]
SA --> SEA
AA --> AEA
IA2 -.-> IA2C
subgraph STREAM["Site-Wide Akka Stream"]
PUB["All Instance Actors"]
STR["Site-Wide Akka Stream<br/>(attribute + alarm state changes)"]
DBG["Debug view<br/>(instance-level filtering)"]
PUB -->|publish| STR
STR -->|subscribe filtered| DBG
end
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class DMS,STR alt
class IA,IA2,PUB proc
class SA,AA,DBG start
class SEA,AEA warn
class MOREIA,MORE1,IA2C muted
```
+9 -9
View File
@@ -22,7 +22,7 @@ The dominant theme is **graceful-degradation gaps**: several user-supplied input
URLs, malformed `--bindings`/`--overrides` JSON, non-JSON success bodies) are deserialized
or constructed without `try/catch`, so a normal user mistake surfaces as an unhandled
exception with a stack trace instead of a clean error message and exit code 1. A second
theme is **dead configuration**: the `SCADALINK_FORMAT` environment variable and the
theme is **dead configuration**: the `SCADABRIDGE_FORMAT` environment variable and the
`defaultFormat` config-file field are loaded by `CliConfig` but never consulted by any
command, so the documented format-precedence chain does not work. The third theme is
**substantial design-document drift**: `Component-CLI.md` describes a name-keyed,
@@ -126,7 +126,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
## Findings
### CLI-001 — `SCADALINK_FORMAT` env var and config-file format are dead; format precedence broken
### CLI-001 — `SCADABRIDGE_FORMAT` env var and config-file format are dead; format precedence broken
| | |
|--|--|
@@ -137,7 +137,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
**Description**
`CliConfig.Load()` reads `SCADALINK_FORMAT` and the `defaultFormat` config-file field into
`CliConfig.Load()` reads `SCADABRIDGE_FORMAT` and the `defaultFormat` config-file field into
`CliConfig.DefaultFormat`, and `Component-CLI.md` documents a format-precedence chain
(command-line option → env var → config file). However, every command resolves the format
with `var format = result.GetValue(formatOption) ?? "json";` and `formatOption` is created
@@ -145,7 +145,7 @@ in `Program.cs:11` with `DefaultValueFactory = _ => "json"`. `GetValue` therefor
returns a non-null value ("json" when the flag is absent), so the `?? "json"` fallback never
fires and `config.DefaultFormat` is never consulted. The env var and config-file format
settings are dead code: `scadabridge site list` always outputs JSON regardless of
`SCADALINK_FORMAT=table` or a `defaultFormat` entry in `~/.scadabridge/config.json`. The
`SCADABRIDGE_FORMAT=table` or a `defaultFormat` entry in `~/.scadabridge/config.json`. The
documented behaviour silently does not work.
**Recommendation**
@@ -312,14 +312,14 @@ wrong element types, and JSON null).
Credentials are supplied only via `--username` / `--password`. A password on the command
line is visible to any local user via the process list (`ps`, `/proc/<pid>/cmdline`) and is
typically persisted into shell history. Unlike the management URL — which can also come
from `SCADALINK_MANAGEMENT_URL` or the config file — there is no environment-variable
from `SCADABRIDGE_MANAGEMENT_URL` or the config file — there is no environment-variable
fallback, no `--password-stdin`, and no interactive prompt for the password. For a tool
explicitly intended for CI/CD automation this materially increases the chance of credential
leakage.
**Recommendation**
Add a `SCADALINK_PASSWORD` environment variable fallback and/or a `--password-stdin`
Add a `SCADABRIDGE_PASSWORD` environment variable fallback and/or a `--password-stdin`
option (read the password from stdin), and document that `--password` on the command line
is discouraged. Optionally prompt interactively when stdin is a TTY and no password was
supplied.
@@ -327,7 +327,7 @@ supplied.
**Resolution**
Resolved 2026-05-16 (commit pending). Root cause confirmed — credentials had no
non-command-line source. Added `SCADALINK_USERNAME` / `SCADALINK_PASSWORD` environment
non-command-line source. Added `SCADABRIDGE_USERNAME` / `SCADABRIDGE_PASSWORD` environment
fallbacks: `CliConfig.Load` now reads them into new `CliConfig.Username` / `Password`
properties (credentials are sourced from environment variables only, never the config
file, so they are not persisted). `CommandHelpers.ResolveCredential` resolves precedence
@@ -387,7 +387,7 @@ bind-connections`/`assign-area`, `data-connection assign/unassign`, `security ap
enable/disable`) are removed; previously-omitted commands (`instance alarm-override
set/delete/list`, `external-system method` subgroup, `site deploy-artifacts`) are added.
A note now points to `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` as the authoritative reference. The
Configuration section also documents the new `SCADALINK_USERNAME`/`SCADALINK_PASSWORD`
Configuration section also documents the new `SCADABRIDGE_USERNAME`/`SCADABRIDGE_PASSWORD`
env vars (see CLI-006).
### CLI-008 — `--format` value is not validated
@@ -817,7 +817,7 @@ and a caller-supplied success handler. In duplicating it, two contracts that
`2` = authorization failure," so this is a contract regression.
2. **Error-message phrasing drift.** The two duplicated error paths
(`bundle:258-260`, `:264-266`) emit shorter messages that omit the
`SCADALINK_MANAGEMENT_URL` / `SCADALINK_USERNAME` env-var hints the canonical paths
`SCADABRIDGE_MANAGEMENT_URL` / `SCADABRIDGE_USERNAME` env-var hints the canonical paths
give — confusing if the user is trying to debug what's missing.
**Recommendation**
@@ -230,7 +230,7 @@ Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the f
fell back to a literal `User Id=sa;Password=YourPassword;...` connection string when no
configured value was found. Removed the hardcoded fallback entirely. The factory now
resolves the connection string from the Host's appsettings files or, when those are not
present, from the `SCADALINK_DESIGNTIME_CONNECTIONSTRING` environment variable, and
present, from the `SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING` environment variable, and
throws a clear `InvalidOperationException` (naming both the config key and the env var)
when neither yields a value. Also hardened `SetBasePath` to be applied only when the
`ZB.MOM.WW.ScadaBridge.Host` directory exists, so the factory degrades cleanly instead of throwing
+214
View File
@@ -0,0 +1,214 @@
<mxfile host="app.diagrams.net">
<diagram id="arch-logical" name="Logical Architecture">
<mxGraphModel dx="1200" dy="900" grid="1" gridSize="10" guides="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1000" pageHeight="1200" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- top: users + load balancer -->
<mxCell id="users" value="Users (Blazor Server)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="430" y="20" width="180" height="40" as="geometry" />
</mxCell>
<mxCell id="lb" value="Load Balancer / Traefik" style="whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="430" y="92" width="180" height="40" as="geometry" />
</mxCell>
<mxCell id="e_users_lb" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="users" target="lb">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_lb_central" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="lb" target="central">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- central cluster container -->
<mxCell id="central" value="CENTRAL CLUSTER — 2-node active / standby" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fontSize=14;fillColor=#eef3fb;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="40" y="160" width="740" height="490" as="geometry" />
</mxCell>
<mxCell id="te" value="Template Engine" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="70" y="206" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="dm" value="Deployment Manager" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="300" y="206" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="ui" value="Central UI (Blazor Server)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="530" y="206" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="sec" value="Security &amp; Auth (JWT / LDAP)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="70" y="270" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="cfg" value="Configuration DB (EF + IAudit)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="300" y="270" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="health" value="Health Monitor" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="530" y="270" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="inapi" value="Inbound API" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="70" y="338" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="extsys" value="External Systems&#10;(X-API-Key)" style="shape=cloud;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="548" y="352" width="184" height="48" as="geometry" />
</mxCell>
<mxCell id="e_ext_in" value="POST /api/{method} · JSON" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;fontSize=10;" edge="1" parent="1" source="extsys" target="inapi">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="mgmt" value="Management Service" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="70" y="402" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="cli" value="CLI&#10;(ClusterClient)" style="shape=cloud;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="548" y="414" width="184" height="48" as="geometry" />
</mxCell>
<mxCell id="e_cli_mgmt" value="ManagementActor + Receptionist" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;fontSize=10;" edge="1" parent="1" source="cli" target="mgmt">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="obslabel" value="Observability / Audit" style="text;html=1;align=left;verticalAlign=middle;fontStyle=2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="70" y="456" width="300" height="18" as="geometry" />
</mxCell>
<mxCell id="ntf" value="Notification Outbox (#21)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
<mxGeometry x="70" y="478" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="sca" value="Site Call Audit (#22)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
<mxGeometry x="300" y="478" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="audit" value="Audit Log (#23)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
<mxGeometry x="530" y="478" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="comm" value="Akka.NET Communication Layer&#10;ClusterClient (command/control) · gRPC Client (real-time streams)" style="whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
<mxGeometry x="70" y="558" width="430" height="64" as="geometry" />
</mxCell>
<mxCell id="mssql" value="MS SQL&#10;Config DB · Machine Data DB" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="580" y="556" width="200" height="70" as="geometry" />
</mxCell>
<mxCell id="e_cfg_sql" value="EF Core" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;dashed=1;fontSize=10;exitX=1;exitY=0.7;entryX=0;entryY=0.4;" edge="1" parent="1" source="cfg" target="mssql">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="515" y="301" />
<mxPoint x="515" y="560" />
</Array>
</mxGeometry>
</mxCell>
<!-- ingests edges -->
<mxCell id="e_ing_ntf" value="ingests (S&amp;F)" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;dashed=1;fontSize=9;" edge="1" parent="1" source="comm" target="ntf">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_ing_sca" value="ingests (telemetry)" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;dashed=1;fontSize=9;" edge="1" parent="1" source="comm" target="sca">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_ing_audit" value="ingests (telemetry + direct-write)" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;dashed=1;fontSize=9;exitX=0.95;exitY=0;entryX=0.1;entryY=1;" edge="1" parent="1" source="comm" target="audit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- transport annotation between central and sites -->
<mxCell id="transport" value="Akka.NET Remoting (command/control) · gRPC HTTP/2 (real-time data, port 8083)" style="text;html=1;align=center;verticalAlign=middle;fontStyle=2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="120" y="662" width="580" height="28" as="geometry" />
</mxCell>
<!-- SITE A -->
<mxCell id="siteA" value="SITE A — 2-node" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#eafaf0;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="40" y="720" width="230" height="364" as="geometry" />
</mxCell>
<mxCell id="a_dcl" value="Data Connection Layer" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="758" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_rt" value="Site Runtime" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="806" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_grpc" value="gRPC Server" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="854" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_snf" value="Store-and-Forward Engine" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="902" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_esg" value="External System Gateway" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="950" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_sql" value="SQLite" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="85" y="1002" width="140" height="64" as="geometry" />
</mxCell>
<!-- SITE B -->
<mxCell id="siteB" value="SITE B — 2-node" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#eafaf0;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="295" y="720" width="230" height="364" as="geometry" />
</mxCell>
<mxCell id="b_dcl" value="Data Connection Layer" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="758" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_rt" value="Site Runtime" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="806" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_grpc" value="gRPC Server" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="854" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_snf" value="Store-and-Forward Engine" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="902" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_esg" value="External System Gateway" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="950" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_sql" value="SQLite" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="340" y="1002" width="140" height="64" as="geometry" />
</mxCell>
<!-- SITE N -->
<mxCell id="siteN" value="SITE N — 2-node" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#eafaf0;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="550" y="720" width="230" height="364" as="geometry" />
</mxCell>
<mxCell id="n_dcl" value="Data Connection Layer" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="758" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_rt" value="Site Runtime" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="806" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_grpc" value="gRPC Server" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="854" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_snf" value="Store-and-Forward Engine" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="902" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_esg" value="External System Gateway" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="950" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_sql" value="SQLite" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="595" y="1002" width="140" height="64" as="geometry" />
</mxCell>
<!-- central -> sites transport edges -->
<mxCell id="e_c_a" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;strokeWidth=1.5;" edge="1" parent="1" source="comm" target="siteA">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_c_b" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;strokeWidth=1.5;" edge="1" parent="1" source="comm" target="siteB">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_c_n" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;strokeWidth=1.5;" edge="1" parent="1" source="comm" target="siteN">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- field protocol per site -->
<mxCell id="a_proto" value="OPC UA / Custom Protocol" style="whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="60" y="1100" width="190" height="36" as="geometry" />
</mxCell>
<mxCell id="b_proto" value="OPC UA / Custom Protocol" style="whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="315" y="1100" width="190" height="36" as="geometry" />
</mxCell>
<mxCell id="n_proto" value="OPC UA / Custom Protocol" style="whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="570" y="1100" width="190" height="36" as="geometry" />
</mxCell>
<mxCell id="e_a_proto" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="a_dcl" target="a_proto">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_b_proto" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="b_dcl" target="b_proto">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_n_proto" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="n_dcl" target="n_proto">
<mxGeometry relative="1" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

+10
View File
@@ -73,6 +73,16 @@ bash docker-env2/deploy.sh
bash docker-env2/seed-sites.sh
```
The seed also creates a **native alarm source demo**: a `MxAlarmDemo` template with a
`GalaxyAlarms` native alarm source bound to the shared MxGateway connection, plus a
deployed `MxAlarmDemo-1` instance on site-x. This exercises the read-only native alarm
mirror end-to-end — mirrored alarms appear live in the Central UI Debug View with severity
and condition badges. Verify the binding with:
```bash
scadabridge --url http://localhost:9100 template native-alarm-source list --template-id <id>
```
### After Code Changes
```bash
+4 -4
View File
@@ -3,7 +3,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-central-a
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -20,7 +20,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-central-b
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -37,7 +37,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-site-x-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9121:8082" # Akka remoting
- "9123:8083" # gRPC streaming
@@ -53,7 +53,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-site-x-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9122:8082" # Akka remoting
- "9124:8083" # gRPC streaming
+53
View File
@@ -83,12 +83,65 @@ IF NOT EXISTS (SELECT 1 FROM LdapGroupMappings WHERE Id = 4)
SET IDENTITY_INSERT LdapGroupMappings OFF;
"
echo ""
echo "Creating MxAlarm demo template + native alarm source binding..."
# Mirrors native MxAccess Gateway alarms (read-only) into ScadaBridge. The
# binding points at the shared 'ScadaBridge Site X' MxGateway connection and a
# representative MxAccess area/object. Requires the Design role (granted above).
if [ -n "$SITE_X_ID" ]; then
CONN_NAME="ScadaBridge Site X"
TEMPLATE_NAME="MxAlarmDemo"
# Representative MxAccess area object to mirror alarms from. Adjust to a real
# Galaxy area present on the gateway.
NAS_SOURCE_REF="\$Area_001"
$CLI $URL $AUTH template create \
--name "$TEMPLATE_NAME" \
--description "Demo: mirrors native MxAccess Gateway alarms (read-only)" \
|| echo " ('$TEMPLATE_NAME' may already exist)"
TEMPLATE_ID=$($CLI $URL $AUTH --format json template list \
| python3 -c "import sys,json; print(next((t['id'] for t in json.load(sys.stdin) if t.get('name')=='$TEMPLATE_NAME'), ''))" 2>/dev/null)
if [ -n "$TEMPLATE_ID" ]; then
echo " template '$TEMPLATE_NAME' (id=$TEMPLATE_ID): adding native alarm source 'GalaxyAlarms'..."
$CLI $URL $AUTH template native-alarm-source add \
--template-id "$TEMPLATE_ID" \
--name "GalaxyAlarms" \
--connection "$CONN_NAME" \
--source-ref "$NAS_SOURCE_REF" \
|| echo " ('GalaxyAlarms' may already exist on the template)"
echo " creating instance 'MxAlarmDemo-1' at site-x..."
$CLI $URL $AUTH instance create \
--name "MxAlarmDemo-1" \
--template-id "$TEMPLATE_ID" \
--site-id "$SITE_X_ID" \
|| echo " ('MxAlarmDemo-1' may already exist)"
else
echo " (template '$TEMPLATE_NAME' not found — skipping native alarm source seed)"
fi
else
echo " (site-x not found — skipping native alarm source demo seed)"
fi
echo ""
echo "Deploying artifacts to all env2 sites (pushes the data connection so the"
echo "site establishes it — the MxGateway DataConnectionActor connects eagerly)..."
$CLI $URL $AUTH deploy artifacts \
|| echo " (artifact deploy reported an issue — check 'deploy status')"
echo ""
echo "Deploying the MxAlarm demo instance to site-x..."
INSTANCE_ID=$($CLI $URL $AUTH --format json instance list \
| python3 -c "import sys,json; print(next((i['id'] for i in json.load(sys.stdin) if i.get('uniqueName')=='MxAlarmDemo-1'), ''))" 2>/dev/null)
if [ -n "$INSTANCE_ID" ]; then
$CLI $URL $AUTH instance deploy --id "$INSTANCE_ID" \
|| echo " (instance deploy reported an issue — check 'deploy status')"
else
echo " (MxAlarmDemo-1 not found — skipping instance deploy)"
fi
echo ""
echo "=== Env2 site seeding complete ==="
echo ""
+8 -8
View File
@@ -3,7 +3,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-central-a
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -20,7 +20,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-central-b
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -37,7 +37,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-a-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9021:8082" # Akka remoting (host access for debugging)
- "9023:8083" # gRPC streaming
@@ -53,7 +53,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-a-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9022:8082" # Akka remoting
- "9024:8083" # gRPC streaming
@@ -69,7 +69,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-b-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9031:8082" # Akka remoting
- "9033:8083" # gRPC streaming
@@ -85,7 +85,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-b-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9032:8082" # Akka remoting
- "9034:8083" # gRPC streaming
@@ -101,7 +101,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-c-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9041:8082" # Akka remoting
- "9043:8083" # gRPC streaming
@@ -117,7 +117,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-c-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9042:8082" # Akka remoting
- "9044:8083" # gRPC streaming
+44 -17
View File
@@ -6,23 +6,50 @@ ScadaBridge uses a hub-and-spoke architecture:
- **Central Cluster**: Two-node active/standby Akka.NET cluster for management, UI, and coordination.
- **Site Clusters**: Two-node active/standby Akka.NET clusters at each remote site for data collection and local processing.
```
┌──────────────────────────┐
│ Central Cluster │
│ ┌──────┐ ┌──────┐ │
Users ──────────► │ │Node A│◄──►│Node B│ │
(HTTPS/LB) │ │Active│ │Stby │ │
│ └──┬───┘ └──┬───┘ │
└─────┼───────────┼────────┘
│ │
┌───────────┼───────────┼───────────┐
│ │ │ │
┌─────▼─────┐ ┌──▼──────┐ ┌──▼──────┐ ┌──▼──────┐
│ Site 01 │ │ Site 02 │ │ Site 03 │ │ Site N │
│ ┌──┐ ┌──┐ │ │ ┌──┐┌──┐│ │ ┌──┐┌──┐│ │ ┌──┐┌──┐│
│ │A │ │B │ │ │ │A ││B ││ │ │A ││B ││ │ │A ││B ││
│ └──┘ └──┘ │ │ └──┘└──┘│ │ └──┘└──┘│ │ └──┘└──┘│
└───────────┘ └─────────┘ └─────────┘ └─────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
USERS["Users<br/>(HTTPS / LB)"]
subgraph CENTRAL["Central Cluster"]
NA["Node A<br/>Active"]
NB["Node B<br/>Standby"]
NA <--> NB
end
USERS --> NA
CENTRAL --> SITE01
CENTRAL --> SITE02
CENTRAL --> SITE03
CENTRAL --> SITEN
subgraph SITE01["Site 01"]
S01A["A<br/>Active"]
S01B["B<br/>Standby"]
end
subgraph SITE02["Site 02"]
S02A["A<br/>Active"]
S02B["B<br/>Standby"]
end
subgraph SITE03["Site 03"]
S03A["A<br/>Active"]
S03B["B<br/>Standby"]
end
subgraph SITEN["Site N"]
SNA["A<br/>Active"]
SNB["B<br/>Standby"]
end
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class USERS dec
class CENTRAL proc
class NA,S01A,S02A,S03A,SNA start
class NB,S01B,S02B,S03B,SNB muted
class SITE01,SITE02,SITE03,SITEN warn
```
## Central Cluster Setup
@@ -39,27 +39,42 @@ Both endpoints use the same `Protocol`. EF Core migration renames `Configuration
The `DataConnectionActor` Reconnecting state is extended:
```
Connected
│ disconnect detected
Push bad quality to all subscribers
Retry active endpoint (5s interval)
│ failure
_consecutiveFailures++
├─ < FailoverRetryCount → retry same endpoint
├─ ≥ FailoverRetryCount AND backup exists
│ → dispose adapter, switch _activeEndpoint, reset counter
│ → create fresh adapter with other config
│ → attempt connect
└─ ≥ FailoverRetryCount AND no backup
→ keep retrying indefinitely (current behavior)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
C(["Connected"])
BQ["Push bad quality<br/>to all subscribers"]
RT["Retry active endpoint<br/>(5s interval)"]
INC["_consecutiveFailures++"]
BR{"Evaluate<br/>_consecutiveFailures"}
SAME["Retry same endpoint"]
FO["Failover<br/>- dispose adapter, switch _activeEndpoint, reset counter<br/>- create fresh adapter with other config<br/>- attempt connect"]
NB["Keep retrying indefinitely<br/>(current behavior)"]
RC(["On successful reconnect (either endpoint)<br/>1. Reset _consecutiveFailures = 0<br/>2. ReSubscribeAll() — re-create subscriptions on new adapter<br/>3. Transition to Connected<br/>4. Log failover event if endpoint changed<br/>5. Report active endpoint in health metrics"])
C -->|"disconnect detected"| BQ
BQ --> RT
RT -->|"failure"| INC
INC --> BR
BR -->|"&lt; FailoverRetryCount"| SAME
SAME -.->|"retry"| RT
BR -->|"&gt;= FailoverRetryCount AND backup exists"| FO
BR -->|"&gt;= FailoverRetryCount AND no backup"| NB
NB -.->|"retry (round-robin n/a)"| RT
FO -->|"connect succeeds"| RC
FO -.->|"connect fails (round-robin: primary to backup to primary...)"| RT
RC -->|"Transition to Connected"| C
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class C,RC start
class BQ,RT,SAME proc
class INC,BR dec
class FO warn
class NB bad
```
**On successful reconnect (either endpoint):**
@@ -32,33 +32,45 @@ We want a strongly-typed model for OPC UA endpoint configuration, a validator th
## Architecture
```
┌──────────────────────────────────────┐
│ ZB.MOM.WW.ScadaBridge.Commons │
│ Types/DataConnections/ │
OpcUaEndpointConfig.cs (POCO)
│ OpcUaHeartbeatConfig.cs (POCO) │
│ OpcUaSecurityMode.cs (enum) │
│ Validators/ │
│ OpcUaEndpointConfigValidator.cs │
│ Serialization/ │
│ OpcUaEndpointConfigSerializer.cs │
└──────────────────────────────────────┘
│ (referenced by both)
┌───────┴────────────────────────┐
▼ ▼
┌──────────────────────────┐ ┌────────────────────────────┐
│ ZB.MOM.WW.ScadaBridge.CentralUI │ │ ZB.MOM.WW.ScadaBridge.SiteRuntime │
│ Components/Forms/ │ │ Actors/ │
│ OpcUaEndpointEditor │ │ DeploymentManagerActor │
│ .razor (shared) │ │ (passes raw JSON to │
│ │ │ DataConnectionFactory)│
│ Pages/Admin/ │ │ │
│ DataConnectionForm │ │ DataConnections.OpcUa/ │
│ .razor │ │ OpcUaDataConnection.cs │
└──────────────────────────┘ │ (consumes typed model) │
└────────────────────────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
subgraph COMMONS["ZB.MOM.WW.ScadaBridge.Commons"]
TYPES["Types/DataConnections/<br/>OpcUaEndpointConfig.cs (POCO)<br/>OpcUaHeartbeatConfig.cs (POCO)<br/>OpcUaSecurityMode.cs (enum)"]
VALID["Validators/<br/>OpcUaEndpointConfigValidator.cs"]
SER["Serialization/<br/>OpcUaEndpointConfigSerializer.cs"]
TYPES ~~~ VALID ~~~ SER
end
subgraph CENTRALUI["ZB.MOM.WW.ScadaBridge.CentralUI"]
CUIFORMS["Components/Forms/<br/>OpcUaEndpointEditor.razor (shared)"]
CUIPAGES["Pages/Admin/<br/>DataConnectionForm.razor"]
CUIFORMS ~~~ CUIPAGES
end
subgraph SITERUNTIME["ZB.MOM.WW.ScadaBridge.SiteRuntime"]
SRACTORS["Actors/<br/>DeploymentManagerActor<br/>(passes raw JSON to DataConnectionFactory)"]
SRDC["DataConnections.OpcUa/<br/>OpcUaDataConnection.cs<br/>(consumes typed model)"]
SRACTORS ~~~ SRDC
end
COMMONS -->|referenced by| CENTRALUI
COMMONS -->|referenced by| SITERUNTIME
NOTE["Both sides deserialize DataConnection.PrimaryConfiguration / BackupConfiguration<br/>into the same OpcUaEndpointConfig instance. The DB column type does not change."]
CENTRALUI -.- NOTE
SITERUNTIME -.- NOTE
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class COMMONS dec
class TYPES,VALID,SER warn
class CENTRALUI,CUIFORMS,CUIPAGES proc
class SITERUNTIME,SRACTORS,SRDC start
class NOTE muted
```
Both sides deserialize from `DataConnection.PrimaryConfiguration` / `BackupConfiguration` strings into the same `OpcUaEndpointConfig` instance. The DB column type does not change.
@@ -165,7 +165,7 @@ Five indexes with explicit names:
- `scadabridge_audit_writer`: GRANT INSERT ON AuditLog; GRANT SELECT ON AuditLog. (No UPDATE, no DELETE.)
- `scadabridge_audit_purger`: GRANT ALTER ON SCHEMA::dbo; GRANT SELECT ON AuditLog. (Enables ALTER PARTITION FUNCTION SWITCH and SWITCH PARTITION.)
- `Down()` drops indexes, table, scheme, function, then both roles.
- Create: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs` — uses a fixture connecting to the running `infra/mssql` container via the connection string in `infra/mssql/.env` (or skips with `Skip.If` when the env var `SCADALINK_MSSQL_TEST_CONN` is unset, so CI without the container still passes).
- Create: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs` — uses a fixture connecting to the running `infra/mssql` container via the connection string in `infra/mssql/.env` (or skips with `Skip.If` when the env var `SCADABRIDGE_MSSQL_TEST_CONN` is unset, so CI without the container still passes).
Integration test assertions:
- `sys.partition_functions` contains `pf_AuditLog_Month`.
@@ -191,10 +191,10 @@ Integration test assertions:
**Notes for the implementer:**
- Use `Microsoft.Data.SqlClient` directly in the test fixture (not EF) to issue raw SQL for grant assertions.
- `Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SCADALINK_MSSQL_TEST_CONN")), "MSSQL not available")` — keeps tests CI-safe.
- `Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SCADABRIDGE_MSSQL_TEST_CONN")), "MSSQL not available")` — keeps tests CI-safe.
- Test database name: `ScadaBridgeAuditMigrationTest_<guid>` (created per fixture, dropped on dispose).
**Bundle C acceptance:** Migration applied to a fresh test DB on the `infra/mssql` container creates the partition function/scheme/table/indexes/roles. Smoke test confirms UPDATE is denied for the writer role. All migration tests pass when `SCADALINK_MSSQL_TEST_CONN` is set; skip cleanly when unset.
**Bundle C acceptance:** Migration applied to a fresh test DB on the `infra/mssql` container creates the partition function/scheme/table/indexes/roles. Smoke test confirms UPDATE is denied for the writer role. All migration tests pass when `SCADABRIDGE_MSSQL_TEST_CONN` is set; skip cleanly when unset.
---
@@ -17,29 +17,8 @@ A sibling `docker-env2/` directory with `deploy.sh` / `teardown.sh` / `seed-site
## Architecture Overview
```
(host machine)
Primary stack (already existing — unchanged) Env2 stack (new)
┌────────────────────────────────────┐ ┌──────────────────────────────┐
│ Traefik :9000 ◄── 9001/9002 UI │ │ Traefik :9100 ◄── 9101/9102 UI│
│ Central A/B (9011/9012 Akka) │ │ Central A/B (9111/9112 Akka) │
│ Site-A/B/C (9021..9044) │ │ Site-X (9121/9122 Akka, │
└─────────────┬──────────────────────┘ │ 9123/9124 gRPC) │
│ └──────────┬───────────────────┘
│ │
▼ scadabridge-net (shared bridge network) ◄──────┘
┌──────────────────────────────────────────────────────────────┐
│ scadabridge-mssql ScadaBridgeConfig (primary DB) │
│ ScadaBridgeMachineData (primary DB) │
│ ScadaBridgeConfig2 (env2 DB) ← new │
│ ScadaBridgeMachineData2(env2 DB) ← new │
│ scadabridge-ldap (shared — same test users) │
│ scadabridge-smtp (shared Mailpit) │
│ scadabridge-opcua (shared) │
│ scadabridge-restapi (shared) │
└──────────────────────────────────────────────────────────────┘
```
![env2-architecture-overview](diagrams/env2-architecture-overview.png)
<!-- source: diagrams/env2-architecture-overview.drawio — edit, then re-export with export-drawio.sh -->
Both stacks attach to the same `scadabridge-net` Docker bridge so env2's app containers can reach the infra services by container hostname (`scadabridge-mssql`, `scadabridge-ldap`, etc.). Akka clusters are independent — each side's `SeedNodes` lists only its own central nodes, so they never gossip-merge despite sharing the network.
+43 -18
View File
@@ -14,18 +14,43 @@
## Task Dependency Graph
```
T0 ─┐ ┐
T1 ─┤ (all independent, all │
T2 ─┤ parallelizable, all ├─► T10 (manual smoke test)
T3 ─┤ ready from the start) │
T4 ─┤ │
T6 ─┤ │
T7 ─┤ │
T8 ─┤ │
T9 ─┘ │
T0,T4 ──► T5 (lifecycle scripts) ─────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart LR
GRP["all independent, all parallelizable, all ready from the start"]
T0["T0"]
T1["T1"]
T2["T2"]
T3["T3"]
T4["T4"]
T6["T6"]
T7["T7"]
T8["T8"]
T9["T9"]
T5["T5<br/>lifecycle scripts"]
T10(["T10<br/>manual smoke test"])
NOTE["T10 is the only task that requires all of T0T9 done. Everything else runs in parallel."]
T0 --> T10
T1 --> T10
T2 --> T10
T3 --> T10
T6 --> T10
T7 --> T10
T8 --> T10
T9 --> T10
T0 --> T5
T4 --> T5
T5 --> T10
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class T0,T1,T2,T3,T4,T6,T7,T8,T9 proc
class T5 warn
class T10 start
class GRP,NOTE muted
```
T10 is the only task that requires all of T0T9 done. Everything else can run in parallel.
@@ -414,7 +439,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-central-a
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -431,7 +456,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-central-b
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -448,7 +473,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-site-x-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9121:8082" # Akka remoting
- "9123:8083" # gRPC streaming
@@ -464,7 +489,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-site-x-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9122:8082" # Akka remoting
- "9124:8083" # gRPC streaming
@@ -760,7 +785,7 @@ Mirrors `docker/README.md`'s structure but documents the env2 specifics. Reuses
A second Docker deployment of a minimal ScadaBridge cluster topology, designed to run **concurrently with** the primary `docker/` stack so the Transport (#24) feature can be exercised end-to-end across two real environments.
See [`docs/plans/2026-05-24-second-environment-design.md`](../docs/plans/2026-05-24-second-environment-design.md) for the design rationale.
See [`docs/plans/2026-05-24-second-environment-design.md`](2026-05-24-second-environment-design.md) for the design rationale.
## Cluster Topology
@@ -886,7 +911,7 @@ Same as primary (env2 shares LDAP). See `infra/glauth/config.toml` and primary `
## Transport Testing Workflow
See [`docs/plans/2026-05-24-second-environment-verification.md`](../docs/plans/2026-05-24-second-environment-verification.md) for the manual golden-path checklist.
See [`docs/plans/2026-05-24-second-environment-verification.md`](2026-05-24-second-environment-verification.md) for the manual golden-path checklist.
## What's Different from Primary
+1 -1
View File
@@ -81,7 +81,7 @@ Exactly one of `content.json` or `content.enc` is present.
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
"scadaLinkVersion": "1.4.2",
"scadaBridgeVersion": "1.4.2",
"contentHash": "sha256:...",
"encryption": {
"algorithm": "AES-256-GCM",
+1 -1
View File
@@ -734,7 +734,7 @@ Tests:
**Step 2-5:** Run-fail → implement → run-pass → commit.
`ManifestBuilder` accepts: `sourceEnvironment, exportedBy, scadaLinkVersion, encryption?, contents[], contentBytes` and returns a `BundleManifest` with `ContentHash = SHA-256(contentBytes)`.
`ManifestBuilder` accepts: `sourceEnvironment, exportedBy, scadaBridgeVersion, encryption?, contents[], contentBytes` and returns a `BundleManifest` with `ContentHash = SHA-256(contentBytes)`.
`ManifestValidator.Validate(BundleManifest manifest, byte[] contentBytes)` returns a `ValidationResult` enum (`Ok | UnsupportedFormatVersion | ContentHashMismatch | MalformedManifest`).
@@ -18,26 +18,32 @@
## Section 1 — Architecture
```
[Blazor Server browser]
│ SignalR
[CentralUI: InstanceConfigure.razor]
│ opens
[CentralUI: <OpcUaBrowserDialog/>]
│ uses
[CentralUI: IOpcUaBrowseService] ── implementation calls
[CommunicationService.SendCommandToSiteAsync<BrowseOpcUaNodeResult>(siteId, BrowseOpcUaNodeCommand)]
│ ClusterClient Ask, ManagementEnvelope { User, Command, CorrelationId }
[Site: CentralCommunicationActor → DataConnectionManagerActor]
│ dispatches to IBrowsableDataConnection (RealOpcUaClient)
[OPC UA server] ◄── OPC Foundation .NET SDK Browse service
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
N1["Blazor Server browser"]
N2["CentralUI: InstanceConfigure.razor"]
N3["CentralUI: OpcUaBrowserDialog component"]
N4["CentralUI: IOpcUaBrowseService"]
N5["CommunicationService.SendCommandToSiteAsync of BrowseOpcUaNodeResult (siteId, BrowseOpcUaNodeCommand)"]
N6["Site: CentralCommunicationActor → DataConnectionManagerActor"]
N7["OPC UA server"]
N1 -->|SignalR| N2
N2 -->|opens| N3
N3 -->|uses| N4
N4 -->|implementation calls| N5
N5 -->|"ClusterClient Ask<br/>ManagementEnvelope { User, Command, CorrelationId }"| N6
N6 -->|"dispatches to IBrowsableDataConnection (RealOpcUaClient)<br/>OPC Foundation .NET SDK Browse service"| N7
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class N1,N2,N3,N4 proc
class N5 alt
class N6 start
class N7 warn
```
Three slices, top-to-bottom:
@@ -164,24 +170,47 @@ Returning failure inside `BrowseOpcUaNodeResult` (rather than exceptions across
**Wire flow.**
```
CentralUI.OpcUaBrowseService.BrowseChildrenAsync(siteId, connId, parent)
→ CommunicationService.SendCommandToSiteAsync<BrowseOpcUaNodeResult>(
siteId,
new BrowseOpcUaNodeCommand(connId, parent))
ManagementEnvelope { User, Command, CorrelationId } over ClusterClient
Site: CentralCommunicationActor unwraps envelope
Site: DataConnectionManagerActor receives BrowseOpcUaNodeCommand
- Look up IDataConnection by Id
- if not found → ConnectionNotFound
- if !(conn is IBrowsableDataConnection) → NotBrowsable
- else await conn.BrowseChildrenAsync(ParentNodeId, ct)
- Catch ConnectionNotConnectedException → ConnectionNotConnected
- Catch OperationCanceledException → Timeout
- Catch ServiceResultException → ServerError + verbatim msg
- Else success: BrowseOpcUaNodeResult(children, truncated, null)
→ Reply travels back via CentralCommunicationActor → CommunicationService
→ returned to CentralUI page
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
S1["CentralUI.OpcUaBrowseService.BrowseChildrenAsync(siteId, connId, parent)"]
S2["CommunicationService.SendCommandToSiteAsync of BrowseOpcUaNodeResult (siteId, new BrowseOpcUaNodeCommand(connId, parent))"]
S3["ManagementEnvelope { User, Command, CorrelationId }<br/>over ClusterClient"]
S4["Site: CentralCommunicationActor unwraps envelope"]
S5["Site: DataConnectionManagerActor receives BrowseOpcUaNodeCommand<br/>(DCL coordinator actor — owns the per-connection IDataConnection instances)"]
S1 --> S2 --> S3 --> S4 --> S5
subgraph HANDLER["Handler logic"]
direction TB
HL["Look up IDataConnection by Id"]
HNF["if not found → ConnectionNotFound"]
HNB["if not (conn is IBrowsableDataConnection) → NotBrowsable"]
HAW["else await conn.BrowseChildrenAsync(ParentNodeId, ct)"]
HNC["Catch ConnectionNotConnectedException → ConnectionNotConnected"]
HCN["Catch OperationCanceledException → Timeout"]
HSVC["Catch ServiceResultException → ServerError + verbatim msg"]
HSUC["Else success: BrowseOpcUaNodeResult(children, truncated, null)"]
HL --- HNF --- HNB --- HAW --- HNC --- HCN --- HSVC --- HSUC
end
S5 -->|processes| HANDLER
R1["Reply travels back via<br/>CentralCommunicationActor → CommunicationService"]
R2["returned to CentralUI page"]
HANDLER -->|result / failure| R1
R1 --> R2
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class S1,R1,R2 proc
class S2,S3 alt
class S4,S5,HSUC start
class HANDLER,HL,HAW dec
class HNF,HNB,HNC,HCN,HSVC bad
```
Handler lives in the **DCL coordinator actor** (the same actor that owns the per-connection `IDataConnection` instances) — keeps lifecycle and browse co-located so we don't race against reconnect.
@@ -0,0 +1,212 @@
# Native OPC UA & MxAccess Gateway Alarms — Design
**Date:** 2026-05-29
**Status:** Approved
## Problem
Today alarms are **computed at the site**: a `TemplateAlarm` defines a trigger (ValueMatch, RangeViolation, RateOfChange, HiLo, Expression); one `AlarmActor` per alarm evaluates attribute values and emits `AlarmStateChanged` carrying a bare `AlarmState { Active, Normal }` plus an integer `Priority` and (for HiLo) a `Level`. State is in-memory only — there is **no severity dimension, no acknowledgement, no shelve/suppress state, and no operator metadata** — and it surfaces only in the per-instance DebugView.
Two data sources we connect to own their own alarm lifecycle and expose far richer state:
- **OPC UA Alarms & Conditions (Part 9)** — the server raises/acks/clears `AlarmCondition` nodes with orthogonal sub-states (Active/Inactive, Acked/Unacked, Confirmed/Unconfirmed, Shelved, Suppressed) and a 11000 severity. The DCL OPC UA adapter currently subscribes only to the `Value` attribute.
- **MxAccess Gateway** — already exposes a session-less `StreamAlarms` feed (`OnAlarmTransitionEvent`: raise/ack/clear/retrigger, severity, operator user + comment, category, description, current/limit value) plus `QueryActiveAlarms`. The DCL MxGateway adapter currently consumes only the `OnDataChange` event family.
These are **mirrored** alarms — the source is the source of truth — which is a real divergence from the computed model. This design enriches the alarm tracking model to carry severity + ack/shelve/suppress state, and ingests native alarms from both sources.
## Design Decisions
| Decision | Choice |
|----------|--------|
| State model scope | **Unified** A&C-style state model for *all* alarms (computed + native) |
| Interactivity | **Read-only mirror** — display source-reported state; no acking/shelving from ScadaBridge, no command relay, no operator identity captured by ScadaBridge (source-supplied operator user/comment *are* displayed for native alarms) |
| Binding | Instance declares a `NativeAlarmSource` (connection + source ref); conditions under it are **discovered at runtime**, keyed by source reference |
| State location | **Site-only**, persisted to SQLite (survives restart/failover); central **queries live** (snapshot + live stream); **no central tables, no central history** |
| MxGateway transport | Gateway session-less `StreamAlarms` feed |
| OPC UA transport | Alarms & Conditions events + `ConditionRefresh` snapshot |
| Site actor structure | New `NativeAlarmActor` child of `InstanceActor`, peer to computed `AlarmActor`s (Approach 1) |
| Authoring | Central UI design-time panels (Template editor + Instance Configure) **and** CLI |
| Runtime UI | Enrich the per-instance DebugView alarm table only (no new operator page) |
### Trade-offs accepted
- **No central audit trail of alarms** (who acked, history). Acceptable because the source systems own ack and retain their own alarm history; ScadaBridge is a read-only window. If audit of alarm state is later wanted, a central mirror following the Site Call Audit (#22) pattern can be added without disturbing this design.
- **Read-only** means MxGateway/OPC UA acknowledgements happen in the source's own tools; ScadaBridge reflects them.
---
## Section 1 — Unified state model & wire contracts
**New Commons types** (`Types/Enums/`, `Types/Alarms/`):
```
enum AlarmKind { Computed, NativeOpcUa, NativeMxAccess }
enum AlarmShelveState { Unshelved, OneShotShelved, TimedShelved, PermanentShelved }
record AlarmConditionState(
bool Active, // Active vs Inactive
bool Acknowledged, // Acked / Unacked
bool? Confirmed, // null = not a confirmable condition (OPC UA optional)
AlarmShelveState Shelve,
bool Suppressed,
int Severity) // 01000, unified scale
```
The OPC UA Part 9 sub-conditions are **orthogonal** and MxAccess's `ACTIVE / ACTIVE_ACKED / INACTIVE` maps cleanly onto them, so they are modeled as independent flags (the UI rolls them up for display).
**`AlarmStateChanged` is extended additively** (existing fields kept for back-compat; new fields defaulted):
| New field | Default | Notes |
|-----------|---------|-------|
| `Kind` | `Computed` | discriminator |
| `Condition` | computed from existing | the `AlarmConditionState` above |
| `SourceReference` | `""` | native key, e.g. `"Tank01.Level.HiHi"` |
| `AlarmTypeName` | `""` | native, e.g. `"AnalogLimitAlarm.HiHi"` |
| `Category` | `""` | native taxonomy |
| `OperatorUser` | `""` | native ack metadata (display-only) |
| `OperatorComment` | `""` | native ack metadata (display-only) |
| `OriginalRaiseTime` | `null` | native |
| `CurrentValue` | `""` | native, display |
| `LimitValue` | `""` | native, display |
**Identity / key:** computed alarms key by `AlarmName` (canonical); native alarms key by `SourceReference` (stable across transitions). `Kind` discriminates. The existing `AlarmName` field carries the source reference's display form for native rows so existing consumers don't break.
**Source → `AlarmConditionState` mapping:**
- **Computed:** `Active = state==Active`, `Acknowledged = true` (auto), `Confirmed = null`, `Shelve = Unshelved`, `Suppressed = false`, `Severity = Priority`, `Level` retained for HiLo.
- **OPC UA A&C:** read `ActiveState`, `AckedState`, `ConfirmedState`, `ShelvingState`, `SuppressedState`, `Severity` from the condition's event fields.
- **MxAccess:** `ACTIVE → (Active=t, Ack=f)`, `ACTIVE_ACKED → (Active=t, Ack=t)`, `INACTIVE → (Active=f)`; `Severity` from the gateway's remapped 01000; shelve/suppress default (gateway proto doesn't surface them).
**gRPC `AlarmStateUpdate` (`sitestream.proto`)** gets the same fields appended as new field numbers (additive — never renumber/remove): `kind`, `active`, `acknowledged`, `confirmed`, `shelve_state`, `suppressed`, `source_reference`, `alarm_type_name`, `category`, `operator_user`, `operator_comment`, `original_raise_time`, `current_value`, `limit_value`. Existing `state`, `priority`, `level`, `message` stay for compatibility.
---
## Section 2 — Configuration, binding & deployment
This mirrors how template **attributes bind to a data source** today (template declares, instance overrides the concrete reference).
**New Commons entities**
- `TemplateNativeAlarmSource` (`Entities/Templates/`): `Id`, `TemplateId`, `Name` (unique within template), `Description?`, `ConnectionName`, `SourceReference` (OPC UA SourceNode/notifier nodeId, or MxAccess object/area), `ConditionFilter?` (null = mirror *all* conditions under the source), `IsLocked`, `IsInherited` — same lock/inherit bookkeeping as `TemplateAlarm`.
- `InstanceNativeAlarmSourceOverride` (`Entities/Instances/`): `Id`, `InstanceId`, `SourceCanonicalName`, `ConnectionNameOverride?`, `SourceReferenceOverride?`, `ConditionFilterOverride?`; unique `(InstanceId, SourceCanonicalName)`. `SourceReference` is the field that varies per physical instance, so per-instance override is the common case.
**Flattening** (`FlatteningService`, `FlattenedConfiguration`)
- New `ResolvedNativeAlarmSource { CanonicalName, ConnectionName, SourceReference, ConditionFilter?, Source }`, resolved through the same steps as `ResolvedAlarm`: inherited → composed (path-qualified `[Module].[Name]`) → instance overrides applied.
- **Pre-deployment semantic validation** (extends existing checks): `ConnectionName` resolves to a real site `DataConnection`; that connection's protocol is alarm-capable (`OpcUa` or `MxGateway`); `SourceReference` non-empty; canonical-name collision check.
**ConfigurationDatabase (EF + migration)**
- `TemplateNativeAlarmSourceConfiguration` → table `Templates.NativeAlarmSources`, unique `(TemplateId, Name)`, FK cascade.
- `InstanceNativeAlarmSourceOverrideConfiguration` → table `InstanceNativeAlarmSourceOverrides`, unique `(InstanceId, SourceCanonicalName)`, FK cascade.
- One migration adds both tables (auto-apply in dev per existing convention).
**Deployment** — `FlattenedConfiguration` carries `ResolvedNativeAlarmSource[]`, deployed alongside `ResolvedAlarm[]` on the existing artifact path. Site Runtime consumes them when building the instance actor hierarchy. All-or-nothing per-instance apply unchanged.
**Authoring (Central UI + CLI)**
- Template editor: a "Native Alarm Sources" subsection (name, connection dropdown filtered to alarm-capable protocols, source reference, optional filter).
- Instance Configure: override connection/source-ref/filter per instance, like attribute data-source overrides.
- CLI: `template native-alarm-source add/list/remove`, `instance native-alarm-source set/clear`.
---
## Section 3 — DCL ingestion & the two adapters
**Capability seam** (mirrors the existing `IBrowsableDataConnection` pattern):
```
interface IAlarmSubscribableConnection {
Task<string> SubscribeAlarmsAsync(string sourceReference, string? conditionFilter, AlarmTransitionCallback cb);
Task UnsubscribeAlarmsAsync(string subscriptionId);
}
delegate void AlarmTransitionCallback(NativeAlarmTransition t);
```
**Protocol-neutral transition** (`Commons/Types/Alarms/`):
```
enum AlarmTransitionKind { Snapshot, SnapshotComplete, Raise, Acknowledge, Clear, Retrigger, StateChange }
record NativeAlarmTransition(
string SourceReference, string SourceObjectReference, string AlarmTypeName,
AlarmTransitionKind Kind, AlarmConditionState Condition,
string Category, string Description, string Message,
string OperatorUser, string OperatorComment,
DateTimeOffset? OriginalRaiseTime, DateTimeOffset TransitionTime,
string CurrentValue, string LimitValue)
```
`Snapshot`/`SnapshotComplete` carry the initial active-condition replay so the consumer re-seeds a source's state on every (re)subscribe — this is how reconnect reconciliation works without central storage.
**Connection-level transport + source-ref routing.** Although binding is *declared* per-instance, the subscription is naturally **connection-level** (OPC UA wants one event subscription; MxGateway `StreamAlarms` is one session-less feed). `DataConnectionActor` opens **one** alarm feed per connection and maintains `_alarmSubscribers: SourceObjectRef → set<instance actorRef>`, routing each transition to matching instances.
New messages (`Messages/DataConnection/`): `SubscribeAlarmsRequest`/`Response`, `UnsubscribeAlarmsRequest`, internal `NativeAlarmTransitionReceived(conn, transition, generation)`, forwarded as `NativeAlarmTransitionUpdate(conn, transition)`. Subscribe/unsubscribe obey the existing **Become/Stash** lifecycle (stashed while Connecting/Reconnecting, replayed on Connected). The stale-callback **generation guard** and once-only disconnect guard apply unchanged. On disconnect the actor emits a per-source `NativeAlarmSourceUnavailable` so consumers mark mirrored alarms *uncertain* rather than clearing them.
**OPC UA A&C adapter** (`OpcUaDataConnection` / `RealOpcUaClient`)
- One event `MonitoredItem` (`AttributeId = EventNotifier`) on the Server object (i=2253) or configured notifier, with an `EventFilter`: SelectClauses for EventId, EventType, SourceNode, SourceName, Time, Message, Severity + `ConditionType`/`AcknowledgeableConditionType`/`AlarmConditionType` state fields (Acked/Confirmed/Active/Shelving/Suppressed). Optional WhereClause scoping to the union of bound SourceNodes.
- Map event fields → `NativeAlarmTransition`; derive `Kind` from which sub-state changed.
- Call `ConditionRefresh` on (re)subscribe → emit the `Snapshot`/`SnapshotComplete` sequence.
**MxGateway adapter** (`MxGatewayDataConnection` / `RealMxGatewayClient`)
- Open session-less `StreamAlarms` (optional `alarm_filter_prefix` from bound source refs). Map `AlarmFeedMessage`: `active_alarm``Snapshot`, `snapshot_complete``SnapshotComplete`, `transition (OnAlarmTransitionEvent)` → mapped transition (RAISE/ACK/CLEAR/RETRIGGER, severity, operator user+comment, category, description, raise/transition times, current/limit value).
- Resumable stream; on transport fault re-open (existing `RaiseDisconnected` once-only guard) → fresh snapshot re-seeds.
- Uses `ZB.MOM.WW.MxGateway.Client`'s `StreamAlarmsAsync` (already exercised by OtOpcUa's `GatewayGalaxyAlarmFeed`); bump the NuGet package if the referenced version predates it.
---
## Section 4 — Site runtime, central query, UI, errors & testing
**`NativeAlarmActor` (new)**
- Child of `InstanceActor`, one per `ResolvedNativeAlarmSource` (named `native-alarm-{canonicalName}`). On `PreStart` sends `SubscribeAlarmsRequest` for its `(ConnectionName, SourceReference, ConditionFilter)`. Holds `_alarms: Dictionary<sourceRef, MirroredAlarm>` (discovered conditions + `AlarmConditionState` + metadata).
- On `NativeAlarmTransitionUpdate`: `Snapshot…SnapshotComplete` → buffer then **atomic swap** the source's set (drop conditions absent from the snapshot, emit diffs — no flicker); `Raise/Ack/Clear/Retrigger/StateChange` → update entry, last-write-wins by `TransitionTime` (ignore older). Each change emits an enriched `AlarmStateChanged` to `InstanceActor` → existing stream path.
- **Retention:** keep an entry while `Active` OR `Unacked`; once fully normal (`Inactive` AND `Acked`) emit a final return-to-normal and drop it.
- On `NativeAlarmSourceUnavailable`: mark its alarms **uncertain** (snapshot flag) rather than clearing; re-seed from the reconnect snapshot.
- **Persistence:** site-SQLite table `NativeAlarmState (InstanceUniqueName, SourceCanonicalName, SourceReference, serialized condition+metadata, LastTransitionTime)`. Rehydrate on `PreStart` (so central can query immediately after restart), then reconcile against the fresh snapshot. Reset on redeployment, like static attribute writes.
- **Supervision:** coordinator-style child → **Resume**. A bad source ref / subscribe failure logs to the site event log (`alarm`), reports unhealthy, and is retried periodically (same spirit as tag-resolution retry) without crashing the instance.
**Computed `AlarmActor`:** no logic change — populate `AlarmConditionState` on emit (`Active`, `Acknowledged=true`, `Severity=Priority`, `Level` retained, `Kind=Computed`).
**`InstanceActor`:** builds `NativeAlarmActor`s from `ResolvedNativeAlarmSource[]`; native `AlarmStateChanged` flows through the existing `_alarmStates`/`_alarmTimestamps` + `_streamManager.PublishAlarmStateChanged` path (state dictionaries extended to carry the enriched shape); the instance snapshot includes native alarms.
**Streaming + central query (no central tables)**
- Live: enriched `AlarmStateChanged``SiteStreamManager` → enriched gRPC `AlarmStateUpdate` → DebugView, as today.
- Initial snapshot: the existing **ClusterClient instance-snapshot** request (DebugView's seed) is extended to include native alarms in the unified shape. Large snapshots reuse existing per-subscriber buffering / frame-size guard (the browse-cap precedent); chunk if needed.
**Central UI — DebugView enrichment** (+ Section 2 authoring panels)
- Alarm table gains: Severity, a composite condition badge (Active/Acked/Shelved/Suppressed), a Kind badge (computed vs native), Source reference, Alarm type, Category, Operator/comment (tooltip), Original raise time, Current/Limit value (tooltip). Computed rows show severity=priority, auto-acked. Built with the `frontend-design` skill, Bootstrap-only custom components.
**Error handling / edge cases**
- Connection loss → uncertain, not cleared; reconnect snapshot reconciles. Source ref absent from snapshot → cleared. Severity normalized to 01000. **Bounded growth:** configurable per-source mirrored-alarm cap in `SiteRuntimeOptions`; when hit, **log it** (no silent truncation). Disabled/deleted instance → unsubscribe.
- `DataConnectionActor` health report extended with alarm-feed status (active feeds, last-event time, uncertain sources) via `ISiteHealthCollector`.
**Testing**
- Unit: `AlarmConditionState` mapping (computed / OPC UA fields / MxAccess states); `NativeAlarmActor` snapshot-swap, transition handling, persistence rehydrate, uncertain-on-disconnect; `FlatteningService` native-source inherit/compose/override; semantic validation.
- Adapter: OPC UA event→transition + ConditionRefresh snapshot (fake client); MxGateway `AlarmFeedMessage`→transition + reconnect re-seed (fake client, existing fake patterns).
- Integration: end-to-end against the infra OPC UA server — **confirm the test OPC UA server exposes A&C; if not, add an alarm-capable test source or simulate.** MxGateway path mocked in CI unless a gateway-with-alarms is available.
- Seed: add a `NativeAlarmSource` binding to the `docker-env2` site-x MxGateway connection for manual verification.
---
## Affected components & documents
| Area | Changes |
|------|---------|
| Commons | New enums/records (`AlarmKind`, `AlarmShelveState`, `AlarmConditionState`, `NativeAlarmTransition`); extend `AlarmStateChanged`; new entities `TemplateNativeAlarmSource`, `InstanceNativeAlarmSourceOverride`; new DCL messages; `IAlarmSubscribableConnection` |
| Template Engine (#1) | `ResolvedNativeAlarmSource`, flattening resolution, semantic validation |
| Site Runtime (#3) | `NativeAlarmActor`, enriched `AlarmActor`, `InstanceActor` wiring, `NativeAlarmState` SQLite persistence, `SiteRuntimeOptions` cap |
| Data Connection Layer (#4) | `DataConnectionActor` alarm feed + routing; OPC UA A&C adapter; MxGateway `StreamAlarms` adapter |
| Communication (#5) | `sitestream.proto` `AlarmStateUpdate` enrichment; instance-snapshot enrichment |
| Configuration Database (#17) | EF configurations + migration for two new tables |
| Central UI (#9) | DebugView alarm table enrichment; Template editor + Instance Configure authoring panels |
| CLI (#19) | `native-alarm-source` commands |
| Health Monitoring (#11) | Alarm-feed status in `DataConnectionHealthReport` |
| Docs | `Component-DataConnectionLayer.md`, `Component-SiteRuntime.md`, `Component-TemplateEngine.md`, `Component-CentralUI.md`, `Component-CLI.md`, `Component-Communication.md`, `Component-ConfigurationDatabase.md`; CLAUDE.md design-decisions; README if needed |
## Out of scope (this pass)
- Acknowledging / shelving / suppressing from ScadaBridge (read-only mirror).
- Central alarm tables, alarm history/journal, central audit of alarm state.
- A dedicated operator-facing Alarm Summary page (DebugView only).
- Alarm-driven notifications or scripts off native alarms.
## Open items / risks
- **MxGateway alarm delivery** must work end-to-end via `StreamAlarms`. OtOpcUa notes record the x86 COM worker historically delivered no native alarm events; we are trusting that the gateway now delivers (per the chosen transport). Verify against a live gateway before integration sign-off.
- **Test OPC UA server A&C support** — confirm the infra OPC UA server exposes Alarms & Conditions; otherwise add/simulate an alarm-capable source for integration tests.
- **`ZB.MOM.WW.MxGateway.Client` version** — ensure the referenced package exposes `StreamAlarmsAsync`; bump if needed.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
{
"planPath": "docs/plans/2026-05-29-native-alarms.md",
"tasks": [
{"id": 1, "subject": "Task 1: Commons alarm core types", "status": "completed"},
{"id": 2, "subject": "Task 2: Extend AlarmStateChanged + computed-default mapping", "status": "completed", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: IAlarmSubscribableConnection seam + DCL alarm messages", "status": "completed", "blockedBy": [1]},
{"id": 4, "subject": "Task 4: Entities + flattened type + Template navigation", "status": "completed"},
{"id": 5, "subject": "Task 5: EF configurations + DbSets", "status": "completed", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Repository interface + implementation", "status": "completed", "blockedBy": [4, 5]},
{"id": 7, "subject": "Task 7: EF migration AddNativeAlarmSources", "status": "completed", "blockedBy": [5]},
{"id": 8, "subject": "Task 8: Flattening ResolveNativeAlarmSources", "status": "completed", "blockedBy": [4]},
{"id": 9, "subject": "Task 9: Semantic validation", "status": "completed", "blockedBy": [4, 8]},
{"id": 10, "subject": "Task 10: DataConnectionActor alarm subscribe/route/unavailable", "status": "completed", "blockedBy": [3]},
{"id": 11, "subject": "Task 11: OPC UA A&C adapter", "status": "completed", "blockedBy": [3]},
{"id": 12, "subject": "Task 12: MxGateway StreamAlarms adapter", "status": "completed", "blockedBy": [3]},
{"id": 13, "subject": "Task 13: SiteRuntimeOptions alarm cap + retry", "status": "completed"},
{"id": 14, "subject": "Task 14: Site SQLite NativeAlarmState store", "status": "completed"},
{"id": 15, "subject": "Task 15: NativeAlarmActor", "status": "completed", "blockedBy": [1, 2, 3, 4, 13, 14]},
{"id": 16, "subject": "Task 16: InstanceActor wiring", "status": "completed", "blockedBy": [15]},
{"id": 17, "subject": "Task 17: Enrich computed AlarmActor emit", "status": "completed", "blockedBy": [2]},
{"id": 18, "subject": "Task 18: Extend sitestream.proto + regenerate", "status": "completed", "blockedBy": [2]},
{"id": 19, "subject": "Task 19: gRPC alarm mapping (server + client)", "status": "completed", "blockedBy": [2, 18]},
{"id": 20, "subject": "Task 20: Management command contracts + registry", "status": "completed", "blockedBy": [4]},
{"id": 21, "subject": "Task 21: ManagementActor handlers", "status": "completed", "blockedBy": [6, 20]},
{"id": 22, "subject": "Task 22: CLI commands", "status": "completed", "blockedBy": [20]},
{"id": 23, "subject": "Task 23: DebugView alarm table enrichment", "status": "completed", "blockedBy": [2, 19]},
{"id": 24, "subject": "Task 24: Template editor Native Alarm Sources subsection", "status": "completed", "blockedBy": [20]},
{"id": 25, "subject": "Task 25: Instance Configure native alarm source override panel", "status": "completed", "blockedBy": [20]},
{"id": 26, "subject": "Task 26: docker-env2 seed sample native alarm source", "status": "completed", "blockedBy": [22]},
{"id": 27, "subject": "Task 27: Documentation sync", "status": "completed", "blockedBy": [16, 19, 22, 23, 24, 25]},
{"id": 28, "subject": "Task 28: Integration / live verification", "status": "completed", "blockedBy": [10, 11, 12, 16, 19]}
],
"lastUpdated": "2026-05-31 ALL-COMPLETE (28/28)"
}
@@ -0,0 +1,123 @@
# Folder + Repo Rename `scadalink-design``ScadaBridge` — Design
**Date:** 2026-05-31
**Status:** Approved, ready for implementation planning
**Scope:** Rename the repo folder `~/Desktop/scadalink-design``~/Desktop/ScadaBridge`, rename the Gitea repository `dohertj2/scadalink-design``dohertj2/ScadaBridge`, and scrub every residual `scadalink`/`ScadaLink` reference inside the repo to its `ScadaBridge` equivalent.
This reverses the earlier decision (in `2026-05-28-scadabridge-rename-design.md`) to leave the folder name diverged from the product name, and completes the residual reference scrub that the earlier effort deliberately left out (the `SCADALINK_*` runtime env vars and the production deploy script).
## Context
The product was already renamed from "ScadaLink" to "ScadaBridge" in the `2026-05-28` effort: the solution (`ZB.MOM.WW.ScadaBridge.slnx`), all `src/`/`tests/` projects, namespaces, MS SQL databases, docker containers/network/image, the CLI config dir (`~/.scadabridge/`), `CLAUDE.md`, and `README.md` already say ScadaBridge. What remained untouched were the runtime environment variables (`SCADALINK_*`), one `.NET` config-key convention (`ScadaLink__*`), a stale assembly-name reference in the production deploy script, a few cosmetic identifiers, and historical doc/code-review mentions.
## Decisions
| # | Decision | Selected option |
|---|----------|-----------------|
| 1 | Folder-name collision (`~/Desktop/ScadaBridge` already exists — a separate, older, non-git project) | Rename the existing folder aside to `~/Desktop/ScadaBridge-old` (non-destructive); nothing deleted |
| 2 | Reference scope | Full scrub — env vars, config keys, assembly name, SQL login, cosmetic identifiers, and historical docs/code-reviews |
| 3 | Env-var cutover style | Hard cutover (no backward-compat fallback); dev stack is redeployed |
| 4 | New env-var/identifier prefix | Short product name `SCADABRIDGE_` / `ScadaBridge__` (no `ZB.MOM.WW`), consistent with existing runtime artifacts (`scadabridge-*` containers, `~/.scadabridge/`) |
| 5 | Gitea server-side rename | User renames via the Gitea web UI; Claude updates the local `origin` remote and verifies |
| 6 | Execution mechanism | Scripted multi-pass `sed` over `git ls-files`, most-specific-first, with a completeness-gate `git grep` |
| 7 | Migration records | Carved out of the scrub so their before→after meaning survives (see §2) |
## Section 1 — Order of operations
1. **Safety check** — confirm the working tree is clean (commit/stash WIP first); record the current branch.
2. **Clear the collision**`mv ~/Desktop/ScadaBridge ~/Desktop/ScadaBridge-old`.
3. **Move the folder**`mv ~/Desktop/scadalink-design ~/Desktop/ScadaBridge`. Plain OS move; git tracks contents, not the repo-root directory name, so history is untouched. `.idea/` and `.claude/` ride along inside the moved folder.
4. **Scrub references** — run the scripted multi-pass `sed` (§2).
5. **Build + test gate**`dotnet build ZB.MOM.WW.ScadaBridge.slnx`, unit tests, completeness `git grep`.
6. **Commit** (§5).
7. **Runtime cutover** — rebuild + redeploy docker so running containers read the new env-var names (§4). No DB changes.
8. **Gitea rename** — user web-UI rename; Claude updates local `origin` + verifies fetch/push.
9. **Write/commit this design doc** (already produced by the brainstorm).
## Section 2 — The scrub
A new `tools/scrub-scadalink-refs.sh` operating on `git ls-files -z | xargs -0 sed -i ''` (skips `.git/` and `bin/obj` build artifacts), applied **most-specific-first** so a broad rule cannot double-replace an earlier result:
```
1. ScadaLink.Host.exe → ZB.MOM.WW.ScadaBridge.Host.exe (stale assembly name, deploy/install.ps1)
2. ScadaLink__ → ScadaBridge__ (.NET hierarchical config keys, deploy/install.ps1)
3. SCADALINK_ → SCADABRIDGE_ (13 runtime env vars)
4. scadaLinkVersion → scadaBridgeVersion (Transport param/local var — cosmetic)
5. scadalink_app → scadabridge_app (SQL login)
6. ScadaLink → ScadaBridge (residual)
7. scadalink → scadabridge (residual)
```
### The 13 `SCADALINK_*` env vars in scope
`SCADALINK_CONFIG`, `SCADALINK_FORMAT`, `SCADALINK_USERNAME`, `SCADALINK_PASSWORD`, `SCADALINK_MANAGEMENT_URL`, `SCADALINK_CONFIGURATIONDB_CONNECTION_STRING`, `SCADALINK_DESIGNTIME_CONNECTIONSTRING`, `SCADALINK_MSSQL_TEST_CONN`, `SCADALINK_PLAYWRIGHT_DB`, `SCADALINK_JWT_SIGNING_KEY`, `SCADALINK_LDAP_SERVICE_ACCOUNT_PASSWORD`, `SCADALINK_AUDIT_FILTER_4KB_P95_US`, `SCADALINK_AUDIT_FILTER_RAW_P95_US`.
They are read by code (`Environment.GetEnvironmentVariable(...)` in Host/CLI/tests) **and** set by `docker/docker-compose.yml`, `docker-env2/docker-compose.yml`, `appsettings.Central.json` (`${...}` placeholders), and `deploy/wonder-app-vd03/install.ps1`. All sites change together in one pass.
### Carved out (excluded — migration records)
Scrubbing these would destroy their before→after meaning:
- `tools/rename-to-scadabridge.sh` — prior rename tooling.
- `tools/scrub-scadalink-refs.sh` — this scrub script (its substitution rules are the mapping).
- `docker/rename-databases.sh` — DB rename helper (`ALTER DATABASE ScadaLinkConfig MODIFY NAME = ScadaBridgeConfig`, `ALTER LOGIN [scadalink_app] ...`).
- `docs/plans/2026-05-28-scadabridge-rename-design.md` — prior rename design.
- `docs/plans/2026-05-31-folder-repo-rename-scadabridge-design.md`**this document** (also a before→after record).
- `docs/plans/2026-05-31-folder-repo-rename-scadabridge-plan.md` — the implementation plan (its substitution table and embedded script are the mapping).
### Completeness gate
Must return only the six carve-outs:
```bash
git grep -niE "scadalink" -- . \
':!tools/rename-to-scadabridge.sh' \
':!tools/scrub-scadalink-refs.sh' \
':!docker/rename-databases.sh' \
':!docs/plans/2026-05-28-scadabridge-rename-design.md' \
':!docs/plans/2026-05-31-folder-repo-rename-scadabridge-design.md' \
':!docs/plans/2026-05-31-folder-repo-rename-scadabridge-plan.md'
```
## Section 3 — Collision folder
The existing `~/Desktop/ScadaBridge` (non-git, older project containing `akka.md`, `netstd.md`, `CommentChecker`, its own `src/`/`tests/`/`tsdb/`) is renamed to `~/Desktop/ScadaBridge-old`. Nothing is deleted. If the user later confirms it is dead, they delete it themselves.
## Section 3a — Git-ignored `deploy/` tree (added during implementation)
`/deploy/` is git-ignored, so the `git grep`-based scrub script (and its completeness gate) structurally could not reach it — a gap surfaced by code review. The `deploy/wonder-app-vd03/` production artifacts (`install.ps1`, `appsettings.Central.json`, `appsettings.Site.json`, `RUNBOOK.md`, `ldap/glauth.cfg`) still held old references that would break against the renamed binary: `SCADALINK_*` env vars, `ScadaLink__*` config keys, the `"ScadaLink"` config-section root, `ScadaLink.Host.exe` (now `ZB.MOM.WW.ScadaBridge.Host.exe`), `scadalink.exe` (CLI `AssemblyName` is `scadabridge`), and `akka.tcp://scadalink@…` seed URIs (the actor system name in code is `scadabridge`).
Per user decision, this tree was scrubbed by hand with the **same** substitution set, **including** the internal LDAP directory domain (`dc=scadalink,dc=local``dc=scadabridge,dc=local` and `@scadalink.local``@scadabridge.local`) applied in lockstep across both `appsettings` and `glauth.cfg` so LDAP login stays consistent. Because `deploy/` is git-ignored, these edits are **local-only / not committed** — they fix the on-disk artifacts for the next `wonder-app-vd03` deploy. Verified clean with `grep -rniI scadalink deploy/`.
## Section 4 — Runtime cutover
Hard cutover. After the scrub, rebuild the image and `docker compose up -d --force-recreate` on both clusters (`docker/`, `docker-env2/`) so the running containers read `SCADABRIDGE_*`. **No database rename or wipe** — database names were already migrated in the `2026-05-28` effort and are unchanged here. If no stack is currently running, the cutover applies on the next `bash docker/deploy.sh`.
## Section 5 — Commits
Two staged commits on the current branch, `git diff`-reviewed between them:
1. `refactor: scrub residual ScadaLink refs → ScadaBridge (env vars, config keys, assembly name, SQL login)` — the scrub + any build/test fixes.
2. `docs: add folder/repo rename design doc` — this document.
The folder `mv` and the Gitea rename are not git commits.
## Section 6 — Risks & rollback
- **Missed env var** → silent config-read failure. Mitigated by the completeness-gate grep + build/test + redeploy smoke check.
- **Folder move breaks editor/Claude session paths** — the concern that drove the `2026-05-28` "don't rename the folder" decision; accepted by the user this time.
- **Gitea redirect** — Gitea auto-redirects the old repo URL after rename, so pushes do not hard-fail even before the remote is updated; `origin` is updated regardless.
- **Rollback** — pre-scrub state is the last git commit (`git reset --hard`); folder moves reverse with `mv`.
## Section 7 — Verification
- `dotnet build ZB.MOM.WW.ScadaBridge.slnx` clean; unit tests green.
- Completeness grep returns only the four carve-outs.
- `git remote -v` shows `https://gitea.dohertylan.com/dohertj2/ScadaBridge.git`; `git ls-remote origin` / `git fetch` succeeds.
- (If a stack is running) CLI smoke against the redeployed cluster.
## Out of scope
- Renaming the MS SQL databases, docker containers/network, or CLI config dir — already done in the `2026-05-28` effort.
- Sister repos `~/Desktop/MxAccessGateway`, `~/Desktop/OtOpcUa`.
- Deleting `~/Desktop/ScadaBridge-old` — left to the user.
@@ -0,0 +1,349 @@
# Folder + Repo Rename `scadalink-design``ScadaBridge` Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Rename the repo folder `~/Desktop/scadalink-design``~/Desktop/ScadaBridge`, rename the Gitea repository `dohertj2/scadalink-design``dohertj2/ScadaBridge`, and scrub every residual `scadalink`/`ScadaLink` reference inside the repo to its `ScadaBridge` equivalent.
**Architecture:** A scripted, most-specific-first `sed` pass over git-tracked text files does the content scrub; the folder rename is a plain OS `mv` (git tracks contents, not the repo-root directory name); the Gitea rename is a user web-UI action followed by a local `origin` URL update. Migration records (the prior rename tooling/design and the DB-rename helper) are carved out so their before→after meaning survives. Hard env-var cutover, no DB changes.
**Tech Stack:** bash 3.2 (macOS), BSD `sed -i ''`, git, `git grep`, dotnet (`ZB.MOM.WW.ScadaBridge.slnx`), docker compose.
**Design doc:** `docs/plans/2026-05-31-folder-repo-rename-scadabridge-design.md`
> **CRITICAL — path change after Task 0:** Task 0 moves the repo. From Task 1 onward, the repo root is `/Users/dohertj2/Desktop/ScadaBridge` (NOT `scadalink-design`). Every command below `cd`s there.
---
### Task 0: Safety checkpoint, clear the collision, move the folder
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Files:** (filesystem operations only — no file edits)
- Move: `~/Desktop/ScadaBridge``~/Desktop/ScadaBridge-old`
- Move: `~/Desktop/scadalink-design``~/Desktop/ScadaBridge`
**Step 1: Confirm the working tree is clean and record the branch**
Run:
```bash
cd /Users/dohertj2/Desktop/scadalink-design && git status --short && git branch --show-current
```
Expected: no output from `git status --short` (clean tree) and a branch name (e.g. `main`). If there is uncommitted work, STOP and ask the user whether to commit or stash before proceeding.
**Step 2: Verify both move endpoints are safe**
Run:
```bash
ls -ld /Users/dohertj2/Desktop/ScadaBridge /Users/dohertj2/Desktop/ScadaBridge-old /Users/dohertj2/Desktop/scadalink-design 2>&1
```
Expected: `ScadaBridge` exists (the old non-git project), `scadalink-design` exists, and `ScadaBridge-old` does **not** exist (`No such file or directory`). If `ScadaBridge-old` already exists, STOP and ask the user for a different aside-name.
**Step 3: Move the existing ScadaBridge aside, then move the repo into place**
Run:
```bash
mv /Users/dohertj2/Desktop/ScadaBridge /Users/dohertj2/Desktop/ScadaBridge-old
mv /Users/dohertj2/Desktop/scadalink-design /Users/dohertj2/Desktop/ScadaBridge
```
Expected: no output (success).
**Step 4: Verify the move and that git still works in the new location**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git status --short && git log --oneline -1
```
Expected: clean tree; the HEAD commit is the design-doc commit (`docs: add folder/repo rename design doc ...`). Confirms git history survived the folder move.
**Step 5: No commit** — filesystem moves are not git changes. Proceed to Task 1.
**Acceptance:** `~/Desktop/ScadaBridge` is the git repo (clean tree, intact history); `~/Desktop/ScadaBridge-old` holds the former non-git project; `~/Desktop/scadalink-design` no longer exists.
---
### Task 1: Create the scrub script
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Files:**
- Create: `/Users/dohertj2/Desktop/ScadaBridge/tools/scrub-scadalink-refs.sh`
**Step 1: Write the script**
Create `tools/scrub-scadalink-refs.sh` with exactly this content:
```bash
#!/usr/bin/env bash
# One-time scrub of residual ScadaLink/scadalink references → ScadaBridge.
# Operates on git-tracked TEXT files only (git grep -I skips binaries),
# minus carve-out migration records whose before→after meaning must survive.
# Substitutions are applied most-specific-first so a broad rule cannot
# double-replace an earlier result. Idempotent: re-running is a no-op.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# Carve-outs (migration records): prior rename tooling/design, the DB-rename
# helper, this script itself, and the two rename design docs.
EXCLUDES_RE='^(tools/rename-to-scadabridge\.sh|tools/scrub-scadalink-refs\.sh|docker/rename-databases\.sh|docs/plans/2026-05-28-scadabridge-rename-design\.md|docs/plans/2026-05-31-folder-repo-rename-scadabridge-design\.md)$'
files=()
while IFS= read -r f; do
[[ "$f" =~ $EXCLUDES_RE ]] && continue
files+=("$f")
done < <(git grep -liI 'scadalink' -- .)
if [[ ${#files[@]} -eq 0 ]]; then
echo "No files to scrub."
exit 0
fi
printf 'Scrubbing %d file(s):\n' "${#files[@]}"
printf ' %s\n' "${files[@]}"
sed -i '' \
-e 's/ScadaLink\.Host\.exe/ZB.MOM.WW.ScadaBridge.Host.exe/g' \
-e 's/ScadaLink__/ScadaBridge__/g' \
-e 's/SCADALINK_/SCADABRIDGE_/g' \
-e 's/scadaLinkVersion/scadaBridgeVersion/g' \
-e 's/scadalink_app/scadabridge_app/g' \
-e 's/ScadaLink/ScadaBridge/g' \
-e 's/scadalink/scadabridge/g' \
"${files[@]}"
echo "Done."
```
**Step 2: Make it executable**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && chmod +x tools/scrub-scadalink-refs.sh
```
Expected: no output.
**Step 3: Dry-run preview (list candidate files WITHOUT editing)**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git grep -liI 'scadalink' -- . | grep -Ev '^(tools/rename-to-scadabridge\.sh|tools/scrub-scadalink-refs\.sh|docker/rename-databases\.sh|docs/plans/2026-05-28-scadabridge-rename-design\.md|docs/plans/2026-05-31-folder-repo-rename-scadabridge-design\.md)$'
```
Expected: a list of ~27 tracked source/config/test/doc files (the same set surfaced in the design doc), and NONE of the five carve-outs. No binary files (e.g. `site_events.db`) appear. If a carve-out appears, the script's `EXCLUDES_RE` is wrong — fix before running.
**Step 4: No commit yet** — the script is committed together with the scrub in Task 4.
**Acceptance:** `tools/scrub-scadalink-refs.sh` exists, is executable, and the dry-run lists only the intended files.
---
### Task 2: Run the scrub and pass the completeness gate
**Classification:** high-risk
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Files:** (the script edits ~27 tracked files in place; see Task 1 dry-run)
**Step 1: Run the scrub**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && bash tools/scrub-scadalink-refs.sh
```
Expected: `Scrubbing N file(s):` followed by the file list and `Done.`
**Step 2: Completeness gate — only the five carve-outs may still match**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git grep -niI 'scadalink' -- . \
':!tools/rename-to-scadabridge.sh' \
':!tools/scrub-scadalink-refs.sh' \
':!docker/rename-databases.sh' \
':!docs/plans/2026-05-28-scadabridge-rename-design.md' \
':!docs/plans/2026-05-31-folder-repo-rename-scadabridge-design.md'
```
Expected: **no output** (zero hits outside the carve-outs). If any line prints, inspect it — it is either a new case variant the substitution list missed (add a rule and re-run) or a file that should have been carved out.
**Step 3: Sanity-check the new env-var names landed**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git grep -nE 'SCADABRIDGE_CONFIG|ScadaBridge__|scadabridge_app|ZB\.MOM\.WW\.ScadaBridge\.Host\.exe' -- src/ deploy/ docker/ docker-env2/ | head
```
Expected: hits showing `SCADABRIDGE_CONFIG` in compose/Host, `ScadaBridge__` and `ZB.MOM.WW.ScadaBridge.Host.exe` in `deploy/wonder-app-vd03/install.ps1`.
**Step 4: Review the diff**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git diff --stat
```
Expected: ~27 files changed, all content-only. Spot-check `git diff docker/docker-compose.yml src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` shows clean `SCADALINK_``SCADABRIDGE_` swaps.
**Step 5: No commit yet** — commit after the build/test gate (Task 4).
**Acceptance:** completeness gate returns zero hits outside the five carve-outs; new names present in code/config; diff is content-only.
---
### Task 3: Build, run unit tests, fix any stragglers
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:** (no planned edits; only stragglers surfaced by the build, if any)
**Step 1: Restore + build the solution**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | tail -20
```
Expected: `Build succeeded` with 0 errors. The scrub only renamed env-var string literals, a method parameter (`scadaLinkVersion``scadaBridgeVersion`), comments, and docs — no type/namespace changes — so the build should be clean. If an error appears (e.g. a parameter referenced by name elsewhere), fix that single reference and rebuild.
**Step 2: Run the CLI + Transport + ConfigurationDatabase unit tests (the projects whose code changed)**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ 2>&1 | tail -25
```
Expected: all tests pass. These projects assert on the env-var name string literals (`SCADABRIDGE_FORMAT`, `SCADABRIDGE_USERNAME`, …) and the `scadaBridgeVersion` parameter — they were scrubbed in lockstep with the code, so they stay consistent. If a test references an env var the code no longer sets (or vice-versa), reconcile the pair.
**Step 3: No commit yet** — commit in Task 4.
**Acceptance:** `dotnet build` clean; the changed projects' unit tests green.
---
### Task 4: Commit the scrub
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none
**Files:** (commit only)
**Step 1: Stage and commit**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git add -A && git commit -m "refactor: scrub residual ScadaLink refs → ScadaBridge (env vars, config keys, assembly name, SQL login)
Renames the 13 SCADALINK_* runtime env vars → SCADABRIDGE_*, the ScadaLink__
.NET config keys → ScadaBridge__, the stale ScadaLink.Host.exe assembly name
→ ZB.MOM.WW.ScadaBridge.Host.exe, the scadalink_app SQL login → scadabridge_app,
and residual identifiers/comments/docs. Migration records (prior rename
tooling/design, DB-rename helper, this scrub script) carved out.
Adds tools/scrub-scadalink-refs.sh."
```
Expected: commit succeeds; `~28` files changed (the 27 scrubbed + the new script).
**Step 2: Verify**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git log --oneline -2 && git status --short
```
Expected: the refactor commit on top of the design-doc commit; clean tree.
**Acceptance:** scrub committed; clean working tree.
---
### Task 5: Update the local Git remote after the Gitea web-UI rename
**Classification:** small
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 6
**Files:** (git remote config only)
**Step 1: Manual gate — user renames the repo in the Gitea web UI**
Ask the user to rename the repository at `https://gitea.dohertylan.com/dohertj2/scadalink-design` to `ScadaBridge` via **Settings → Repository Name**, and to confirm when done. Do NOT proceed until confirmed.
**Step 2: Point `origin` at the new URL**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git remote set-url origin https://gitea.dohertylan.com/dohertj2/ScadaBridge.git && git remote -v
```
Expected: both fetch and push lines show `.../dohertj2/ScadaBridge.git`.
**Step 3: Verify the remote is reachable and refs match**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git ls-remote origin HEAD
```
Expected: prints a commit SHA for `HEAD` (auth via macOS keychain succeeds). If it fails with not-found, the web-UI rename has not propagated — re-confirm with the user.
**Step 4: Push the new commits**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git push origin HEAD
```
Expected: push succeeds; the design-doc + scrub commits land on the renamed remote.
**Acceptance:** `origin` points at `dohertj2/ScadaBridge`; `git ls-remote` and `git push` succeed.
---
### Task 6: Runtime cutover — rebuild + redeploy so containers read the new env vars (conditional)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:** (no edits — runtime only)
**Step 1: Check whether a stack is running**
Run:
```bash
docker ps --format '{{.Names}}' | grep -E 'scadabridge|central|site' || echo "no stack running"
```
Expected: either a list of running containers, or `no stack running`. **If `no stack running`, SKIP this task** — the new env-var names apply automatically on the next `bash docker/deploy.sh`. Note this to the user and mark the task done.
**Step 2: Rebuild the image and recreate the primary cluster**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && bash docker/deploy.sh 2>&1 | tail -30
```
Expected: image rebuilds; containers recreated. (`deploy.sh` already force-recreates.) Containers now receive `SCADABRIDGE_*` from the scrubbed `docker/docker-compose.yml`.
**Step 3: Recreate the second cluster (if it was running)**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && bash docker-env2/deploy.sh 2>&1 | tail -30
```
Expected: env2 cluster recreated from the scrubbed `docker-env2/docker-compose.yml`.
**Step 4: Smoke-test readiness + CLI**
Run:
```bash
sleep 20
curl -s http://localhost:9000/health/ready && echo
cd /Users/dohertj2/Desktop/ScadaBridge && dotnet run --project src/ZB.MOM.WW.ScadaBridge.CLI -- --username multi-role --password password template list 2>&1 | tail -15
```
Expected: `/health/ready` reports healthy; the CLI returns a template list (proves the Host read its config via the renamed `SCADABRIDGE_*` env vars). No DB rename/wipe is needed — database names are unchanged by this effort.
**Acceptance:** either the stack is confirmed not-running (skipped), or the redeployed cluster is healthy and the CLI smoke passes against the new env-var names.
---
## Execution Notes
- Tasks are strictly sequential 0 → 1 → 2 → 3 → 4, then 5 and 6 may run in parallel (both depend only on 4).
- The completeness gate in Task 2 and the build/test gate in Task 3 are the safety net for a missed reference.
- Rollback: `git reset --hard HEAD~1` (or `~2`) undoes the scrub commit; `mv` reverses the folder moves; `git remote set-url` reverts the remote.
@@ -0,0 +1,13 @@
{
"planPath": "docs/plans/2026-05-31-folder-repo-rename-scadabridge-plan.md",
"tasks": [
{"id": 0, "subject": "Task 0: Safety checkpoint, clear collision, move folder", "status": "completed"},
{"id": 1, "subject": "Task 1: Create the scrub script", "status": "completed", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Run scrub + completeness gate", "status": "completed", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Build, run unit tests, fix stragglers", "status": "completed", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Commit the scrub", "status": "completed", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Update local Git remote after Gitea web-UI rename", "status": "completed", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Runtime cutover redeploy (conditional)", "status": "completed", "blockedBy": [4]}
],
"lastUpdated": "2026-06-01T02:05:03Z"
}
@@ -0,0 +1,96 @@
<mxfile host="app.diagrams.net">
<diagram id="env2arch" name="Env2 Architecture">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1100" pageHeight="900" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- Host machine container -->
<mxCell id="host" value="host machine" style="rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#666666;verticalAlign=top;fontStyle=2;fontColor=#666666;dashed=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="1020" height="800" as="geometry" />
</mxCell>
<!-- Primary stack -->
<mxCell id="primary" value="Primary stack&#10;(already existing — unchanged)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;verticalAlign=top;fontStyle=1;align=center;spacingTop=6;" vertex="1" parent="1">
<mxGeometry x="80" y="100" width="420" height="220" as="geometry" />
</mxCell>
<mxCell id="p-traefik" value="Traefik :9000 ◄── 9001/9002 UI" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="100" y="160" width="380" height="40" as="geometry" />
</mxCell>
<mxCell id="p-central" value="Central A/B (9011/9012 Akka)" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="100" y="210" width="380" height="40" as="geometry" />
</mxCell>
<mxCell id="p-site" value="Site-A/B/C (9021..9044)" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="100" y="260" width="380" height="40" as="geometry" />
</mxCell>
<!-- Env2 stack -->
<mxCell id="env2" value="Env2 stack&#10;(new)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;verticalAlign=top;fontStyle=1;align=center;spacingTop=6;" vertex="1" parent="1">
<mxGeometry x="600" y="100" width="420" height="220" as="geometry" />
</mxCell>
<mxCell id="e-traefik" value="Traefik :9100 ◄── 9101/9102 UI" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="620" y="160" width="380" height="40" as="geometry" />
</mxCell>
<mxCell id="e-central" value="Central A/B (9111/9112 Akka)" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="620" y="210" width="380" height="40" as="geometry" />
</mxCell>
<mxCell id="e-site" value="Site-X (9121/9122 Akka, 9123/9124 gRPC)" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="620" y="260" width="380" height="40" as="geometry" />
</mxCell>
<!-- Shared network bar -->
<mxCell id="net" value="scadabridge-net (shared bridge network)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="80" y="400" width="940" height="50" as="geometry" />
</mxCell>
<!-- Infra container -->
<mxCell id="infra" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" vertex="1" parent="1">
<mxGeometry x="120" y="500" width="860" height="300" as="geometry" />
</mxCell>
<!-- MSSQL block -->
<mxCell id="mssql" value="scadabridge-mssql" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;verticalAlign=top;fontStyle=1;spacingTop=4;" vertex="1" parent="1">
<mxGeometry x="150" y="530" width="160" height="240" as="geometry" />
</mxCell>
<mxCell id="db1" value="ScadaBridgeConfig (primary DB)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="340" y="540" width="300" height="40" as="geometry" />
</mxCell>
<mxCell id="db2" value="ScadaBridgeMachineData (primary DB)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="340" y="590" width="300" height="40" as="geometry" />
</mxCell>
<mxCell id="db3" value="ScadaBridgeConfig2 (env2 DB) ← new" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="340" y="640" width="300" height="40" as="geometry" />
</mxCell>
<mxCell id="db4" value="ScadaBridgeMachineData2 (env2 DB) ← new" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="340" y="690" width="300" height="40" as="geometry" />
</mxCell>
<!-- Shared commodity infra services -->
<mxCell id="ldap" value="scadabridge-ldap&#10;(shared — same test users)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="680" y="530" width="280" height="40" as="geometry" />
</mxCell>
<mxCell id="smtp" value="scadabridge-smtp&#10;(shared Mailpit)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="680" y="580" width="280" height="40" as="geometry" />
</mxCell>
<mxCell id="opcua" value="scadabridge-opcua (shared)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="680" y="630" width="280" height="40" as="geometry" />
</mxCell>
<mxCell id="restapi" value="scadabridge-restapi (shared)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="680" y="680" width="280" height="40" as="geometry" />
</mxCell>
<!-- Edges: primary -> net, env2 -> net -->
<mxCell id="ep" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;strokeColor=#6c8ebf;" edge="1" parent="1" source="primary" target="net">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="ee" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;strokeColor=#82b366;" edge="1" parent="1" source="env2" target="net">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- net -> infra -->
<mxCell id="eni" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;strokeColor=#d6b656;" edge="1" parent="1" source="net" target="infra">
<mxGeometry relative="1" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

+20 -20
View File
@@ -547,26 +547,26 @@ This section governs how implementation plans are executed. The goal is autonomo
For each work package, follow this sequence:
```
┌─────────────────────────────────────────────────────┐
│ 1. READ the WP description and acceptance criteria │
│ 2. READ all traced requirements (HLR bullets, KDD, │
CD constraints) to understand intent
3. IMPLEMENT the WP
│ - Write code │
│ - Write unit tests for acceptance criteria │
│ - Write negative tests for prohibition criteria │
│ 4. VERIFY acceptance criteria │
│ - Run tests: all must pass │
│ - Walk each acceptance criterion line by line │
│ - If a criterion cannot be verified yet (depends │
│ on a later WP), note it as "deferred to WP-N" │
│ 5. UPDATE the phase execution checklist │
│ - Mark WP as complete with date │
│ - Note any deferred criteria │
│ - Note any questions logged │
│ 6. COMMIT with message: "Phase N WP-M: <summary>" │
└─────────────────────────────────────────────────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
S1["1. READ the WP description and acceptance criteria"]
S2["2. READ all traced requirements (HLR bullets, KDD, CD constraints) to understand intent"]
S3["3. IMPLEMENT the WP<br/>• Write code<br/>• Write unit tests for acceptance criteria<br/>• Write negative tests for prohibition criteria"]
S4["4. VERIFY acceptance criteria<br/>• Run tests: all must pass<br/>• Walk each acceptance criterion line by line<br/>• If a criterion cannot be verified yet (depends on a later WP), note it as deferred to WP-N"]
S5["5. UPDATE the phase execution checklist<br/>• Mark WP as complete with date<br/>• Note any deferred criteria<br/>• Note any questions logged"]
S6["6. COMMIT with message:<br/>Phase N WP-M: summary"]
S1 --> S2 --> S3 --> S4 --> S5 --> S6
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
class S1,S2 proc
class S3 start
class S4,S5 dec
class S6 warn
```
### Mid-Phase Compliance Check
+73 -56
View File
@@ -23,28 +23,38 @@ gRPC server-streaming is an established pattern for real-time tag value updates;
## Architecture
```
Central Cluster Site Cluster
───────────── ────────────
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
subgraph CENTRAL["Central Cluster"]
BT["DebugStreamBridgeActor"]
GC["SiteStreamGrpcClient<br/>(per-site, on central)"]
BB["DebugStreamBridgeActor"]
SR(["SignalR Hub / Blazor UI"])
end
DebugStreamBridgeActor InstanceActor
│ │
│── SubscribeDebugView ──► │ (ClusterClient: command/control)
│◄── DebugViewSnapshot ── │
│ │
│ │ publishes AttributeValueChanged
│ │ publishes AlarmStateChanged
│ ▼
SiteStreamGrpcClient ◄──── gRPC stream ───── SiteStreamGrpcServer
(per-site, on central) (HTTP/2) (Kestrel, on site)
│ │
│ reads from gRPC stream │ receives from SiteStreamManager
│ routes by correlationId │ filters by instance name
▼ │
DebugStreamBridgeActor │
│ │
▼ │
SignalR Hub / Blazor UI │
subgraph SITE["Site Cluster"]
IN["InstanceActor"]
PB{"publishes<br/>AttributeValueChanged<br/>AlarmStateChanged"}
GS["SiteStreamGrpcServer<br/>(Kestrel, on site)"]
end
BT -.->|"SubscribeDebugView"| IN
IN -.->|"DebugViewSnapshot"| BT
IN --> PB
PB --> GS
GS -->|"gRPC stream (HTTP/2)"| GC
GC --> BB
BB --> SR
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
class BT,GC,BB proc
class SR start
class IN,GS warn
class PB dec
```
**Key separation**: ClusterClient handles subscribe/unsubscribe/snapshot (request-response). gRPC handles the ongoing value stream (server-streaming).
@@ -271,16 +281,22 @@ public override async Task SubscribeInstance(
`IServerStreamWriter<T>` is **not thread-safe**. Multiple Akka actors may publish events concurrently. The `Channel<SiteStreamEvent>` bridges these worlds:
```
Akka Actor Thread(s) gRPC Response Stream
│ ▲
│ channel.Writer.TryWrite(evt) │ await responseStream.WriteAsync(evt)
▼ │
┌─────────────────────────────────────────┐
│ Channel<SiteStreamEvent> │
│ BoundedChannelOptions(1000) │
│ FullMode = DropOldest │
└─────────────────────────────────────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
AKKA["Akka Actor Thread(s)"]
CH(["Channel&lt;SiteStreamEvent&gt;<br/><br/>BoundedChannelOptions(1000)<br/>FullMode = DropOldest"])
GRPC["gRPC Response Stream"]
AKKA -->|"channel.Writer.TryWrite(evt)"| CH
CH -->|"await responseStream.WriteAsync(evt)"| GRPC
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
class AKKA warn
class CH start
class GRPC proc
```
- **Bounded capacity** (1000): prevents unbounded memory growth if the gRPC client is slow
@@ -431,31 +447,32 @@ private void HandleGrpcStreamError(Exception ex)
### Reconnection State Machine (DebugStreamBridgeActor)
```
┌──────────────────┐
│ Streaming │ ◄── Normal state: gRPC stream active
└────────┬─────────┘
│ gRPC stream error / keepalive timeout
┌──────────────────┐
┌──► │ Reconnecting │ ── try other node endpoint
│ └────────┬─────────┘
│ │
│ ┌────────┴─────────┐
│ │ │
│ success failure (retry < max)
│ │ │
│ ▼ │
│ Streaming schedule retry (5s backoff)
│ │
└───────────────────────┘
failure (retry >= max)
┌──────────────────┐
│ Terminated │ ── notify consumer, stop actor
└──────────────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
S(["Streaming<br/><i>Normal state: gRPC stream active</i>"])
R(["Reconnecting<br/><i>try other node endpoint</i>"])
D{"reconnect result?"}
RT["schedule retry<br/>(5s backoff)"]
T(["Terminated<br/><i>notify consumer, stop actor</i>"])
S -->|"gRPC stream error / keepalive timeout"| R
R --> D
D -->|"success"| S
D -->|"failure (retry &lt; max)"| RT
RT --> R
D -->|"failure (retry &gt;= max)"| T
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class S start
class R dec
class D proc
class RT warn
class T bad
```
### Summary
+46 -3
View File
@@ -73,6 +73,9 @@ scadabridge template script update --id <id> [--name <name>] [--code <code>] [--
scadabridge template script delete --id <id>
scadabridge template composition add --template-id <id> --instance-name <name> --composed-template-id <id>
scadabridge template composition delete --template-id <id> --instance-name <name>
scadabridge template native-alarm-source add --template-id <id> --name <name> --connection <name> --source-ref <ref> [--filter <expr>] [--description <desc>] [--locked]
scadabridge template native-alarm-source list --template-id <id>
scadabridge template native-alarm-source remove --id <id>
```
### Instance Commands
@@ -85,6 +88,8 @@ scadabridge instance set-overrides --id <id> --overrides <json>
scadabridge instance alarm-override set --instance-id <id> --alarm <name> [--trigger-config <json>] [--priority <n>]
scadabridge instance alarm-override delete --instance-id <id> --alarm <name>
scadabridge instance alarm-override list --instance-id <id>
scadabridge instance native-alarm-source set --instance-id <id> --source <name> [--connection <name>] [--source-ref <ref>] [--filter <expr>]
scadabridge instance native-alarm-source clear --instance-id <id> --source <name>
scadabridge instance set-area --id <id> [--area-id <id>]
scadabridge instance diff --id <id>
scadabridge instance deploy --id <id>
@@ -97,6 +102,44 @@ scadabridge instance delete --id <id>
`[["Speed", 5], ["Mode", 7]]`. `--overrides` is a JSON object of attribute name to
value, e.g. `{"Speed": "100", "Mode": null}`.
### Native Alarm Source Commands
The `native-alarm-source` subcommands manage the **read-only native alarm mirror**
alarms surfaced from an alarm-capable data connection rather than evaluated by the
ScadaBridge alarm engine. Native alarm sources are declared on a template and may be
overridden per instance. The subcommands map to management commands that resolve via
`ManagementCommandRegistry`:
- `--connection` names an alarm-capable data connection (**OPC UA** or **MxGateway**).
- `--source-ref` is the connection-specific reference: an **OPC UA `SourceNode` nodeId**
or an **MxAccess object/area**.
- `--filter` is an optional connection-specific filter expression that narrows the
mirrored alarm set.
**Template-level** (defines the inherited native alarm sources):
| CLI command | Management command | Required role |
|-------------|--------------------|---------------|
| `template native-alarm-source add` | `AddTemplateNativeAlarmSourceCommand` | Design |
| `template native-alarm-source list` | `ListTemplateNativeAlarmSourcesCommand` | — |
| `template native-alarm-source remove` | `DeleteTemplateNativeAlarmSourceCommand` | Design |
`add` takes `--name`, `--connection`, and `--source-ref` (required), plus optional
`--filter`, `--description`, and `--locked` (a flag that prevents instance-level
override). `remove` targets a single native alarm source by its own `--id`.
**Instance-level** (per-instance overrides of an inherited source; upsert semantics):
| CLI command | Management command | Required role |
|-------------|--------------------|---------------|
| `instance native-alarm-source set` | `SetInstanceNativeAlarmSourceOverrideCommand` | Deployment |
| `instance native-alarm-source clear` | `DeleteInstanceNativeAlarmSourceOverrideCommand` | Deployment |
`set` is an **upsert** keyed by `--instance-id` and `--source` (the inherited source
name): a blank/omitted `--connection`, `--source-ref`, or `--filter` keeps the
inherited value, so only the supplied options are overridden. `clear` removes the
override and **reverts the instance to the inherited template value**.
### Site Commands
```
scadabridge site list
@@ -267,9 +310,9 @@ Configuration is resolved in the following priority order (highest wins):
1. **Command-line options**: `--url`, `--username`, `--password`, `--format`.
2. **Environment variables**:
- `SCADALINK_MANAGEMENT_URL` — Management API URL (e.g., `http://central-host:5000`).
- `SCADALINK_FORMAT` — Default output format (`json` or `table`).
- `SCADALINK_USERNAME` / `SCADALINK_PASSWORD` — LDAP credentials. Preferred over
- `SCADABRIDGE_MANAGEMENT_URL` — Management API URL (e.g., `http://central-host:5000`).
- `SCADABRIDGE_FORMAT` — Default output format (`json` or `table`).
- `SCADABRIDGE_USERNAME` / `SCADABRIDGE_PASSWORD` — LDAP credentials. Preferred over
`--password` on the command line, which is visible in process listings and shell
history. Credentials are never read from the config file.
3. **Configuration file**: `~/.scadabridge/config.json` — Persistent defaults for management URL and output format only (never credentials).
+24
View File
@@ -45,6 +45,13 @@ Central cluster only. Sites have no user interface.
- Manage template hierarchy (inheritance) — visual tree of parent/child relationships.
- Manage composition — add/remove feature module instances within templates. **Naming collision detection** provides immediate feedback if composed modules introduce duplicate attribute, alarm, or script names.
- Define and edit attributes, alarms, and scripts on templates.
- **Native Alarms tab** (`TemplateEdit`): a tab alongside Attributes / Alarms / Scripts / Compositions that lists the template's **native alarm source bindings** — the OPC UA Alarms & Conditions / MxAccess Gateway sources whose alarm state the instance mirrors. Each binding carries Name, Connection, Source Reference, optional Condition Filter, Description, and a Lock flag. Add / edit / delete go through a **modal**:
- **Name** — unique within the template (lock/inherit bookkeeping mirrors `TemplateAlarm`).
- **Connection** — a dropdown filtered to **alarm-capable connections only** (OPC UA and MxGateway protocols).
- **Source Reference** — the native key (OPC UA SourceNode / notifier nodeId, or MxAccess object/area).
- **Condition Filter** (optional) — blank mirrors *all* conditions under the source.
- **Description** (optional) and **Lock** (prevents instance-level override, like locked alarms/attributes).
- CRUD is **repository-direct** (Blazor Server runs in-process against `ICentralUiRepository`); no Akka round-trip is needed for design-time authoring.
- Set lock flags on attributes, alarms, and scripts.
- Visual indicator showing inherited vs. locally defined vs. overridden members.
- **On-demand validation**: A "Validate" action allows Design users to run comprehensive pre-deployment validation (flattening, naming collisions, script compilation, trigger references) without triggering a deployment. Provides early feedback during authoring.
@@ -97,6 +104,11 @@ Central cluster only. Sites have no user interface.
- **Override** — optional per-attribute OPC UA node id (or other protocol address). When set, replaces the template's `DataSourceReference` at flattening time; when blank, the template default is used. The greyed placeholder shows the template default for context.
- **Browse…** — opens the OPC UA Tag Browser dialog, populated live from the site's OPC UA server via `BrowseOpcUaNodeCommand`. Visible only when the row's connection uses the OPC UA protocol; disabled until a connection is picked on that row. The dialog lazy-loads the address space, supports manual node-id entry as a fallback, and remains usable when the site or its OPC UA session is offline (the manual-paste field stays active even on error).
- Set instance-level attribute overrides (non-locked attributes only).
- **Native Alarm Source Overrides card** (`InstanceConfigure`): a card placed **after the Alarm Overrides card**, listing the template's native alarm sources for per-instance binding. Each row offers **inline override** of the three fields that typically vary per physical instance:
- **Connection** — a dropdown (same alarm-capable filtering as the template editor).
- **Source Reference** — the concrete native key for this instance.
- **Filter** — the per-instance condition filter.
- A **blank field inherits** the template default (the greyed placeholder shows the inherited value for context, mirroring the per-attribute Override field). **Save** and **Clear** act per row — Save persists the row's overrides, Clear reverts the row to the template-inherited binding. Locked template sources are not overridable.
- Filter/search instances by site, area, template, or status.
- **Disable** instances — stops data collection, script triggers, and alarm evaluation at the site while retaining the deployed configuration.
- **Enable** instances — re-activates a disabled instance.
@@ -127,6 +139,18 @@ Central cluster only. Sites have no user interface.
- Stream includes attribute values formatted as `[InstanceUniqueName].[AttributePath].[AttributeName]` and alarm states formatted as `[InstanceUniqueName].[AlarmName]`.
- Subscribe-on-demand — stream starts when opened, stops when closed.
#### Alarm Table (Computed + Native)
The DebugView alarm table is the **only** runtime surface for native OPC UA Alarms & Conditions and MxAccess Gateway alarms (no dedicated operator/alarm-summary page). Native alarms are a **read-only mirror** of source-reported state — the source system owns the alarm lifecycle (ack / shelve / suppress), so the table never offers ack-back or any command action. Both enriched `AlarmStateChanged` events (live, via the gRPC stream) and the initial `DebugViewSnapshot` (via ClusterClient) carry the unified alarm shape, so native alarms appear on the first paint and update in place. The table is a custom Blazor + Bootstrap component (no third-party grid).
- **Kind column** — a badge distinguishing **Computed** alarms from native ones (an **OPC UA** or **MxAccess** badge), driven by the event's `AlarmKind` discriminator.
- **Sev column** — the unified **01000 severity** (`AlarmConditionState.Severity`) shown for every row. Computed rows surface their integer priority on the same scale.
- **Source reference subtitle** — for native rows, the `SourceReference` (e.g. `Tank01.Level.HiHi`) renders as a **monospace subtitle under the alarm name**. Computed rows have no subtitle and render exactly as before this change.
- **State cell composite badges** — the orthogonal condition sub-states roll up into badges shown beside the active/normal state: **Unacked**, **Shelved**, and **Suppressed** appear only when the corresponding `AlarmConditionState` flag is set. Computed alarms are auto-acked and never shelved/suppressed, so they show none of these.
- **Row tooltip** — hovering a row surfaces the native metadata that does not warrant its own column: alarm type (`AlarmTypeName`), category, operator user and comment (source-supplied ack metadata, display-only), original raise time, and the current/limit value.
- **Filter** — the existing alarm filter additionally matches the native `SourceReference` (in addition to the alarm name), so operators can find a mirrored condition by its source path.
- **Computed alarms render unchanged** — no Kind badge styling change, no subtitle, no new state badges beyond what the unified model implies; the enrichment is purely additive for native rows.
### Parked Message Management (Deployment Role)
- Query sites for parked messages (external system calls, cached DB writes). (Parked notifications are managed centrally on the Notification Outbox page, not here.)
- View message details (target, payload, retry count, timestamps).
+25 -9
View File
@@ -34,6 +34,11 @@ Commons must define shared primitive and utility types used across multiple comp
- **`AlarmState` enum**: Active, Normal.
- **`AlarmLevel` enum**: None, Low, LowLow, High, HighHigh. Severity level for an active alarm; always `None` for binary trigger types, set by `HiLo` triggers.
- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange, HiLo.
- **`AlarmKind` enum**: Computed, NativeOpcUa, NativeMxAccess. Discriminates how an alarm's state is produced — evaluated at the site by an `AlarmActor` from attribute triggers (`Computed`) vs. mirrored read-only from a native source (OPC UA Alarms & Conditions / MxAccess Gateway).
- **`AlarmShelveState` enum**: Unshelved, OneShotShelved, TimedShelved, PermanentShelved. OPC UA Part 9 shelving sub-state of an alarm condition; mirrored read-only from the source. Computed alarms are always `Unshelved`.
- **`AlarmTransitionKind` enum**: Snapshot, SnapshotComplete, Raise, Acknowledge, Clear, Retrigger, StateChange. Classifies a `NativeAlarmTransition`; `Snapshot`/`SnapshotComplete` carry the initial active-condition replay produced on every (re)subscribe so consumers can re-seed state.
- **`AlarmConditionState`**: The unified, read-only alarm condition state. The OPC UA Part 9 sub-conditions are orthogonal (and MxAccess's ACTIVE / ACTIVE_ACKED / INACTIVE map cleanly onto them), so they are modeled as independent flags — `Active`, `Acknowledged`, `Confirmed` (`bool?`; null when not confirmable), `Shelve` (`AlarmShelveState`), `Suppressed` — plus a `Severity` on the unified 01000 scale. Computed alarms populate it from State + Priority; native alarms mirror it from the source. (Helper `AlarmConditionStateFactory` builds the computed-alarm projection.)
- **`NativeAlarmTransition`**: The protocol-neutral alarm transition emitted by an `IAlarmSubscribableConnection` adapter — `SourceReference` (stable per-condition key), `SourceObjectReference` (owning source object, used for instance routing), `AlarmTypeName`, `Kind` (`AlarmTransitionKind`), `Condition` (`AlarmConditionState`), source taxonomy/operator/value metadata (`Category`, `Description`, `Message`, `OperatorUser`, `OperatorComment`, `CurrentValue`, `LimitValue`), and `OriginalRaiseTime?` / `TransitionTime`.
- **`ConnectionHealth` enum**: Connected, Disconnected, Connecting, Error.
- **`TrackedOperationId`**: A GUID identifying a tracked store-and-forward operation (`ExternalSystem.CachedCall`, `Database.CachedWrite`, `Notify.Send`). Generated caller-side at the site at call time, returned to the script as a tracking handle, and reused as the idempotency key for telemetry sent to central. The notification domain's existing `NotificationId` is the notification-specific name for this same concept.
- **`TrackedOperationKind` enum**: ExternalCall, DatabaseWrite. Discriminates the two cached-call kinds carried by a tracked operation (notifications are tracked separately via the `NotificationType` enum).
@@ -54,6 +59,7 @@ Types defined here must be immutable and thread-safe.
Commons must define the protocol abstraction interfaces that the Data Connection Layer implements and other components consume:
- **`IDataConnection`**: The common interface for reading, writing, and subscribing to device data regardless of the underlying protocol (OPC UA, custom legacy, etc.).
- **`IAlarmSubscribableConnection`**: An **optional capability interface** an `IDataConnection` implementation *may also* implement when its source can mirror native alarms (OPC UA Alarms & Conditions, MxAccess Gateway). Exposes `SubscribeAlarmsAsync(sourceReference, conditionFilter, callback, ct)` (returns a subscription id; replays a snapshot of currently-active conditions on every (re)subscribe) and `UnsubscribeAlarmsAsync(subscriptionId, ct)`. Transitions are delivered via the `AlarmTransitionCallback` delegate as protocol-neutral `NativeAlarmTransition` records. Mirrors the `IBrowsableDataConnection` capability-interface pattern; consumed by the Data Connection Layer's `DataConnectionActor` only.
- **Related types**: Tag identifiers, read/write results, subscription callbacks, connection status enums, and quality codes.
These interfaces must not reference any specific protocol implementation.
@@ -69,8 +75,8 @@ Commons must define persistence-ignorant POCO entity classes for all configurati
Entity classes are organized by domain area:
- **Template & Modeling**: `Template`, `TemplateAttribute`, `TemplateAlarm`, `TemplateScript`, `TemplateComposition`, `TemplateFolder`.
- **Instances**: `Instance`, `InstanceAttributeOverride`, `InstanceConnectionBinding`, `InstanceAlarmOverride`, `Area`.
- **Template & Modeling**: `Template`, `TemplateAttribute`, `TemplateAlarm`, `TemplateNativeAlarmSource`, `TemplateScript`, `TemplateComposition`, `TemplateFolder`. `TemplateNativeAlarmSource` is a read-only binding (Name, Description, ConnectionName, SourceReference, optional ConditionFilter, plus `IsLocked`/`IsInherited`/`LockedInDerived` lock flags) to a native alarm source; inheritance/lock semantics mirror `TemplateAlarm`.
- **Instances**: `Instance`, `InstanceAttributeOverride`, `InstanceConnectionBinding`, `InstanceAlarmOverride`, `InstanceNativeAlarmSourceOverride`, `Area`. `InstanceNativeAlarmSourceOverride` retargets an inherited native alarm source per physical instance, keyed by `SourceCanonicalName`; its `ConnectionNameOverride` / `SourceReferenceOverride` / `ConditionFilterOverride` fields each apply only when non-null (null keeps the inherited value), mirroring `InstanceAlarmOverride`.
- **Shared Scripts**: `SharedScript`.
- **Sites & Data Connections**: `Site`, `DataConnection`.
- **External Systems & Database Connections**: `ExternalSystemDefinition`, `ExternalSystemMethod`, `DatabaseConnectionDefinition`.
@@ -86,7 +92,7 @@ The **`Notification`** entity is the persistence-ignorant POCO for a row of the
Commons must define repository interfaces that consuming components use for data access. Each interface is tailored to the data needs of its consuming component:
- `ITemplateEngineRepository` — Templates, attributes, alarms, scripts, compositions, template folders, instances, overrides, alarm overrides, connection bindings, areas.
- `ITemplateEngineRepository` — Templates, attributes, alarms, native alarm sources, scripts, compositions, template folders, instances, overrides, alarm overrides, native alarm source overrides, connection bindings, areas.
- `IDeploymentManagerRepository` — Deployment records, deployed configuration snapshots, system-wide artifact deployment records.
- `ISecurityRepository` — LDAP group mappings, site scoping rules.
- `IInboundApiRepository` — API keys, API method definitions.
@@ -134,7 +140,8 @@ Commons must define the shared DTOs and message contracts used for inter-compone
- **Instance Lifecycle DTOs**: Disable, enable, delete commands and responses.
- **Health DTOs**: Health check results, site status reports, heartbeat messages. Includes script error rates and alarm evaluation error rates.
- **Communication DTOs**: Site identity, connection state, routing metadata.
- **Attribute Stream DTOs**: Attribute value change messages (instance name, attribute path, value, quality, timestamp) and alarm state change messages (instance name, alarm name, state, priority, timestamp) for the site-wide Akka stream.
- **Attribute Stream DTOs**: Attribute value change messages (instance name, attribute path, value, quality, timestamp) and alarm state change messages (instance name, alarm name, state, priority, timestamp) for the site-wide Akka stream. The alarm state change message (`AlarmStateChanged`) is **additively enriched** to carry both computed and native alarms on one shape: an `AlarmKind` discriminator, the unified `AlarmConditionState`, and native metadata (`SourceReference`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime`, `CurrentValue`, `LimitValue`) — defaulted/empty for computed alarms. Subject to the additive-only evolution rules in REQ-COM-5a, since it crosses the site→central gRPC stream.
- **Native Alarm DTOs** (`Messages/DataConnection/`): the read-only native alarm mirror messages between the Site Runtime and the Data Connection Layer — `SubscribeAlarmsRequest` / `SubscribeAlarmsResponse` (subscribe a source binding; response carries success + optional error), `UnsubscribeAlarmsRequest`, `NativeAlarmTransitionUpdate` (`ConnectionName`, `Transition` — one routed `NativeAlarmTransition`, including snapshot replay), and `NativeAlarmSourceUnavailable` (`ConnectionName`, `SourceReference`, `Timestamp` — the feed dropped on connection loss).
- **Debug View DTOs**: Subscribe/unsubscribe requests, one-shot snapshot request (`DebugSnapshotRequest`), initial snapshot, stream filter criteria.
- **Script Execution DTOs**: Script call requests (with recursion depth), return values, error results.
- **System-Wide Artifact DTOs**: Shared script packages, external system definitions, database connection definitions, notification list definitions.
@@ -174,13 +181,17 @@ ZB.MOM.WW.ScadaBridge.Commons/
│ ├── SiteCallOperational.cs # SiteCalls operational-row projection
│ ├── TrackingStatusSnapshot.cs # site-local Tracking.Status(id) projection
│ ├── Enums/ # InstanceState, DeploymentStatus, AlarmState,
│ │ # AlarmLevel, AlarmTriggerType, ConnectionHealth,
│ │ # AlarmLevel, AlarmTriggerType, AlarmKind,
│ │ # AlarmShelveState, AlarmTransitionKind,
│ │ # ConnectionHealth,
│ │ # DataType, StoreAndForwardCategory,
│ │ # StoreAndForwardMessageStatus,
│ │ # NotificationType, NotificationStatus,
│ │ # TrackedOperationKind, TrackedOperationStatus,
│ │ # AuditChannel, AuditKind, AuditStatus,
│ │ # AuditForwardState
│ ├── Alarms/ # AlarmConditionState, AlarmConditionStateFactory,
│ │ # NativeAlarmTransition (unified read-only condition model)
│ ├── Audit/ # AuditLogPaging, AuditLogQueryFilter,
│ │ # AuditQueryParamParsers, ExecutionTreeNode,
│ │ # SiteCallKpiSnapshot, SiteCallPaging,
@@ -199,7 +210,9 @@ ZB.MOM.WW.ScadaBridge.Commons/
├── Interfaces/ # Shared interfaces by concern
│ ├── IOperationTrackingStore.cs # site-local tracked-operation status store
│ ├── IPartitionMaintenance.cs # central partition-switch / retention purge hook
│ ├── Protocol/ # REQ-COM-2: Protocol abstraction (IDataConnection, etc.)
│ ├── Protocol/ # REQ-COM-2: Protocol abstraction (IDataConnection,
│ │ # IBrowsableDataConnection, IAlarmSubscribableConnection,
│ │ # AlarmTransitionCallback)
│ ├── Repositories/ # REQ-COM-4: Per-component repository interfaces
│ │ ├── ITemplateEngineRepository.cs
│ │ ├── IDeploymentManagerRepository.cs
@@ -231,9 +244,11 @@ ZB.MOM.WW.ScadaBridge.Commons/
│ └── IBundleSessionStore.cs
├── Entities/ # REQ-COM-3: Domain entity POCOs, by domain area
│ ├── Templates/ # Template, TemplateAttribute, TemplateAlarm,
│ │ # TemplateScript, TemplateComposition, TemplateFolder
│ │ # TemplateNativeAlarmSource, TemplateScript,
│ │ # TemplateComposition, TemplateFolder
│ ├── Instances/ # Instance, InstanceAttributeOverride,
│ │ # InstanceConnectionBinding, InstanceAlarmOverride, Area
│ │ # InstanceConnectionBinding, InstanceAlarmOverride,
│ │ # InstanceNativeAlarmSourceOverride, Area
│ ├── Sites/ # Site, DataConnection
│ ├── ExternalSystems/ # ExternalSystemDefinition, ExternalSystemMethod,
│ │ # DatabaseConnectionDefinition
@@ -256,7 +271,8 @@ ZB.MOM.WW.ScadaBridge.Commons/
│ ├── DebugView/
│ ├── ScriptExecution/
│ ├── Artifacts/
│ ├── DataConnection/ # data-connection subscribe/write/health messages
│ ├── DataConnection/ # data-connection subscribe/write/health messages,
│ │ # native alarm subscribe/transition messages
│ ├── Instance/ # attribute get/set request/command messages
│ ├── Integration/ # external-integration call request/response,
│ │ # cached-call tracking telemetry + reconcile,
+73 -12
View File
@@ -87,6 +87,32 @@ The streaming protocol is defined in `sitestream.proto` (`src/ZB.MOM.WW.ScadaBri
- The `oneof event` pattern is extensible — future event types (health metrics, connection state changes) are added as new fields without breaking existing consumers.
- Proto field numbers are never reused. Old clients ignore unknown `oneof` variants.
#### Enriched AlarmStateUpdate (Native Alarm Mirror)
`AlarmStateUpdate` carries the read-only native alarm mirror (Computed, native OPC UA, and native MxAccess Gateway alarms) to central over the **existing gRPC real-time stream** — no new transport, no command/control round-trip. The message was extended **additively**: existing fields 17 are unchanged, and fields 821 carry the enriched native-alarm state. Old clients that only read fields 17 continue to work; new fields are populated only where the source provides them.
| Field | # | Type | Meaning |
|-------|---|------|---------|
| `kind` | 8 | string | Alarm origin: `Computed`, `NativeOpcUa`, or `NativeMxAccess`. |
| `active` | 9 | bool | Alarm condition is active. |
| `acknowledged` | 10 | bool | Alarm has been acknowledged. |
| `confirmed` | 11 | bool | Alarm has been confirmed. The domain `Confirmed` (`bool?`) collapses to a definite bool on the wire. |
| `shelve_state` | 12 | string | `Unshelved`, `OneShotShelved`, `TimedShelved`, or `PermanentShelved`. |
| `suppressed` | 13 | bool | Alarm is suppressed by the source system. |
| `source_reference` | 14 | string | Source node / tag reference. |
| `alarm_type_name` | 15 | string | Native alarm type name. |
| `category` | 16 | string | Alarm category. |
| `operator_user` | 17 | string | User who last acted on the alarm. |
| `operator_comment` | 18 | string | Operator comment from the last action. |
| `original_raise_time` | 19 | Timestamp | First-raise time of the underlying condition (nullable on the wire). |
| `current_value` | 20 | string | Current process value associated with the alarm. |
| `limit_value` | 21 | string | Limit / setpoint value that the alarm evaluates against. |
- **Server-side mapping (`StreamRelayActor.HandleAlarmStateChanged`)**: maps the enriched domain `AlarmStateChanged` event — `Kind` + `AlarmConditionState` + native metadata — out to the proto `AlarmStateUpdate`. The nullable `original_raise_time` is emitted only when present, and `shelve_state` is mapped from the domain shelve enum to its wire string via a new **`AlarmShelveStateCodec`** (string↔enum, defaulting to `Unshelved`). The domain `Confirmed` (`bool?`) is collapsed to a definite bool for field 11.
- **Client-side mapping (`SiteStreamGrpcClient.ConvertToDomainEvent`)**: reconstructs the domain `AlarmStateChanged` from the proto — `Kind` is parsed via `ParseAlarmKind`, the `Condition` is rebuilt with `severity` taken from the existing wire `priority`, and native metadata is repopulated from fields 821 — so central-side consumers receive the same domain event the site emitted.
> **Regeneration is manual (macOS-only).** `sitestream.proto` is **not** auto-compiled: the `<Protobuf>` include is commented out in the `.csproj`, and the generated C# is **vendored** under `SiteStreamGrpc/`. To regenerate after editing the proto: toggle the `<Protobuf>` include on, build so `Grpc.Tools` regenerates the C#, copy the generated files into `SiteStreamGrpc/`, then re-comment the include. Adding fields 821 followed this process.
#### gRPC Connection Keepalive
Three layers of dead-client detection prevent orphan streams on site nodes:
@@ -141,19 +167,54 @@ Keepalive settings are configurable via `CommunicationOptions`:
## Topology
```
Central Cluster
├── ClusterClient → Site A Cluster (SiteCommunicationActor via Receptionist) [command/control]
├── ClusterClient → Site B Cluster (SiteCommunicationActor via Receptionist) [command/control]
└── ClusterClient → Site N Cluster (SiteCommunicationActor via Receptionist) [command/control]
├── SiteStreamGrpcClient ◄── gRPC stream ── Site A (SiteStreamGrpcServer) [real-time data]
├── SiteStreamGrpcClient ◄── gRPC stream ── Site B (SiteStreamGrpcServer) [real-time data]
└── SiteStreamGrpcClient ◄── gRPC stream ── Site N (SiteStreamGrpcServer) [real-time data]
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart LR
subgraph Central["Central Cluster"]
CCA["ClusterClient<br/>(command/control)"]
CCB["ClusterClient<br/>(command/control)"]
CCN["ClusterClient<br/>(command/control)"]
GRPCC["SiteStreamGrpcClient<br/>(real-time data)"]
end
Site Clusters
└── ClusterClient → Central Cluster (CentralCommunicationActor via Receptionist) [command/control]
└── SiteStreamGrpcServer (Kestrel HTTP/2, port 8083) → serves gRPC streams [real-time data]
subgraph SiteA["Site A Cluster"]
SACOMM["SiteCommunicationActor<br/>(via Receptionist)"]
SAGRPC["SiteStreamGrpcServer<br/>(Kestrel HTTP/2, port 8083)"]
SACC["ClusterClient to Central<br/>(CentralCommunicationActor)"]
end
subgraph SiteB["Site B Cluster"]
SBCOMM["SiteCommunicationActor<br/>(via Receptionist)"]
SBGRPC["SiteStreamGrpcServer"]
end
subgraph SiteN["Site N Cluster"]
SNCOMM["SiteCommunicationActor<br/>(via Receptionist)"]
SNGRPC["SiteStreamGrpcServer"]
end
CCA -->|command/control| SACOMM
CCB -->|command/control| SBCOMM
CCN -->|command/control| SNCOMM
SAGRPC -->|"gRPC stream (real-time data)"| GRPCC
SBGRPC -->|gRPC stream| GRPCC
SNGRPC -->|gRPC stream| GRPCC
SACC -.->|command/control| Central
NOTE["Sites do NOT communicate with each other.<br/>All inter-cluster communication flows through Central."]
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class CCA,CCB,CCN,SACOMM,SACC,SBCOMM,SNCOMM dec
class GRPCC,SAGRPC,SBGRPC,SNGRPC start
class NOTE muted
class Central proc
class SiteA,SiteB,SiteN alt
```
- Sites do **not** communicate with each other.
@@ -32,10 +32,12 @@ The configuration database stores all central system data, organized by domain a
- **TemplateFolders**: Hierarchical organizational folders for templates (`Id`, `Name`, nullable `ParentFolderId` self-reference, `SortOrder`). Unique index on `(ParentFolderId, Name)` enforces case-insensitive sibling uniqueness. Folders are UI-only — they have no effect on template resolution or flattening.
- **Template Attributes**: Attribute definitions per template (name, value, data type, lock flag, description, data source reference).
- **Template Alarms**: Alarm definitions per template (name, description, priority, lock flag, trigger type, trigger configuration, on-trigger script reference).
- **Native Alarm Sources** (`NativeAlarmSources`): Native alarm source bindings per template — alarms produced by the underlying data source rather than evaluated by the Site Runtime. Columns: `Name`, `Description`, `ConnectionName` (the data connection that surfaces the native alarms), `SourceReference` (the source-side address/path the native alarms are read from), and `ConditionFilter` (optional filter narrowing which native alarm conditions are subscribed). FK `TemplateId``Templates` with cascade delete; unique index on `(TemplateId, Name)`.
- **Template Scripts**: Script definitions per template (name, lock flag, C# source code, trigger type, trigger configuration, minimum time between runs, parameter definitions, return value definitions).
- **Template Compositions**: Feature module composition relationships (composing template, composed template, module instance name).
- **Instances**: Instance definitions (template reference, site reference, area reference, enabled/disabled state).
- **Instance Attribute Overrides**: Per-instance attribute value overrides.
- **Instance Native Alarm Source Overrides** (`InstanceNativeAlarmSourceOverrides`): Per-instance overrides for native alarm sources, keyed by the source's path-qualified canonical name. Columns: `SourceCanonicalName` (required, sized to fit composed `[ModuleInstanceName].[SourceName]` paths) and the nullable override fields `ConnectionNameOverride`, `SourceReferenceOverride`, and `ConditionFilterOverride` (a null override leaves the template value in effect). FK `InstanceId``Instances` with cascade delete; unique index on `(InstanceId, SourceCanonicalName)`.
- **Instance Connection Bindings**: Per-attribute data connection binding for each instance.
- **Areas**: Hierarchical area definitions per site (name, parent area reference, site reference).
@@ -90,13 +92,24 @@ A single `ScadaBridgeDbContext` (or a small number of bounded DbContexts if warr
- Configures relationships, indexes, constraints, and value conversions.
- Provides `SaveChangesAsync()` as the unit-of-work commit mechanism.
Each entity's Fluent mapping lives in its own `IEntityTypeConfiguration<T>` class under `Configurations/`, and `OnModelCreating` registers them all with `modelBuilder.ApplyConfigurationsFromAssembly(...)` — so a new mapping is picked up simply by adding its configuration class to the assembly.
#### Native Alarm Source Mappings
The native alarm source feature adds two EF-mapped entities (POCOs in Commons, Fluent mappings here), each exposed as a `DbSet` on `ScadaBridgeDbContext`:
- **`TemplateNativeAlarmSource`** → table `NativeAlarmSources` (`DbSet<TemplateNativeAlarmSource> TemplateNativeAlarmSources`). `Name` required (≤200), `Description` (≤2000), `ConnectionName` required (≤200), `SourceReference` required (≤1000), `ConditionFilter` (≤1000). Unique index `(TemplateId, Name)`. Owned by `Template` via `TemplateConfiguration` (`HasMany(t => t.NativeAlarmSources)` on FK `TemplateId`, `OnDelete: Cascade`) — deleting a template removes its native alarm sources.
- **`InstanceNativeAlarmSourceOverride`** → table `InstanceNativeAlarmSourceOverrides` (`DbSet<InstanceNativeAlarmSourceOverride> InstanceNativeAlarmSourceOverrides`). `SourceCanonicalName` required (≤400, wider than plain names so it can hold composed paths), `ConnectionNameOverride` (≤200), `SourceReferenceOverride` (≤1000), `ConditionFilterOverride` (≤1000). Unique index `(InstanceId, SourceCanonicalName)`. Owned by `Instance` via `InstanceConfiguration` (`HasMany(i => i.NativeAlarmSourceOverrides)` on FK `InstanceId`, `OnDelete: Cascade`).
Both mappings follow the same shape as the existing alarm definitions and alarm overrides: dedicated configuration classes that auto-register through `ApplyConfigurationsFromAssembly`, cascade-delete from their owning aggregate root, and a composite unique index that enforces name uniqueness within the owner.
### Per-Component Repository Implementations
Repository interfaces are defined in **Commons** alongside the POCO entity classes (see Component-Commons.md, REQ-COM-4). This component provides the **EF Core implementations** of those interfaces.
| Repository Interface (in Commons) | Consuming Component | Scope |
|---|---|---|
| `ITemplateEngineRepository` | Template Engine | Templates, attributes, alarms, scripts, compositions, instances, overrides, connection bindings, areas |
| `ITemplateEngineRepository` | Template Engine | Templates, attributes, alarms, native alarm sources, scripts, compositions, instances, overrides (including native alarm source overrides), connection bindings, areas |
| `IDeploymentManagerRepository` | Deployment Manager | Current deployment status per instance, deployed configuration snapshots, system-wide artifact deployment status per site (no deployment history — audit log provides historical traceability) |
| `ISecurityRepository` | Security & Auth | LDAP group mappings, site scoping rules |
| `IInboundApiRepository` | Inbound API | API keys, API method definitions |
@@ -109,6 +122,15 @@ Repository interfaces are defined in **Commons** alongside the POCO entity class
Each implementation class uses the DbContext internally and works with the POCO entity classes from Commons. Consuming components depend only on Commons (for interfaces and entities) — they never reference this component or EF Core directly. The DI container in the Host wires the implementations to the interfaces.
#### Native Alarm Source Repository Methods
`ITemplateEngineRepository` (implemented by `TemplateEngineRepository`) gains CRUD for both native alarm source entities, mirroring the existing alarm-override methods one-for-one:
- **Template side**: `GetTemplateNativeAlarmSourceByIdAsync`, `GetNativeAlarmSourcesByTemplateIdAsync`, `AddTemplateNativeAlarmSourceAsync`, `UpdateTemplateNativeAlarmSourceAsync`, `DeleteTemplateNativeAlarmSourceAsync`.
- **Instance side**: `GetNativeAlarmSourceOverridesByInstanceIdAsync`, `GetNativeAlarmSourceOverrideAsync(instanceId, sourceCanonicalName)`, `AddInstanceNativeAlarmSourceOverrideAsync`, `UpdateInstanceNativeAlarmSourceOverrideAsync`, `DeleteInstanceNativeAlarmSourceOverrideAsync`.
The aggregate loaders are extended to eager-load the new children so a template or instance is returned fully populated: `GetTemplateWithChildrenAsync` (and the other template loaders) `.Include(t => t.NativeAlarmSources)`, and the instance-with-children loader `.Include(i => i.NativeAlarmSourceOverrides)` alongside its existing attribute, alarm, and connection-binding includes. Consistent with every other repository method here, the `Add`/`Update`/`Delete` operations only **stage** changes on the DbContext — the caller commits them by invoking `SaveChangesAsync()` (typically together with the matching `IAuditService.LogAsync()` call in one transaction).
### Unit of Work
EF Core's DbContext naturally provides unit-of-work semantics:
@@ -121,14 +143,32 @@ EF Core's DbContext naturally provides unit-of-work semantics:
### Example Transactional Flow
```
Template Engine: Create Template
├── repository.AddTemplate(template) // template is a Commons POCO
├── repository.AddAttributes(attributes) // attributes are Commons POCOs
├── repository.AddAlarms(alarms) // alarms are Commons POCOs
├── repository.AddScripts(scripts) // scripts are Commons POCOs
└── repository.SaveChangesAsync() // single transaction commits all
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
start(["Template Engine: Create Template"])
add1["repository.AddTemplate(template)<br/>// template is a Commons POCO"]
add2["repository.AddAttributes(attributes)<br/>// attributes are Commons POCOs"]
add3["repository.AddAlarms(alarms)<br/>// alarms are Commons POCOs"]
add4["repository.AddScripts(scripts)<br/>// scripts are Commons POCOs"]
save["repository.SaveChangesAsync()<br/>// single transaction commits all"]
db[("Configuration DB<br/>(MS SQL)")]
start --> add1
add1 --> add2
add2 --> add3
add3 --> add4
add4 --> save
save -. "single transaction" .-> db
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class start start
class add1,add2,add3,add4 proc
class save dec
class db muted
```
---
@@ -162,13 +202,30 @@ Audit entries are written **synchronously** within the same database transaction
### Integration Example
```
Template Engine: Update Template
├── repository.UpdateTemplate(template)
├── auditService.LogAsync(user, "Update", "Template", template.Id,
template.Name, template)
└── repository.SaveChangesAsync() ← both the change and audit entry commit together
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
start(["Template Engine: Update Template"])
upd["repository.UpdateTemplate(template)"]
audit["auditService.LogAsync(user, &quot;Update&quot;, &quot;Template&quot;,<br/>template.Id, template.Name, template)"]
save["repository.SaveChangesAsync()"]
note["both the change and audit entry<br/>commit together"]
start --> upd
upd --> audit
audit --> save
save -.- note
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class start start
class upd proc
class audit alt
class save dec
class note warn
```
### Audit Entry Schema
@@ -248,6 +305,7 @@ A CI grep guard fails the build on any occurrence of `UPDATE … AuditLog` or `D
- Schema changes are managed via EF Core Migrations (`dotnet ef migrations add`, `dotnet ef migrations script`).
- Each migration is a versioned, incremental schema change.
- New tables are introduced as their own migration — for example, the `Notifications` table for the Notification Outbox ships as a dedicated EF Core migration that creates the table, its `Type`/`Status` value conversions, and its dispatcher and KPI indexes.
- The native alarm source tables ship in a dedicated `AddNativeAlarmSources` migration, parallel in shape to the existing `AddInstanceAlarmOverrides` migration: it creates `NativeAlarmSources` and `InstanceNativeAlarmSourceOverrides` with their columns, the `TemplateId``Templates` and `InstanceId``Instances` cascade-delete foreign keys, and the `(TemplateId, Name)` / `(InstanceId, SourceCanonicalName)` unique indexes.
- The initial `AuditLog` migration creates the monthly partition function `pf_AuditLog_Month` and partition scheme `ps_AuditLog_Month`, then creates the `AuditLog` table aligned to that scheme on `OccurredAtUtc`, along with the indexes listed under Database Schema. The migration also creates the `scadabridge_audit_writer` and `scadabridge_audit_purger` DB roles with the grants described in Database Roles. The ongoing **partition-maintenance job** that rolls the scheme forward each month (creating the next month's partition ahead of time) and switches out expired partitions is owned by the **Audit Log component** (`AuditLogPurgeActor` and its monthly roll-forward step), not by the Configuration Database component — this component is responsible only for the initial schema, roles, and any EF migrations against the table going forward.
### Development Environment
@@ -80,11 +80,38 @@ Data connections support an optional backup endpoint for automatic failover when
**Failover state machine:**
```
Connected → disconnect → push bad quality → retry active endpoint (5s)
→ N failures (≥ FailoverRetryCount) → switch to other endpoint
→ dispose adapter, create fresh adapter with other config
→ reconnect → ReSubscribeAll → Connected
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
connected(["Connected"])
pushbad["push bad quality"]
retry["retry active endpoint<br/>(5s)"]
decide{"N failures<br/>(≥ FailoverRetryCount)?"}
switch["switch to other endpoint"]
dispose["dispose adapter,<br/>create fresh adapter<br/>with other config"]
reconnect["reconnect"]
resub["ReSubscribeAll"]
connected -->|disconnect| pushbad
pushbad --> retry
retry --> decide
decide -->|"no (retry again)"| retry
decide -->|yes| switch
switch --> dispose
dispose --> reconnect
reconnect --> resub
resub -->|back to Connected| connected
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class connected start
class pushbad bad
class retry,reconnect,resub proc
class decide dec
class switch,dispose warn
```
- **Round-robin**: primary → backup → primary → backup. No preferred endpoint after first failover — the connection stays on whichever endpoint is working.
@@ -173,6 +200,58 @@ DCL is a clean data pipe on the hot path. Browse is an **opt-in capability** for
- Browse runs against the live session; no caching at DCL.
- **Frame-size guard**: the reply crosses the site→central Akka frame (default 128 KB) on a temp Ask actor; an oversized reply is silently discarded by remoting, hanging the picker. The child handler caps each `BrowseNodeResult` to a byte budget (~100 KB) before replying, OR-ing the adapter's own truncation signal into `Truncated`. This is protocol-agnostic (every adapter's reply funnels through it). Per-protocol upstream caps narrow the window first: OPC UA requests at most 500 references per node (continuation point → `Truncated`); MxGateway relies on the gateway's `BrowseChildren` page cap. A `Truncated` level prompts manual node-id entry in the picker rather than auto-paging.
## Native Alarm Mirroring
Some data sources publish their own alarms — OPC UA **Alarms & Conditions** servers and the **MxAccess Gateway**. The DCL can mirror these native alarms into the Site Runtime as a **read-only** feed: ScadaBridge reflects source alarm state but never acknowledges, confirms, shelves, or otherwise writes back to the source. This complements (does not replace) ScadaBridge's own computed alarms; it feeds the Site Runtime's `NativeAlarmActor` peer subsystem.
Like browse, this is an **opt-in capability** for protocols that support it. It does not touch the hot value path — alarm transitions flow over a separate per-connection feed.
### Capability Seam
Mirroring is exposed via the optional `IAlarmSubscribableConnection` capability interface (in Commons), which an `IDataConnection` implementation **may also** implement (mirroring the `IBrowsableDataConnection` pattern; consumed by the `DataConnectionActor` only):
```
IAlarmSubscribableConnection
├── SubscribeAlarmsAsync(sourceReference, conditionFilter?, callback, ct) → subscriptionId
└── UnsubscribeAlarmsAsync(subscriptionId, ct) → void
```
The `AlarmTransitionCallback` delivers a protocol-neutral `NativeAlarmTransition` per transition. On every (re)subscribe the adapter replays a **snapshot** of currently-active conditions (`Snapshot…` records terminated by a `SnapshotComplete` sentinel) so consumers can reconcile state after a reconnect.
### Protocol Adapters
- **OPC UA** (`OpcUaDataConnection` + `RealOpcUaClient`): a single **event MonitoredItem** (`AttributeId = EventNotifier`) on the Server object, with an `EventFilter` selecting `EventType` / `SourceNode` / `Severity` plus the `ConditionType` / `AcknowledgeableConditionType` / `AlarmConditionType` state fields. `ConditionRefresh` is invoked on subscribe to replay active conditions as the snapshot. The OPC UA field → `NativeAlarmTransition` mapping is isolated in the pure helper `OpcUaAlarmMapper`, unit-testable without a live server.
- **MxGateway** (`MxGatewayDataConnection` + `RealMxGatewayClient`): mirrors over the gateway package's `StreamAlarmsAsync` — a resumable background stream whose reconnect re-sends a snapshot. The field mapping lives in `MxGatewayAlarmMapper`.
Other/custom protocols do not implement the capability; a subscribe request against such a connection is replied to with a failure (`SubscribeAlarmsResponse.Success = false`).
### Connection Actor Behavior
The `DataConnectionActor` opens **one alarm feed per connection** (not per subscriber) and routes incoming transitions to instance subscribers by **source-object reference** — a prefix match of the transition's `SourceObjectReference` (falling back to `SourceReference`) against each subscriber's registered `SourceReference`. Subscribers (the Site Runtime's `NativeAlarmActor` instances) are **ref-counted per source**, so the underlying feed is opened once and torn down only when the last subscriber for that source unsubscribes.
- **State gating**: `SubscribeAlarmsRequest` is handled only in the **Connected** state; requests arriving while **Connecting**/**Reconnecting** are stashed (standard Become/Stash) and processed on entering Connected.
- **Capability check**: if `_adapter is not IAlarmSubscribableConnection`, the actor replies `SubscribeAlarmsResponse(Success = false, ...)`.
- **Reconnect handling**: on entering **Reconnecting**, the actor pushes a `NativeAlarmSourceUnavailable` to every alarm subscriber (consumers mark mirrored alarms uncertain rather than clearing them). On successful reconnection it re-subscribes the feed; the adapter re-emits a snapshot, reconciling state.
### Protocol-Neutral Types & Messages
All defined in Commons so the feed is identical across protocols:
| Type | Shape |
|------|-------|
| `NativeAlarmTransition` | `SourceReference`, `SourceObjectReference`, `AlarmTypeName`, `Kind`, `Condition`, `Category`, `Description`, `Message`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `TransitionTime`, `CurrentValue`, `LimitValue` |
| `AlarmConditionState` | `Active`, `Acknowledged`, `Confirmed?` (null when not confirmable), `Shelve`, `Suppressed`, `Severity` (01000) |
| `AlarmTransitionKind` (enum) | `Snapshot`, `SnapshotComplete`, `Raise`, `Acknowledge`, `Clear`, `Retrigger`, `StateChange` |
`OperatorUser` / `OperatorComment` and `CurrentValue` / `LimitValue` are display-only mirrors from the source.
**Messages:**
- `SubscribeAlarmsRequest` / `SubscribeAlarmsResponse` — instance (via the DCL manager) subscribes a source binding to native alarms; the response carries success + an optional error message.
- `UnsubscribeAlarmsRequest` — cancels a native alarm subscription for an instance + source.
- `NativeAlarmTransitionUpdate(ConnectionName, Transition)` — DCL → instance: one routed transition (including snapshot replay).
- `NativeAlarmSourceUnavailable(ConnectionName, SourceReference, Timestamp)` — DCL → instance: the feed for a source became unavailable (connection lost).
## Value Update Message Format
Each value update delivered to an Instance Actor includes:
@@ -245,11 +324,13 @@ The DCL reports the following metrics to the Health Monitoring component via the
## Dependencies
- **Site Runtime (Instance Actors)**: Receives subscription registrations and delivers value updates. Receives write requests.
- **Site Runtime (NativeAlarmActor)**: For alarm-subscribable connections, receives `SubscribeAlarmsRequest`/`UnsubscribeAlarmsRequest` and delivers `NativeAlarmTransitionUpdate` / `NativeAlarmSourceUnavailable` (read-only native alarm mirroring).
- **Health Monitoring**: Reports connection status.
- **Site Event Logging**: Logs connection status changes.
## Interactions
- **Site Runtime (Instance Actors)**: Bidirectional — delivers value updates, receives subscription registrations and write-back commands.
- **Site Runtime (NativeAlarmActor)**: Bidirectional — receives alarm subscribe/unsubscribe requests, delivers native alarm transitions and source-unavailable notifications (read-only; no ack-back to the source).
- **Health Monitoring**: Reports connection health periodically.
- **Site Event Logging**: Logs connection/disconnection events.
@@ -22,23 +22,46 @@ Central cluster only. The site-side deployment responsibilities (receiving confi
## Deployment Flow
```
Engineer (UI) → Deployment Manager (Central)
├── 1. Request validated + flattened config from Template Engine
│ (validation includes flattening, script compilation,
│ trigger references, connection binding completeness)
├── 2. If validation fails → return errors to UI, stop
├── 3. Send config to site via Communication Layer
│ │
│ ▼
│ Site Runtime (Deployment Manager Singleton)
│ ├── 4. Store new flattened config locally (SQLite)
│ ├── 5. Compile scripts at site
│ ├── 6. Create/update Instance Actor (with child Script + Alarm Actors)
│ └── 7. Report success/failure back to central
└── 8. Update deployment status in config DB
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
engineer(["Engineer (UI)"])
subgraph DMC["Deployment Manager (Central)"]
step1["1. Request validated and flattened config from Template Engine<br/>(validation: flattening, script compilation, trigger references,<br/>connection binding completeness)"]
step2{"2. Validation fails?"}
step2fail(["Return errors to UI, stop"])
step3["3. Send config to site via Communication Layer"]
step8[("8. Update deployment status in config DB")]
end
subgraph SR["Site Runtime (Deployment Manager Singleton)"]
step4[("4. Store new flattened config locally (SQLite)")]
step5["5. Compile scripts at site"]
step6["6. Create/update Instance Actor<br/>(with child Script + Alarm Actors)"]
step7["7. Report success/failure back to central"]
end
engineer --> step1
step1 --> step2
step2 -->|yes| step2fail
step2 -->|no| step3
step3 -->|config| step4
step4 --> step5
step5 --> step6
step6 --> step7
step7 -. "report success/failure" .-> step8
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class engineer start
class step1,step5,step6,step7 dec
class step2,step2fail bad
class step3 dec
class step8 proc
class step4 start
```
## Deployment Identity & Idempotency
+34 -13
View File
@@ -123,19 +123,40 @@ API method scripts are compiled at central startup — all method definitions ar
## Request Flow
```
External System
Inbound API (Central)
├── 1. Extract API key from request
├── 2. Validate key exists and is enabled
├── 3. Resolve method by name
├── 4. Check API key is in method's approved list
├── 5. Validate and deserialize parameters
├── 6. Execute implementation script (subject to method timeout)
├── 7. Serialize return value
└── 8. Return response
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
ext(["External System"])
api["Inbound API (Central)"]
s1["1. Extract API key from request"]
s2["2. Validate key exists and is enabled"]
s3["3. Resolve method by name"]
s4["4. Check API key is in method's approved list"]
s5["5. Validate and deserialize parameters"]
s6["6. Execute implementation script<br/>(subject to method timeout)"]
s7["7. Serialize return value"]
s8["8. Return response"]
ext --> api
api --> s1
s1 --> s2
s2 --> s3
s3 --> s4
s4 --> s5
s5 --> s6
s6 --> s7
s7 --> s8
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class ext start
class api proc
class s1,s2,s3,s4,s5,s7 dec
class s6 alt
class s8 warn
```
## Implementation Script Capabilities
@@ -105,6 +105,8 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
- **AddTemplateAttribute** / **UpdateTemplateAttribute** / **DeleteTemplateAttribute**: Manage attributes on a template.
- **AddTemplateAlarm** / **UpdateTemplateAlarm** / **DeleteTemplateAlarm**: Manage alarm definitions on a template.
- **AddTemplateNativeAlarmSource** / **UpdateTemplateNativeAlarmSource** / **DeleteTemplateNativeAlarmSource**: Manage native alarm source bindings (the read-only OPC UA Alarms & Conditions / MxAccess Gateway mirror) on a template — `Name`, `ConnectionName`, `SourceReference`, optional `ConditionFilter` / `Description`, `IsLocked`. Mutations gated to the **Design** role; handlers write through `ITemplateEngineRepository` directly.
- **ListTemplateNativeAlarmSources** (`TemplateId`): List a template's native alarm source bindings (read-only; any authenticated user).
- **AddTemplateScript** / **UpdateTemplateScript** / **DeleteTemplateScript**: Manage scripts on a template.
- **AddTemplateComposition** / **DeleteTemplateComposition**: Manage feature module compositions on a template.
@@ -113,6 +115,9 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
- **ListInstances** / **GetInstance**: Query instances, with filtering by site and area.
- **CreateInstance**: Create a new instance from a template.
- **UpdateInstanceOverrides**: Set attribute overrides on an instance.
- **SetInstanceAlarmOverride** / **DeleteInstanceAlarmOverride** / **ListInstanceAlarmOverrides**: Manage per-instance computed-alarm overrides.
- **SetInstanceNativeAlarmSourceOverride** / **DeleteInstanceNativeAlarmSourceOverride**: Retarget or clear a per-instance native alarm source binding, keyed by `SourceCanonicalName``ConnectionNameOverride` / `SourceReferenceOverride` / `ConditionFilterOverride` each apply only when non-null. Gated to the **Deployment** role.
- **ListInstanceNativeAlarmSourceOverrides** (`InstanceId`): List an instance's native alarm source overrides (read-only).
- **SetInstanceBindings** / **BindDataConnections**: Bind data connections to instance attributes.
- **AssignArea**: Assign an instance to an area.
- **EnableInstance** / **DisableInstance** / **DeleteInstance**: Instance lifecycle commands.
@@ -193,8 +198,8 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
Every incoming message carries the authenticated user's identity and roles. The ManagementActor enforces the same role-based authorization rules as the Central UI:
- **Admin** role required for: site management, area management, API key management, role mapping management, scope rule management, system configuration.
- **Design** role required for: template authoring (including template member management: attributes, alarms, scripts, compositions), shared scripts, external system definitions, database connection definitions, notification lists, inbound API method definitions.
- **Deployment** role required for: instance management, deployments, debug view, debug snapshot, parked message queries, site event log queries. Site scoping is enforced for site-scoped Deployment users.
- **Design** role required for: template authoring (including template member management: attributes, alarms, native alarm sources, scripts, compositions), shared scripts, external system definitions, database connection definitions, notification lists, inbound API method definitions.
- **Deployment** role required for: instance management (including instance alarm overrides and native alarm source overrides), deployments, debug view, debug snapshot, parked message queries, site event log queries. Site scoping is enforced for site-scoped Deployment users.
- **Read-only access** (any authenticated role): health summary, health site, site event log queries, parked message queries.
Unauthorized commands receive an `Unauthorized` response message. Failed authorization attempts are not audit logged (consistent with existing behavior).
@@ -24,23 +24,39 @@ SMTP and HTTP delivery is blocking I/O. Delivery work runs on a **dedicated bloc
## End-to-End Flow
```
Site script: Notify.To("list").Send(subject, body)
│ generate NotificationId (GUID) locally; return it to the script immediately
Site Store-and-Forward Engine (notification category, target = central)
│ durably forwards to central via CentralSite Communication (ClusterClient);
│ buffers/retries if central is unreachable
Central ingest: insert-if-not-exists on NotificationId → Notifications table (Pending)
│ ack the site → site S&F clears the message
Central Notification Outbox actor (singleton, active central node)
│ polls due rows; resolves the list; delivers via the matching adapter
├── success → Delivered
├── transient failure → Retrying (schedule NextAttemptAt)
└── permanent failure
/ retries exhausted → Parked
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
SCRIPT(["Site script: Notify.To('list').Send(subject, body)<br/>generate NotificationId (GUID) locally;<br/>return it to the script immediately"])
SNF["Site Store-and-Forward Engine<br/>(notification category, target = central)<br/>durably forwards to central via Central-Site Communication<br/>(ClusterClient); buffers/retries if central is unreachable"]
INGEST[("Central ingest: insert-if-not-exists on NotificationId<br/>to Notifications table (Pending)<br/>ack the site, site S and F clears the message")]
OUTBOX["Central Notification Outbox actor<br/>(singleton, active central node)<br/>polls due rows; resolves the list;<br/>delivers via the matching adapter"]
D1{Delivery outcome}
DELIVERED(["Delivered"])
RETRYING["Retrying<br/>(schedule NextAttemptAt)"]
PARKED(["Parked"])
SCRIPT --> SNF
SNF --> INGEST
INGEST --> OUTBOX
OUTBOX --> D1
D1 -->|success| DELIVERED
D1 -->|transient failure| RETRYING
D1 -->|"permanent failure /<br/>retries exhausted"| PARKED
RETRYING -.->|retry due| OUTBOX
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class SCRIPT,DELIVERED start
class SNF warn
class INGEST proc
class OUTBOX alt
class D1,RETRYING dec
class PARKED bad
```
The site forwards only `(listName, subject, body)` plus provenance — recipient resolution happens at central, at delivery time. This keeps notification-list definitions in one place and removes the deploy-to-sites artifact entirely.
+119 -14
View File
@@ -27,19 +27,56 @@ Site clusters only.
## Actor Hierarchy
```
Deployment Manager Singleton (Cluster Singleton)
├── Instance Actor ("MachineA-001")
│ ├── Script Actor ("MonitorSpeed") — coordinator
│ │ └── Script Execution Actor — short-lived, per invocation
│ ├── Script Actor ("CalculateOEE") — coordinator
│ │ └── Script Execution Actor — short-lived, per invocation
│ ├── Alarm Actor ("OverTemp") — coordinator
│ │ └── Alarm Execution Actor — short-lived, per on-trigger invocation
└── Alarm Actor ("LowPressure") — coordinator
├── Instance Actor ("MachineA-002")
│ └── ...
└── ...
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
DMS["Deployment Manager Singleton<br/>(Cluster Singleton)"]
IA1["Instance Actor<br/>('MachineA-001')"]
IA2["Instance Actor<br/>('MachineA-002')"]
IAMORE["… more Instance Actors"]
SA1["Script Actor ('MonitorSpeed')<br/>— coordinator"]
SA2["Script Actor ('CalculateOEE')<br/>— coordinator"]
AA1["Alarm Actor ('OverTemp')<br/>— coordinator (computed)"]
AA2["Alarm Actor ('LowPressure')<br/>— coordinator (computed)"]
NAA1["Native Alarm Actor ('OpcUaServer1')<br/>— read-only mirror, peer to Alarm Actor"]
SEA1["Script Execution Actor<br/>— short-lived, per invocation"]
SEA2["Script Execution Actor<br/>— short-lived, per invocation"]
AEA1["Alarm Execution Actor<br/>— short-lived, per on-trigger invocation"]
IA2CHILD["… (Script / Alarm Actors)"]
DMS --> IA1
DMS --> IA2
DMS -.-> IAMORE
IA1 --> SA1
IA1 --> SA2
IA1 --> AA1
IA1 --> AA2
IA1 --> NAA1
SA1 --> SEA1
SA2 --> SEA2
AA1 --> AEA1
IA2 -.-> IA2CHILD
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class DMS proc
class IA1,IA2 start
class SA1,SA2 dec
class AA1,AA2 bad
class NAA1 alt
class SEA1,SEA2,AEA1 warn
class IAMORE,IA2CHILD muted
```
---
@@ -204,6 +241,74 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
---
## Native Alarm Actor
### Role
- **Read-only mirror** of alarms raised natively by an external source — OPC UA Alarms & Conditions (A&C) servers and the MxAccess Gateway — surfaced into the Site Runtime alongside the alarms ScadaBridge computes itself.
- Created as a child of the **Instance Actor** and is a **peer to the computed Alarm Actor** (not a child of it). One `NativeAlarmActor` is spawned per resolved native alarm source binding on the instance.
- Mirrors source-of-truth condition state into the Instance Actor's view and onto the site-wide stream; it **does not** acknowledge, clear, or otherwise write back to the source. There is no ack-back path — the external source remains authoritative.
### Construction
- Constructed with `(ResolvedNativeAlarmSource source, string instanceName, IActorRef instanceActor, IActorRef dclManager, SiteStorageService storage, SiteRuntimeOptions options, ILogger logger, AlarmKind nativeKind = NativeOpcUa)`.
- `nativeKind` distinguishes the two native flavors and stamps the `Kind` on every emitted `AlarmStateChanged`. The Instance Actor selects it from the bound connection's protocol (see **Instance Actor wiring** below).
### Lifecycle & Subscription
- **PreStart**: rehydrates any previously mirrored conditions for this source from the site SQLite `native_alarm_state` table, then subscribes to the source through the Data Connection Layer by sending a `SubscribeAlarmsRequest` to the DCL manager. The DCL routes the subscription to the bound connection's `IAlarmSubscribableConnection` implementation.
- **Failed subscribe**: schedules a retry timer at `NativeAlarmRetryIntervalMs` and re-attempts until the subscription is established. Rehydrated state remains visible in the meantime.
- **`NativeAlarmSourceUnavailable`**: the source connection has dropped. The actor **retains its last-known mirrored conditions** but marks them uncertain rather than purging them, so a transient disconnect does not flap every condition to normal. The set is reconciled against truth by the next reconnect snapshot.
### Transition Handling (`NativeAlarmTransitionUpdate`)
- **Snapshot / SnapshotComplete (reconnect reconciliation)**: `Snapshot` updates buffer into a staging set; `SnapshotComplete` performs an **atomic swap** of the mirrored set with the staged set. Any condition that was previously mirrored but is **not present** in the new snapshot emits a return-to-normal `AlarmStateChanged` and drops out. This is how the mirror self-corrects after an outage.
- **Live transitions** (`Raise` / `Ack` / `Clear` / `Retrigger` / `StateChange`): upsert the condition by `SourceReference`. Updates carrying a `TransitionTime` **older** than the currently held transition are ignored (out-of-order protection). Accepted transitions persist to SQLite and emit an enriched `AlarmStateChanged` upward to the Instance Actor.
- **Retention**: a mirrored condition is dropped once it is both inactive **and** acknowledged (`!Active && Acknowledged`) — the alarm has fully run its course at the source and no longer needs mirroring. The drop emits a final state change and deletes the SQLite row.
- **Per-source cap**: at most `MirroredAlarmCapPerSource` conditions are retained per source. When the cap is exceeded the **oldest** condition is dropped and the eviction is **logged** — there is no silent truncation.
### Persistence
- Mirrored condition state is persisted to the site SQLite `native_alarm_state` table on every accepted transition and removed on drop-out.
- Persistence is **best-effort / fire-and-forget**: a persistence failure is logged but never blocks the actor's mailbox and never aborts the upward `AlarmStateChanged` emit. The in-memory mirror remains authoritative for the running actor; SQLite exists to survive failover.
### Supervision & Restart
- Supervised by the Instance Actor under the same **OneForOneStrategy** as the computed Alarm Actor — a native source fault is isolated to its own actor.
- On site restart or failover, the actor rehydrates its mirror from `native_alarm_state` in PreStart, then reconciles against the source via the reconnect snapshot. Native mirror state therefore **survives failover** (unlike computed alarm state, which is re-evaluated from values).
- Mirrored native state **is cleared on redeploy/undeploy** of the instance (mirroring the static-override reset): the stale rows for the instance are removed and the fresh actor re-subscribes from a clean slate.
---
## Instance Actor — Native Alarm Wiring
The Instance Actor owns native-alarm setup alongside its computed Script and Alarm Actors:
- **Spawning**: for each entry in `_configuration.NativeAlarmSources`, the Instance Actor spawns a `NativeAlarmActor`. Spawning is **skipped when there is no DCL manager** (e.g., debug/test contexts with no data connections), since native alarms require a live source subscription.
- **Kind derivation**: the `AlarmKind` passed to each `NativeAlarmActor` is derived from the bound connection's protocol — `Mx*` protocols → `NativeMxAccess`, otherwise → `NativeOpcUa`.
- **Latest-event retention**: the Instance Actor retains the latest enriched `AlarmStateChanged` per alarm name in `_latestAlarmEvents`. The DebugView snapshot is built from this map so it carries the **unified condition view plus native metadata** for both computed and native alarms. Computed alarms that have not yet produced an event fall back to a **Normal projection** so the snapshot is complete.
- **Reset semantics**: `_latestAlarmEvents` and the mirrored native state are cleared on redeploy/undeploy (same trigger as static-override reset) but rehydrate from SQLite on failover.
---
## Native Alarm State Persistence (Site SQLite)
`SiteStorageService` gains a `native_alarm_state` table backing the native mirror:
- **Primary key**: `(instance_unique_name, source_canonical_name, source_reference)` — one row per mirrored condition.
- **Columns**: `condition_json` (the serialized `AlarmConditionState`) and `last_transition_at` (the accepted `TransitionTime`).
- **Operations**: `Upsert` (on accepted transition), `Delete` (on condition drop-out), `Get` (PreStart rehydrate, scoped to instance + source), and `ClearForInstance` (redeploy/undeploy reset).
- This is a **peer SQLite store** to the existing deployed-configuration, store-and-forward, operation-tracking, and `AuditLog` stores. Unlike computed alarm state, native mirror state is intentionally persisted so it survives failover.
---
## Enriched `AlarmStateChanged` Message
The `AlarmStateChanged` message published by both Alarm Actors and Native Alarm Actors was extended **additively** (existing consumers keep working with computed defaults):
- **`Kind`** (`AlarmKind`): `Computed` for ScadaBridge-evaluated alarms; `NativeOpcUa` / `NativeMxAccess` for mirrored native alarms.
- **`Condition`** (`AlarmConditionState`): the unified condition view. Computed alarms supply a computed default; native alarms carry the mirrored source condition.
- **Native metadata** (populated for native alarms; defaulted/empty for computed): `SourceReference`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime`, `CurrentValue`, `LimitValue`.
- **Computed-alarm projection**: computed alarms are surfaced as **auto-acknowledged** with `Severity = Priority`, so a single enriched shape carries both computed and native alarms onto the stream and into the DebugView snapshot.
The enriched message flows Instance Actor → site-wide Akka stream → `SiteStreamManager``SiteStreamGrpcServer` and is streamed to central as the gRPC `AlarmStateUpdate` event (see [Component-Communication.md](Component-Communication.md)).
---
## Shared Script Library
- Shared scripts are compiled at the site when received from central.
@@ -361,7 +466,7 @@ Per Akka.NET best practices, internal actor communication uses **Tell** (fire-an
## Dependencies
- **Data Connection Layer**: Provides tag value updates to Instance Actors. Receives write requests from Instance Actors.
- **Data Connection Layer**: Provides tag value updates to Instance Actors. Receives write requests from Instance Actors. Also feeds Native Alarm Actors: connections implementing `IAlarmSubscribableConnection` (OPC UA A&C servers, MxAccess Gateway) deliver `NativeAlarmTransitionUpdate` events in response to a `SubscribeAlarmsRequest`, and signal `NativeAlarmSourceUnavailable` on connection loss.
- **Store-and-Forward Engine**: Handles reliable delivery for external system calls, cached database writes, and notifications submitted by scripts. For the notification category specifically, it forwards to the central cluster for delivery (not directly to SMTP). Owns the site-local operation tracking table that backs `Tracking.Status(id)`.
- **External System Gateway**: Provides external system method invocations for scripts.
- **Communication Layer**: Receives deployments and lifecycle commands from central. Handles debug view requests. Reports deployment results.
+22 -16
View File
@@ -25,22 +25,28 @@ Site clusters only. The central cluster does not buffer messages.
## Message Lifecycle
```
Script submits message
Attempt immediate delivery
├── Success → Remove from buffer
└── Failure → Buffer message
Retry loop (per retry policy)
├── Success → Remove from buffer + notify standby
└── Max retries exhausted → Park message
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
A([Script submits message]) --> B[Attempt immediate delivery]
B --> C{Delivered?}
C -->|Success| D([Remove from buffer])
C -->|Failure| E[Buffer message]
E --> F[Retry loop<br/>per retry policy]
F --> G{Retry outcome}
G -->|Success| H([Remove from buffer<br/>+ notify standby])
G -->|Max retries exhausted| I([Park message<br/>dead-letter])
classDef ok fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef buf fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class A,D,H ok
class B,F proc
class C,G dec
class E buf
class I bad
```
For notifications, "delivery" means forwarding the message to the central cluster via CentralSite Communication; "success" is central's ack, on which the message is cleared. Notifications are retried at the fixed forward interval until central acks, but — like every other category — they are bounded by the engine's `DefaultMaxRetries` cap: a sustained central outage that exceeds `DefaultMaxRetries × forward-interval` will park the buffered notification, after which an operator can Retry/Discard it via the parked-message UI. Operationally, the cap is sized so the normal central-recovery window stays well inside it; "do not park" is the design's operational intent on the happy path, not an absolute invariant. Callers that genuinely require unbounded retry pass `maxRetries: 0` on `EnqueueAsync` (the documented "no limit" escape hatch — see `StoreAndForward-015`).
+20 -1
View File
@@ -53,6 +53,13 @@ Central cluster only. Sites receive flattened output and have no awareness of te
- Trigger Definition: Value Match, Range Violation, or Rate of Change.
- Optional On-Trigger Script reference.
### Native Alarm Source (`TemplateNativeAlarmSource`)
- A read-only binding that mirrors **native alarms** raised by an upstream system — OPC UA Alarms & Conditions or the MxAccess Gateway — rather than alarms evaluated by the Site Runtime from attribute values.
- Fields: Name, Description *(optional)*, ConnectionName (the data connection that carries the native alarms), SourceReference (a raw connection address — an OPC UA `SourceNode` nodeId, or an MxAccess object/area), ConditionFilter *(optional — when null, mirror **all** conditions under the source)*, and the standard locking flags (`IsLocked`, `IsInherited`, `LockedInDerived`).
- `SourceReference` is a **raw connection address**, not a relative attribute path — the Template Engine does not interpret or rewrite it (contrast with an attribute's `DataSourceReference`).
- Defined on a template as a first-class member via `Template.NativeAlarmSources`.
- Resolved native alarm sources drive the Site Runtime's **NativeAlarmActor** (see Interactions); the Template Engine only models and flattens them.
### Script (Template-Level)
- Name, Lock Flag, C# source code.
- Trigger configuration: Interval, Value Change, Conditional, Expression, or invoked by alarm/other script. Conditional and Expression triggers also carry a fire mode — **OnTrue** (fire as the condition becomes true) or **WhileTrue** (re-fire on a timer while it stays true).
@@ -64,6 +71,7 @@ Central cluster only. Sites receive flattened output and have no awareness of te
- Associated with a specific template and a specific site.
- Assigned to an area within the site.
- Can override non-locked attribute values (no adding/removing attributes).
- Can override non-locked native alarm source bindings via `Instance.NativeAlarmSourceOverrides` (see Override Granularity) — no adding/removing sources.
- Bound to data connections at instance creation — **per-attribute binding** where each attribute with a data source reference individually selects its data connection.
- Can be in **enabled** or **disabled** state.
- Can be **deleted** — deletion is blocked if the site is unreachable.
@@ -99,6 +107,7 @@ Override and lock rules apply per entity type at the following granularity:
- **Attributes**: Value and Description are overridable. Data Type is fixed by the defining level. `DataSourceReference` on a template attribute defines the **default** physical address for that attribute. Instances may override per attribute via `InstanceConnectionBinding.DataSourceReferenceOverride`; the override replaces the template default at flattening time. When the override is null (the default), the template value is used. Lock applies to the entire attribute (when locked, no fields can be overridden).
- **Alarms**: Priority Level, Trigger Definition (thresholds/ranges/rates), Description, and On-Trigger Script reference are overridable. Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm.
- **Native alarm sources**: An instance overrides a non-locked source via `InstanceNativeAlarmSourceOverride`, keyed by `SourceCanonicalName`. `ConnectionNameOverride`, `SourceReferenceOverride`, and `ConditionFilterOverride` are individually overridable — each is applied only when non-null; a null field **keeps the inherited value**. Name is fixed. Lock applies to the entire source.
- **Scripts**: C# source code, Trigger configuration, minimum time between runs, and parameter/return definitions are overridable. Name is fixed. Lock applies to the entire script.
- **Composed module members**: A composing template or child template can override non-locked members inside a composed module using the canonical path-qualified name.
@@ -122,6 +131,14 @@ When an instance is deployed, the Template Engine resolves the full configuratio
5. Resolve data connection bindings — replace connection name references with concrete connection details from the site.
6. Output a flat structure: list of attributes with resolved values and data source addresses, list of alarms with resolved trigger definitions, list of scripts with resolved code and triggers.
### Native Alarm Source Resolution
The `FlatteningService` resolves native alarm sources alongside alarms, emitting a `ResolvedNativeAlarmSource` (CanonicalName, ConnectionName, SourceReference, ConditionFilter *(optional)*, and `Source``Template` | `Inherited` | `Composed` | `Override`) for each. The resolved set is attached to `FlattenedConfiguration.NativeAlarmSources`.
- **Inheritance**: resolution walks the chain base → derived; a derived-level source wins over the base unless the base level locked it.
- **Composition**: a composed module's sources are path-qualified to the canonical name `[ModuleInstanceName].[Name]`, subject to the same naming-collision checks as other members. Because `SourceReference` is a raw connection address (not an attribute path), composition performs **no attribute-reference rewriting** on it.
- **Instance overrides**: `InstanceNativeAlarmSourceOverride` applies its non-null fields (`ConnectionNameOverride`, `SourceReferenceOverride`, `ConditionFilterOverride`) over the inherited/composed result and sets `Source = Override`.
## Diff Calculation
The Template Engine can compare:
@@ -150,6 +167,7 @@ Beyond compilation, the Template Engine performs static semantic checks:
- **Argument compatibility**: Parameter count and data types at call sites must match the target script's parameter definitions.
- **Return type compatibility**: If a script call's return value is used, the return type definition must match the caller's expectations.
- **Trigger operand types**: Alarm triggers and script conditional triggers must reference attributes with compatible data types (e.g., Range Violation requires numeric attributes).
- **Native alarm sources** (`ValidationCategory.NativeAlarmSourceInvalid`): `SemanticValidator.Validate` flags a `ResolvedNativeAlarmSource` when its `SourceReference` is empty, its `ConnectionName` is empty, or — when the caller supplies the alarm-capable connection set — its connection is unknown or not alarm-capable (protocol ∉ {`OpcUa`, `MxGateway`}). The alarm-capable connection set is an **optional, additive third parameter** to `Validate`; the empty-field checks always run, and the connection-binding check runs only when the set is provided.
### Graph Acyclicity
@@ -185,5 +203,6 @@ For shared scripts, pre-compilation validation is performed before deployment. S
## Interactions
- **Deployment Manager**: Requests flattened configurations, diffs, and validation results from the Template Engine.
- **Central UI**: Provides the data model for template authoring, instance management, and on-demand validation.
- **Central UI**: Provides the data model for template authoring, instance management, and on-demand validation. Native alarm source CRUD (template-level definitions and instance-level overrides) is exposed via the Management Service / CLI / Central UI alongside attributes and alarms.
- **Site Runtime (#3)**: Consumes each `ResolvedNativeAlarmSource` in the flattened configuration to drive its **NativeAlarmActor**, which mirrors the native OPC UA A&C / MxAccess Gateway alarms identified by the resolved connection, source reference, and condition filter.
- **Transport (#24)**: Reads templates, attributes, alarms, scripts, and composition relationships for bundle export; writes the same via repositories during bundle import.
+101 -59
View File
@@ -53,7 +53,7 @@ Exactly one of `content.json` or `content.enc` is present.
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
"scadaLinkVersion": "1.4.2",
"scadaBridgeVersion": "1.4.2",
"contentHash": "sha256:...",
"encryption": {
"algorithm": "AES-256-GCM",
@@ -92,19 +92,31 @@ The manifest is plaintext so the import wizard can preview bundle contents and s
## Architecture
```
ZB.MOM.WW.ScadaBridge.Transport
├── IBundleExporter
│ ExportAsync(ExportSelection, Passphrase?, ct) → Stream
├── IBundleImporter
LoadAsync(stream, Passphrase?, ct) → BundleSession
│ PreviewAsync(sessionId, ct) → ImportPreview
│ ApplyAsync(sessionId, resolutions, ct) → ImportResult
├── DependencyResolver
├── BundleSerializer (manifest + content JSON; ZIP packer)
├── BundleSecretEncryptor (AES-256-GCM + PBKDF2)
├── BundleSessionStore (in-memory, TTL'd)
└── ManifestValidator (schema/version gating, hash check)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
subgraph T["ZB.MOM.WW.ScadaBridge.Transport"]
EXPORTER["IBundleExporter<br/>ExportAsync(ExportSelection, Passphrase?, ct) → Stream"]
IMPORTER["IBundleImporter<br/>LoadAsync(stream, Passphrase?, ct) → BundleSession<br/>PreviewAsync(sessionId, ct) → ImportPreview<br/>ApplyAsync(sessionId, resolutions, ct) → ImportResult"]
RESOLVER["DependencyResolver"]
SERIALIZER["BundleSerializer<br/>(manifest + content JSON; ZIP packer)"]
ENCRYPTOR["BundleSecretEncryptor<br/>(AES-256-GCM + PBKDF2)"]
SESSIONSTORE["BundleSessionStore<br/>(in-memory, TTL'd)"]
MANIFESTVALIDATOR["ManifestValidator<br/>(schema/version gating, hash check)"]
end
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class EXPORTER,IMPORTER proc
class RESOLVER,SERIALIZER start
class ENCRYPTOR alt
class SESSIONSTORE warn
class MANIFESTVALIDATOR dec
class T muted
```
The component is central-only. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session.
@@ -132,21 +144,49 @@ The user can toggle "include all dependencies" off (with a warning that the bund
### Backend
```
User (Design role) ─► Central UI Export wizard
IBundleExporter
├─► DependencyResolver ─► repositories (read)
├─► EntitySerializer ─► content.json
├─► BundleSecretEncryptor ► content.enc (if passphrase)
├─► ManifestBuilder ─► manifest.json
ZIP packer → temp file → browser download
IAuditService.LogAsync(BundleExported …)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
USER(["User (Design role)"])
WIZARD["Central UI Export wizard"]
EXPORTER["IBundleExporter"]
RESOLVER["DependencyResolver"]
REPOS[("repositories (read)")]
SERIALIZER["EntitySerializer"]
CONTENTJSON["content.json"]
ENCRYPTOR["BundleSecretEncryptor"]
CONTENTENC["content.enc<br/>(if passphrase)"]
MANIFESTBUILDER["ManifestBuilder"]
MANIFESTJSON["manifest.json"]
ZIP["ZIP packer → temp file → browser download"]
AUDIT["IAuditService.LogAsync(BundleExported …)"]
USER --> WIZARD
WIZARD --> EXPORTER
EXPORTER --> RESOLVER
RESOLVER --> SERIALIZER
SERIALIZER --> ENCRYPTOR
ENCRYPTOR --> MANIFESTBUILDER
MANIFESTBUILDER --> ZIP
ZIP --> AUDIT
RESOLVER --> REPOS
SERIALIZER --> CONTENTJSON
ENCRYPTOR --> CONTENTENC
MANIFESTBUILDER --> MANIFESTJSON
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class USER,AUDIT start
class WIZARD,EXPORTER,ZIP proc
class RESOLVER,SERIALIZER,MANIFESTBUILDER dec
class ENCRYPTOR alt
class CONTENTJSON,CONTENTENC,MANIFESTJSON warn
class REPOS muted
```
Audit event: `BundleExported` — caller, artifact count, content hash, encrypted yes/no, bundle filename.
@@ -179,34 +219,36 @@ Bundle references that cannot be satisfied in either the bundle or the target DB
### Backend
```
User (Admin role) ─► uploads bundle
IBundleImporter.LoadAsync
· verify SHA-256 (manifest vs content)
· check bundleFormatVersion supported
· decrypt content.enc with passphrase (if encrypted)
· deserialize entities
· open BundleSession (30-min TTL)
PreviewAsync → diff vs target DB → ImportPreview
▼ (user reviews + resolves conflicts)
ApplyAsync (single EF transaction)
· run two-tier semantic validation (minimal name scan + full SemanticValidator)
· apply resolutions (add / overwrite / skip / rename)
· upsert TemplateFolder hierarchy
· IAuditService.LogAsync(BundleImported …)
· commit
ImportResult → UI step 5
"View on Deployments →" (existing page)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
USER(["User (Admin role) → uploads bundle"])
LOAD["IBundleImporter.LoadAsync<br/>· verify SHA-256 (manifest vs content)<br/>· check bundleFormatVersion supported<br/>· decrypt content.enc with passphrase (if encrypted)<br/>· deserialize entities<br/>· open BundleSession (30-min TTL)"]
PREVIEW["PreviewAsync → diff vs target DB → ImportPreview"]
REVIEW["(user reviews + resolves conflicts)"]
APPLY["ApplyAsync (single EF transaction)<br/>· run two-tier semantic validation<br/>&nbsp;&nbsp;(minimal name scan + full SemanticValidator)<br/>· apply resolutions (add / overwrite / skip / rename)<br/>· upsert TemplateFolder hierarchy<br/>· IAuditService.LogAsync(BundleImported …)<br/>· commit"]
RESULT["ImportResult → UI step 5"]
DEPLOYMENTS["'View on Deployments →' (existing page)"]
USER --> LOAD
LOAD --> PREVIEW
PREVIEW --> APPLY
PREVIEW -.- REVIEW
APPLY --> RESULT
RESULT --> DEPLOYMENTS
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class USER start
class LOAD,RESULT proc
class PREVIEW dec
class APPLY alt
class DEPLOYMENTS warn
class REVIEW muted
```
Authorization: `RequireAdmin` on both the Razor page and `IBundleImporter.*` entrypoints.
@@ -325,7 +367,7 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
"scadaLinkVersion": "1.4.2",
"scadaBridgeVersion": "1.4.2",
"contentHash": "sha256:abc123...",
"encryption": {
"algorithm": "AES-256-GCM",
@@ -371,7 +413,7 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
| `createdAtUtc` | ISO-8601 UTC timestamp of when the export was created. |
| `sourceEnvironment` | The `SourceEnvironment` name of the exporting cluster (from `TransportOptions`). Displayed in the import wizard and required to be retyped at the confirm step. |
| `exportedBy` | Authenticated username of the person who performed the export. |
| `scadaLinkVersion` | Application version of the exporting node. Used for diagnostic display only. |
| `scadaBridgeVersion` | Application version of the exporting node. Used for diagnostic display only. |
| `contentHash` | `sha256:<hex>` — SHA-256 of the raw `content.json` or `content.enc` bytes (pre-encryption). Verified on upload before any decryption. |
| `encryption` | Present only when a passphrase was supplied. Contains the KDF parameters and the per-bundle random salt and IV needed to re-derive the key and decrypt. Omitted for plaintext bundles. |
| `encryption.algorithm` | Always `"AES-256-GCM"` in v1. |
+24 -1
View File
@@ -119,6 +119,28 @@ Each alarm has:
- Alarm state is **not persisted** — on restart, alarm states are re-evaluated from incoming values.
- Alarm state changes are published to the site-wide Akka stream as `[InstanceUniqueName].[AlarmName]`, alarm state (active/normal), alarm level, priority, timestamp.
### 3.4.2 Native Alarms (Read-Only Mirror)
Sections 3.4 and 3.4.1 describe **computed alarms** — alarms ScadaBridge evaluates at the site from attribute triggers. The system additionally **mirrors native alarms** already raised by upstream systems — **OPC UA Alarms & Conditions** servers and the **MxAccess Gateway**. Native alarms are surfaced read-only alongside computed alarms; ScadaBridge reflects source-reported state but **never acknowledges, confirms, shelves, suppresses, or otherwise writes back** to the source — the source system owns the alarm lifecycle.
**Native alarm source bindings** are first-class template members alongside attributes, alarms, and scripts, following the same **inheritance, override, and lock rules**. Each binding has:
- **Name**: Identifier for the source binding within the template.
- **Description** *(optional)*.
- **Connection**: The data connection that carries the native alarms (must be alarm-capable — an OPC UA A&C or MxAccess Gateway connection).
- **Source Reference**: The source-side address the alarms are read from — an OPC UA `SourceNode`/notifier nodeId, or an MxAccess object/area.
- **Condition Filter** *(optional)*: Narrows which conditions under the source are mirrored; when omitted, **all** conditions under the source are mirrored.
- **Lock Flag**: Controls whether the binding can be overridden downstream.
Because the source reference typically varies per physical instance (e.g. `Tank01` vs `Tank02`), an instance may **override** a non-locked binding's connection, source reference, and/or condition filter individually — a null override field keeps the inherited value. Bindings cannot be added or removed at the instance level, only retargeted.
**Unified condition model.** Computed and native alarms share one **condition state** so they present uniformly: orthogonal **Active**, **Acknowledged**, **Confirmed** (when confirmable), **Shelved**, and **Suppressed** flags plus a **01000 severity**. An **alarm kind** discriminator distinguishes `Computed`, `NativeOpcUa`, and `NativeMxAccess`. Computed alarms project onto this model as auto-acknowledged with severity equal to their priority; native alarms mirror the source condition directly.
**Runtime behavior:**
- Native alarm sources are **discovered and subscribed at runtime** — at deploy time the site subscribes each resolved binding through the Data Connection Layer, which opens **one alarm feed per connection** and routes transitions to instances by source-object reference. A site-side actor (peer to the computed alarm actor) mirrors each source binding.
- On every (re)subscribe the source **replays a snapshot** of currently-active conditions so state is reconciled after a reconnect. On connection loss, mirrored conditions are **marked uncertain rather than cleared**, so a transient disconnect does not flap every condition to normal.
- Unlike computed alarm state (not persisted, re-evaluated on restart), mirrored native alarm state **is persisted at the site** so it survives restart/failover; it is **cleared on redeployment/undeployment** (mirroring static attribute overrides).
- Native alarm state is streamed to the central cluster over the **existing real-time gRPC stream** (additively, alongside computed alarm state) and is surfaced **only in the on-demand debug view** — there is no dedicated operator/alarm-summary page and **no central alarm tables**. The debug view shows the alarm kind, unified severity, and condition sub-state badges, but offers **no ack-back or command actions** for native alarms.
### 3.5 Template Relationships
Templates participate in two distinct relationship types:
@@ -192,6 +214,7 @@ Before any deployment is sent to a site, the central cluster performs **comprehe
- **Naming collision detection**: No duplicate attribute, alarm, or script names exist in the flattened configuration.
- **Script compilation**: All instance scripts and alarm on-trigger scripts are test-compiled and must compile without errors.
- **Alarm trigger references**: Alarm trigger definitions reference attributes that exist in the flattened configuration.
- **Native alarm source bindings**: Each resolved native alarm source has a non-empty source reference and connection name, and — when the alarm-capable connection set is supplied — the bound connection exists and is alarm-capable (an OPC UA A&C or MxAccess Gateway connection).
- **Script trigger references**: Script triggers (value change, conditional) reference attributes that exist in the flattened configuration.
- **Data connection binding completeness**: Every attribute with a data source reference has a data connection binding assigned on the instance, and the bound data connection name exists as a defined connection at the instance's site.
- **Exception**: Validation does **not** verify that data source relative paths resolve to real tags on physical devices — that is a runtime concern that can only be determined at the site.
@@ -508,7 +531,7 @@ Sites log operational events locally, including:
- The CLI sends user credentials via HTTP Basic Auth. The server authenticates against **LDAP/AD** and resolves roles before dispatching commands to the ManagementActor.
- CLI commands mirror all Management Service operations: templates, instances, sites, data connections, deployments, external systems, notifications, security (API keys and role mappings), audit log queries, and health status.
- Output is **JSON by default** (machine-readable, suitable for scripting) with an optional `--format table` flag for human-readable tabular output.
- Configuration is resolved from command-line options, **environment variables** (`SCADALINK_MANAGEMENT_URL`, `SCADALINK_FORMAT`), or a **configuration file** (`~/.scadabridge/config.json`).
- Configuration is resolved from command-line options, **environment variables** (`SCADABRIDGE_MANAGEMENT_URL`, `SCADABRIDGE_FORMAT`), or a **configuration file** (`~/.scadabridge/config.json`).
- The CLI is a separate executable from the Host binary — it is deployed on any machine with HTTP access to a central node.
## 14. General Conventions
+2
View File
@@ -52,6 +52,8 @@ In addition to the local Docker services, the following remote services are avai
**Primary/backup testing**: The dual OPC UA test servers (ports 50000 and 50010) in local Docker provide primary/backup endpoint pairs for testing Data Connection Layer failover. Use `docker compose stop opcua` to simulate primary failure and verify automatic failover to the backup.
**Alarms & Conditions (native alarms)**: The infra OPC PLC server **does** expose OPC UA Alarms & Conditions — a `ConditionRefresh` against its event notifier replays the active condition set and a `SnapshotComplete`, so the native alarm mirror (`IAlarmSubscribableConnection``NativeAlarmActor`) can be exercised live. The `OpcUaAlarmLiveSmokeTests.SubscribeAlarms_DeliversConditionRefreshSnapshot` `[SkippableFact]` (Trait `RequiresOpcUa`) round-trips against `opc.tcp://localhost:50000` and asserts the snapshot arrives; it reports **Skipped** (not failed) when the server is unreachable or — on a substitute server that lacks A&C — when no snapshot arrives within the window. The MxAccess Gateway alarm feed (`MxGateway` protocol) requires a live gateway and is verified via the `docker-env2` manual deploy check, not in CI.
## Connection Strings
For use in `appsettings.Development.json`:
+2
View File
@@ -16,6 +16,8 @@
</packageSource>
<packageSource key="dohertj2-gitea">
<package pattern="ZB.MOM.WW.MxGateway.*" />
<package pattern="ZB.MOM.WW.Health" />
<package pattern="ZB.MOM.WW.Health.*" />
</packageSource>
</packageSourceMapping>
<!--
+6 -6
View File
@@ -13,14 +13,14 @@ public class CliConfig
public string DefaultFormat { get; set; } = "json";
/// <summary>
/// LDAP username from the <c>SCADALINK_USERNAME</c> environment variable, if set.
/// LDAP username from the <c>SCADABRIDGE_USERNAME</c> environment variable, if set.
/// Credentials are intentionally only sourced from environment variables (or the
/// command line) — never from the config file — so they are not persisted to disk.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// LDAP password from the <c>SCADALINK_PASSWORD</c> environment variable, if set.
/// LDAP password from the <c>SCADABRIDGE_PASSWORD</c> environment variable, if set.
/// Provides a safer alternative to <c>--password</c>, which leaks into process
/// listings and shell history.
/// </summary>
@@ -69,20 +69,20 @@ public class CliConfig
}
// Override from environment variables
var envUrl = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
var envUrl = Environment.GetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL");
if (!string.IsNullOrEmpty(envUrl))
config.ManagementUrl = envUrl;
var envFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
var envFormat = Environment.GetEnvironmentVariable("SCADABRIDGE_FORMAT");
if (!string.IsNullOrEmpty(envFormat))
config.DefaultFormat = envFormat;
// Credentials from environment variables only (never the config file).
var envUsername = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
var envUsername = Environment.GetEnvironmentVariable("SCADABRIDGE_USERNAME");
if (!string.IsNullOrEmpty(envUsername))
config.Username = envUsername;
var envPassword = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
var envPassword = Environment.GetEnvironmentVariable("SCADABRIDGE_PASSWORD");
if (!string.IsNullOrEmpty(envPassword))
config.Password = envPassword;
@@ -76,7 +76,7 @@ public static class AuditCommandHelpers
if (string.IsNullOrWhiteSpace(url))
{
return AuditConnection.Fail(
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"No management URL specified. Use --url, set SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"NO_URL");
}
@@ -93,7 +93,7 @@ public static class AuditCommandHelpers
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return AuditConnection.Fail(
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
}
@@ -51,7 +51,7 @@ internal static class CommandHelpers
if (string.IsNullOrWhiteSpace(url))
{
OutputFormatter.WriteError(
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"No management URL specified. Use --url, set SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"NO_URL");
return 1;
}
@@ -65,14 +65,14 @@ internal static class CommandHelpers
}
// Resolve credentials: command-line options take precedence, then the
// SCADALINK_USERNAME / SCADALINK_PASSWORD environment variables.
// SCADABRIDGE_USERNAME / SCADABRIDGE_PASSWORD environment variables.
var username = ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = ResolveCredential(result.GetValue(passwordOption), config.Password);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
return 1;
}
@@ -57,7 +57,7 @@ public static class DebugCommands
if (string.IsNullOrWhiteSpace(url))
{
OutputFormatter.WriteError(
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"No management URL specified. Use --url, set SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"NO_URL");
return 1;
}
@@ -76,7 +76,7 @@ public static class DebugCommands
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
return 1;
}
@@ -24,6 +24,7 @@ public static class InstanceCommands
command.Add(BuildSetBindings(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSetOverrides(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildNativeAlarmSourceOverride(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSetArea(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDiff(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildDeploy(urlOption, formatOption, usernameOption, passwordOption));
@@ -348,6 +349,53 @@ public static class InstanceCommands
return group;
}
private static Command BuildNativeAlarmSourceOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("native-alarm-source")
{
Description = "Manage per-instance native alarm source overrides (retarget an inherited binding; blank = inherited)"
};
// set
var setIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var setSourceOption = new Option<string>("--source") { Description = "Source binding canonical name (e.g. 'Pressure' or 'Module.Pressure')", Required = true };
var setConnectionOption = new Option<string?>("--connection") { Description = "Connection name override (blank = inherited)" };
var setSourceRefOption = new Option<string?>("--source-ref") { Description = "Source reference override (blank = inherited)" };
var setFilterOption = new Option<string?>("--filter") { Description = "Condition filter override (blank = inherited)" };
var setCmd = new Command("set") { Description = "Set (upsert) a native alarm source override on an instance" };
setCmd.Add(setIdOption); setCmd.Add(setSourceOption); setCmd.Add(setConnectionOption);
setCmd.Add(setSourceRefOption); setCmd.Add(setFilterOption);
setCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new SetInstanceNativeAlarmSourceOverrideCommand(
result.GetValue(setIdOption),
result.GetValue(setSourceOption)!,
result.GetValue(setConnectionOption),
result.GetValue(setSourceRefOption),
result.GetValue(setFilterOption)));
});
group.Add(setCmd);
// clear
var clearIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
var clearSourceOption = new Option<string>("--source") { Description = "Source binding canonical name", Required = true };
var clearCmd = new Command("clear") { Description = "Clear a native alarm source override on an instance (revert to inherited)" };
clearCmd.Add(clearIdOption); clearCmd.Add(clearSourceOption);
clearCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteInstanceNativeAlarmSourceOverrideCommand(
result.GetValue(clearIdOption),
result.GetValue(clearSourceOption)!));
});
group.Add(clearCmd);
return group;
}
private static Command BuildSetArea(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
@@ -23,6 +23,7 @@ public static class TemplateCommands
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAttribute(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildAlarm(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption));
@@ -293,6 +294,73 @@ public static class TemplateCommands
return group;
}
private static Command BuildNativeAlarmSource(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("native-alarm-source")
{
Description = "Manage template native alarm source bindings (read-only mirror of OPC UA A&C / MxGateway alarms)"
};
// add
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Source binding name", Required = true };
var connectionOption = new Option<string>("--connection") { Description = "Alarm-capable data connection name", Required = true };
var sourceRefOption = new Option<string>("--source-ref") { Description = "Source reference (OPC UA SourceNode nodeId, or MxAccess object/area)", Required = true };
var filterOption = new Option<string?>("--filter") { Description = "Optional condition filter (null = mirror all conditions under the source)" };
var descOption = new Option<string?>("--description") { Description = "Description" };
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
lockedOption.DefaultValueFactory = _ => false;
var addCmd = new Command("add") { Description = "Add a native alarm source binding to a template" };
addCmd.Add(templateIdOption);
addCmd.Add(nameOption);
addCmd.Add(connectionOption);
addCmd.Add(sourceRefOption);
addCmd.Add(filterOption);
addCmd.Add(descOption);
addCmd.Add(lockedOption);
addCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateNativeAlarmSourceCommand(
result.GetValue(templateIdOption),
result.GetValue(nameOption)!,
result.GetValue(connectionOption)!,
result.GetValue(sourceRefOption)!,
result.GetValue(filterOption),
result.GetValue(descOption),
result.GetValue(lockedOption)));
});
group.Add(addCmd);
// list
var listIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var listCmd = new Command("list") { Description = "List native alarm source bindings on a template" };
listCmd.Add(listIdOption);
listCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new ListTemplateNativeAlarmSourcesCommand(result.GetValue(listIdOption)));
});
group.Add(listCmd);
// remove
var removeIdOption = new Option<int>("--id") { Description = "Native alarm source ID", Required = true };
var removeCmd = new Command("remove") { Description = "Remove a native alarm source binding from a template" };
removeCmd.Add(removeIdOption);
removeCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new DeleteTemplateNativeAlarmSourceCommand(result.GetValue(removeIdOption)));
});
group.Add(removeCmd);
return group;
}
private static Command BuildScript(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var group = new Command("script") { Description = "Manage template scripts" };
+65 -5
View File
@@ -15,7 +15,7 @@ The output binary is `scadabridge` (or `scadabridge.exe` on Windows).
Every command requires a connection to a running Central node. The management URL can be supplied three ways, evaluated in this priority order:
1. `--url` flag on the command line
2. `SCADALINK_MANAGEMENT_URL` environment variable
2. `SCADABRIDGE_MANAGEMENT_URL` environment variable
3. `managementUrl` field in `~/.scadabridge/config.json`
```sh
@@ -57,10 +57,10 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi
| Variable | Description |
|----------|-------------|
| `SCADALINK_MANAGEMENT_URL` | Management API URL (overrides config file) |
| `SCADALINK_FORMAT` | Default output format (overrides config file) |
| `SCADALINK_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
| `SCADALINK_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
| `SCADABRIDGE_MANAGEMENT_URL` | Management API URL (overrides config file) |
| `SCADABRIDGE_FORMAT` | Default output format (overrides config file) |
| `SCADABRIDGE_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
| `SCADABRIDGE_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
## Output
@@ -252,6 +252,41 @@ scadabridge --url <url> template alarm delete --template-id <int> --name <string
| `--template-id` | yes | Template ID |
| `--name` | yes | Alarm name to delete |
#### `template native-alarm-source add`
Bind a native alarm source to a template — a read-only mirror of an OPC UA Alarms &
Conditions server's or the MxAccess Gateway's alarms (no ack-back).
```sh
scadabridge --url <url> template native-alarm-source add --template-id <int> --name <string> --connection <string> --source-ref <string> [--filter <string>] [--description <string>] [--locked]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--template-id` | yes | Template ID |
| `--name` | yes | Source binding name |
| `--connection` | yes | Alarm-capable data connection name (OPC UA or MxGateway) |
| `--source-ref` | yes | Source reference (OPC UA SourceNode nodeId, or MxAccess object/area) |
| `--filter` | no | Condition filter (omit to mirror all conditions under the source) |
| `--description` | no | Description |
| `--locked` | no | Lock the binding in derived templates |
#### `template native-alarm-source list`
List the native alarm source bindings on a template.
```sh
scadabridge --url <url> template native-alarm-source list --template-id <int>
```
#### `template native-alarm-source remove`
Remove a native alarm source binding from a template (by its ID).
```sh
scadabridge --url <url> template native-alarm-source remove --id <int>
```
#### `template script add`
Add a script to a template.
@@ -370,6 +405,31 @@ scadabridge --url <url> instance create --name <string> --template-id <int> --si
| `--site-id` | yes | Site where the instance will run |
| `--area-id` | no | Area within the site |
#### `instance native-alarm-source set`
Override an inherited native alarm source binding for a single instance (upsert). Blank
options keep the inherited value.
```sh
scadabridge --url <url> instance native-alarm-source set --instance-id <int> --source <string> [--connection <string>] [--source-ref <string>] [--filter <string>]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--instance-id` | yes | Instance ID |
| `--source` | yes | Source binding canonical name (e.g. `Pressure` or `Module.Pressure`) |
| `--connection` | no | Connection name override (blank = inherited) |
| `--source-ref` | no | Source reference override (blank = inherited) |
| `--filter` | no | Condition filter override (blank = inherited) |
#### `instance native-alarm-source clear`
Clear an instance's native alarm source override, reverting to the inherited binding.
```sh
scadabridge --url <url> instance native-alarm-source clear --instance-id <int> --source <string>
```
#### `instance deploy`
Deploy an instance to its site. Acquires the per-instance operation lock.
@@ -191,9 +191,10 @@
<thead class="table-light sticky-top">
<tr>
<th>Alarm</th>
<th>Kind</th>
<th>State</th>
<th>Sev</th>
<th>Level</th>
<th>Priority</th>
<th>Timestamp</th>
</tr>
</thead>
@@ -201,18 +202,44 @@
@foreach (var alarm in FilteredAlarmStates)
{
<tr class="@GetAlarmRowClass(alarm.State)"
title="@(string.IsNullOrEmpty(alarm.Message) ? null : alarm.Message)">
title="@BuildAlarmTooltip(alarm)">
<td class="small">
@alarm.AlarmName
@if (!string.IsNullOrEmpty(alarm.Message))
{
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
}
@if (!string.IsNullOrEmpty(alarm.SourceReference))
{
<div class="text-muted font-monospace text-truncate" style="font-size: .7rem; max-width: 180px;"
title="@alarm.SourceReference">@alarm.SourceReference</div>
}
</td>
<td>
<span class="badge @GetKindBadge(alarm.Kind)"
aria-label="@($"Alarm kind: {alarm.Kind}")">@FormatKind(alarm.Kind)</span>
</td>
<td>
<span class="badge @GetAlarmStateBadge(alarm.State)"
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
@if (alarm.Kind != AlarmKind.Computed)
{
@if (alarm.Condition.Active && !alarm.Condition.Acknowledged)
{
<span class="badge bg-warning text-dark ms-1" aria-label="Unacknowledged">Unacked</span>
}
@if (alarm.Condition.Shelve != AlarmShelveState.Unshelved)
{
<span class="badge bg-info text-dark ms-1" title="@alarm.Condition.Shelve"
aria-label="@($"Shelved: {alarm.Condition.Shelve}")">Shelved</span>
}
@if (alarm.Condition.Suppressed)
{
<span class="badge bg-info text-dark ms-1" aria-label="Suppressed">Suppressed</span>
}
}
</td>
<td class="small font-monospace">@alarm.Condition.Severity</td>
<td>
@if (alarm.Level != AlarmLevel.None)
{
@@ -224,7 +251,6 @@
<span class="text-muted small">—</span>
}
</td>
<td class="small">@alarm.Priority</td>
<td class="small text-muted"
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss")
@@ -284,7 +310,8 @@
string.IsNullOrWhiteSpace(_alarmFilter)
? _alarmStates.Values.OrderBy(a => a.AlarmName).ToList()
: _alarmStates.Values
.Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase))
.Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase)
|| a.SourceReference.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.AlarmName)
.ToList();
@@ -575,6 +602,40 @@
_ => "bg-secondary"
};
/// <summary>Badge class distinguishing computed (neutral) from native (info) alarms.</summary>
private static string GetKindBadge(AlarmKind kind) => kind switch
{
AlarmKind.Computed => "bg-secondary",
_ => "bg-info text-dark"
};
/// <summary>Short display label for the alarm kind.</summary>
private static string FormatKind(AlarmKind kind) => kind switch
{
AlarmKind.NativeOpcUa => "OPC UA",
AlarmKind.NativeMxAccess => "MxAccess",
_ => "Computed"
};
/// <summary>
/// Builds the row tooltip from the alarm's operator message plus native
/// metadata (type, category, operator, raise time, current/limit value).
/// Returns null when there is nothing extra to show.
/// </summary>
private static string? BuildAlarmTooltip(AlarmStateChanged a)
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(a.Message)) parts.Add(a.Message);
if (!string.IsNullOrEmpty(a.AlarmTypeName)) parts.Add($"Type: {a.AlarmTypeName}");
if (!string.IsNullOrEmpty(a.Category)) parts.Add($"Category: {a.Category}");
if (!string.IsNullOrEmpty(a.OperatorUser)) parts.Add($"By: {a.OperatorUser}");
if (!string.IsNullOrEmpty(a.OperatorComment)) parts.Add($"Comment: {a.OperatorComment}");
if (a.OriginalRaiseTime.HasValue) parts.Add($"Raised: {a.OriginalRaiseTime.Value.LocalDateTime:HH:mm:ss}");
if (!string.IsNullOrEmpty(a.CurrentValue)) parts.Add($"Value: {a.CurrentValue}");
if (!string.IsNullOrEmpty(a.LimitValue)) parts.Add($"Limit: {a.LimitValue}");
return parts.Count == 0 ? null : string.Join(" · ", parts);
}
private static string FormatLevel(AlarmLevel level) => level switch
{
AlarmLevel.HighHigh => "HiHi",
@@ -347,6 +347,88 @@
</div>
}
@* Native Alarm Source Overrides *@
<div class="card mb-3">
<div class="card-header py-2">
<strong>Native Alarm Source Overrides</strong>
<small class="text-muted ms-2">
Retarget an inherited native alarm source binding for this instance.
Leave a field blank to keep the inherited value.
</small>
</div>
<div class="card-body p-0">
@if (_nativeSources.Count == 0)
{
<p class="text-muted small p-3 mb-0">No native alarm sources on this template.</p>
}
else
{
<table class="table table-sm table-bordered mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Source</th>
<th>Inherited</th>
<th style="width: 220px;">Connection override</th>
<th style="width: 220px;">Source reference override</th>
<th style="width: 170px;">Filter override</th>
<th style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var src in _nativeSources)
{
<tr>
<td class="small">
@src.Name
@if (HasNativeOverride(src.Name))
{
<span class="badge bg-warning text-dark ms-1" title="Override is set">●</span>
}
</td>
<td class="small text-muted font-monospace text-truncate" style="max-width: 200px;"
title="@($"{src.ConnectionName} / {src.SourceReference}")">
@src.ConnectionName / @src.SourceReference
</td>
<td>
<select class="form-select form-select-sm"
value="@(_nasConnEdit.GetValueOrDefault(src.Name) ?? "")"
@onchange="e => _nasConnEdit[src.Name] = string.IsNullOrEmpty((string?)e.Value) ? null : (string?)e.Value">
<option value="">(inherited)</option>
@foreach (var c in AlarmCapableConnections())
{
<option value="@c.Name" selected="@(_nasConnEdit.GetValueOrDefault(src.Name) == c.Name)">@c.Name (@c.Protocol)</option>
}
</select>
</td>
<td>
<input class="form-control form-control-sm font-monospace"
placeholder="@src.SourceReference"
value="@(_nasRefEdit.GetValueOrDefault(src.Name) ?? "")"
@onchange="e => _nasRefEdit[src.Name] = string.IsNullOrWhiteSpace((string?)e.Value) ? null : ((string?)e.Value)!.Trim()" />
</td>
<td>
<input class="form-control form-control-sm"
placeholder="@(string.IsNullOrEmpty(src.ConditionFilter) ? "(all)" : src.ConditionFilter)"
value="@(_nasFilterEdit.GetValueOrDefault(src.Name) ?? "")"
@onchange="e => _nasFilterEdit[src.Name] = string.IsNullOrWhiteSpace((string?)e.Value) ? null : ((string?)e.Value)!.Trim()" />
</td>
<td>
<button class="btn btn-success btn-sm me-1"
@onclick="() => SaveNativeOverride(src.Name)" disabled="@_saving">Save</button>
@if (HasNativeOverride(src.Name))
{
<button class="btn btn-outline-danger btn-sm"
@onclick="() => ClearNativeOverride(src.Name)" disabled="@_saving">Clear</button>
}
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
@* Area Assignment *@
<div class="card mb-3">
<div class="card-header py-2">
@@ -428,6 +510,15 @@
private List<TemplateAlarm> _overridableAlarms = new();
private Dictionary<string, InstanceAlarmOverride> _existingAlarmOverrides = new();
// Native alarm source overrides — the template's source bindings plus any
// per-instance override rows. Editing is inline (connection / source-ref /
// filter; blank = inherited).
private List<TemplateNativeAlarmSource> _nativeSources = new();
private Dictionary<string, InstanceNativeAlarmSourceOverride> _existingNativeOverrides = new();
private Dictionary<string, string?> _nasConnEdit = new();
private Dictionary<string, string?> _nasRefEdit = new();
private Dictionary<string, string?> _nasFilterEdit = new();
// Override edit modal state — non-null while the modal is open.
private TemplateAlarm? _editingAlarm;
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
@@ -514,6 +605,23 @@
_existingAlarmOverrides[o.AlarmCanonicalName] = o;
}
// Native alarm source bindings + per-instance overrides. Seed the
// inline edit maps from existing override rows (blank = inherited).
_nativeSources = (await TemplateEngineRepository.GetNativeAlarmSourcesByTemplateIdAsync(_instance.TemplateId)).ToList();
_existingNativeOverrides = new();
var nativeOverrides = await TemplateEngineRepository.GetNativeAlarmSourceOverridesByInstanceIdAsync(Id);
foreach (var o in nativeOverrides)
{
_existingNativeOverrides[o.SourceCanonicalName] = o;
}
foreach (var s in _nativeSources)
{
var ovr = _existingNativeOverrides.GetValueOrDefault(s.Name);
_nasConnEdit[s.Name] = ovr?.ConnectionNameOverride;
_nasRefEdit[s.Name] = ovr?.SourceReferenceOverride;
_nasFilterEdit[s.Name] = ovr?.ConditionFilterOverride;
}
_flattenedAttributes = await BuildFlattenedAttributesAsync();
}
catch (Exception ex)
@@ -892,6 +1000,94 @@
_saving = false;
}
// ── Native alarm source overrides (repository-direct; blank field = inherited) ──
private bool HasNativeOverride(string sourceName) => _existingNativeOverrides.ContainsKey(sourceName);
private IEnumerable<DataConnection> AlarmCapableConnections() =>
_siteConnections.Where(c => string.Equals(c.Protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.Protocol, "MxGateway", StringComparison.OrdinalIgnoreCase));
private async Task SaveNativeOverride(string sourceName)
{
_saving = true;
try
{
var conn = Blank(_nasConnEdit.GetValueOrDefault(sourceName));
var sref = Blank(_nasRefEdit.GetValueOrDefault(sourceName));
var filt = Blank(_nasFilterEdit.GetValueOrDefault(sourceName));
// All blank → no override; clear any existing row.
if (conn == null && sref == null && filt == null)
{
await ClearNativeOverrideCore(sourceName);
_toast.ShowSuccess($"No override on '{sourceName}' (inherited).");
return;
}
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
if (existing == null)
{
var ovr = new InstanceNativeAlarmSourceOverride(sourceName)
{
InstanceId = Id,
ConnectionNameOverride = conn,
SourceReferenceOverride = sref,
ConditionFilterOverride = filt
};
await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
await TemplateEngineRepository.SaveChangesAsync();
_existingNativeOverrides[sourceName] = ovr;
}
else
{
existing.ConnectionNameOverride = conn;
existing.SourceReferenceOverride = sref;
existing.ConditionFilterOverride = filt;
await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
await TemplateEngineRepository.SaveChangesAsync();
_existingNativeOverrides[sourceName] = existing;
}
_toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'.");
}
catch (Exception ex)
{
_toast.ShowError($"Save failed: {ex.Message}");
}
_saving = false;
}
private async Task ClearNativeOverride(string sourceName)
{
_saving = true;
try
{
await ClearNativeOverrideCore(sourceName);
_toast.ShowSuccess($"Cleared override on '{sourceName}'.");
}
catch (Exception ex)
{
_toast.ShowError($"Clear failed: {ex.Message}");
}
_saving = false;
}
private async Task ClearNativeOverrideCore(string sourceName)
{
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
if (existing != null)
{
await TemplateEngineRepository.DeleteInstanceNativeAlarmSourceOverrideAsync(existing.Id);
await TemplateEngineRepository.SaveChangesAsync();
}
_existingNativeOverrides.Remove(sourceName);
_nasConnEdit[sourceName] = null;
_nasRefEdit[sourceName] = null;
_nasFilterEdit[sourceName] = null;
}
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
/// <summary>
/// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum
/// to the canonical SCADA type string the AlarmTriggerEditor compares
@@ -2,6 +2,7 @@
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
@@ -92,6 +93,19 @@
private bool _alarmIsLocked;
private string? _alarmFormError;
// Native alarm source bindings (read-only mirror of OPC UA A&C / MxGateway alarms)
private List<TemplateNativeAlarmSource> _nativeSources = new();
private List<DataConnection> _alarmCapableConnections = new();
private bool _showNativeSourceForm;
private int? _editNativeSourceId;
private string _nasName = string.Empty;
private string _nasConnection = string.Empty;
private string _nasSourceRef = string.Empty;
private string? _nasFilter;
private string? _nasDescription;
private bool _nasIsLocked;
private string? _nasFormError;
private bool _showScriptForm;
private int? _editScriptId;
private string _scriptName = string.Empty;
@@ -156,6 +170,13 @@
_attributes = (await TemplateEngineRepository.GetAttributesByTemplateIdAsync(Id)).ToList();
_alarms = (await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(Id)).ToList();
_nativeSources = (await TemplateEngineRepository.GetNativeAlarmSourcesByTemplateIdAsync(Id)).ToList();
_alarmCapableConnections = (await CentralUiRepository.GetAllDataConnectionsAsync())
.Where(c => IsAlarmCapable(c.Protocol))
.GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(Id)).ToList();
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(Id)).ToList();
@@ -352,6 +373,15 @@
Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "native-alarms" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "native-alarms" ? "true" : "false")"
aria-controls="tmpl-tab-native-alarms"
@onclick='() => _activeTab = "native-alarms"'>
Native Alarms <span class="badge bg-secondary">@_nativeSources.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")"
role="tab"
@@ -367,6 +397,10 @@
{
<div role="tabpanel" id="tmpl-tab-attributes">@RenderAttributesTab()</div>
}
else if (_activeTab == "native-alarms")
{
<div role="tabpanel" id="tmpl-tab-native-alarms">@RenderNativeAlarmsTab()</div>
}
else if (_activeTab == "alarms")
{
<div role="tabpanel" id="tmpl-tab-alarms">@RenderAlarmsTab()</div>
@@ -852,6 +886,232 @@
</table>
};
// ---- Native Alarms Tab ----
private RenderFragment RenderNativeAlarmsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Native Alarm Sources</h5>
<button class="btn btn-primary btn-sm" @onclick="BeginAddNativeSource">Add Source</button>
</div>
<p class="text-muted small">
Read-only mirror of alarms from an OPC UA Alarms &amp; Conditions server or the
MxAccess Gateway. Discovered at runtime and shown live in the Debug View — no ack-back.
</p>
@if (_showNativeSourceForm)
{
var editing = _editNativeSourceId.HasValue;
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">@(editing ? "Edit Native Alarm Source" : "Add Native Alarm Source")</h6>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelNativeSourceForm"></button>
</div>
<div class="modal-body">
<div class="row g-2">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_nasName" readonly="@editing" />
</div>
<div class="col-12">
<label class="form-label">Connection</label>
<select class="form-select" @bind="_nasConnection">
<option value="">— select an alarm-capable connection —</option>
@foreach (var c in _alarmCapableConnections)
{
<option value="@c.Name">@c.Name (@c.Protocol)</option>
}
</select>
@if (_alarmCapableConnections.Count == 0)
{
<div class="form-text text-warning">No OPC UA or MxGateway connections defined yet.</div>
}
</div>
<div class="col-12">
<label class="form-label">Source Reference</label>
<input type="text" class="form-control font-monospace" @bind="_nasSourceRef"
placeholder="OPC UA SourceNode nodeId, or MxAccess object/area" />
</div>
<div class="col-12">
<label class="form-label">Condition Filter <span class="text-muted">(optional)</span></label>
<input type="text" class="form-control" @bind="_nasFilter"
placeholder="Blank = mirror all conditions under the source" />
</div>
<div class="col-12">
<label class="form-label">Description <span class="text-muted">(optional)</span></label>
<input type="text" class="form-control" @bind="_nasDescription" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_nasIsLocked" id="nasLocked" />
<label class="form-check-label" for="nasLocked">Locked</label>
</div>
</div>
@if (_nasFormError != null)
{
<div class="col-12"><div class="text-danger small">@_nasFormError</div></div>
}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelNativeSourceForm">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveNativeSource">@(editing ? "Save" : "Add")</button>
</div>
</div>
</div>
</div>
}
@if (_nativeSources.Count == 0)
{
<p class="text-muted">No native alarm sources defined.</p>
}
else
{
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Connection</th>
<th>Source Reference</th>
<th>Filter</th>
<th>Lock</th>
<th style="width: 60px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var src in _nativeSources)
{
<tr>
<td>@src.Name</td>
<td><span class="badge bg-light text-dark">@src.ConnectionName</span></td>
<td class="small font-monospace text-truncate" style="max-width: 220px;" title="@src.SourceReference">@src.SourceReference</td>
<td class="small text-muted">@(string.IsNullOrEmpty(src.ConditionFilter) ? "—" : src.ConditionFilter)</td>
<td>
@if (src.IsLocked)
{
<span class="badge bg-danger" aria-label="Locked">Locked</span>
}
else
{
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td>
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown" aria-expanded="false"
aria-label="@($"More actions for {src.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item" @onclick="() => BeginEditNativeSource(src)">Edit…</button></li>
<li><hr class="dropdown-divider" /></li>
<li><button class="dropdown-item text-danger" @onclick="() => DeleteNativeSource(src)">Delete</button></li>
</ul>
</div>
</td>
</tr>
}
</tbody>
</table>
}
};
private static bool IsAlarmCapable(string? protocol) =>
string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
private void BeginAddNativeSource()
{
_showNativeSourceForm = true;
_editNativeSourceId = null;
_nasFormError = null;
_nasName = string.Empty;
_nasConnection = string.Empty;
_nasSourceRef = string.Empty;
_nasFilter = null;
_nasDescription = null;
_nasIsLocked = false;
}
private void BeginEditNativeSource(TemplateNativeAlarmSource src)
{
_showNativeSourceForm = true;
_editNativeSourceId = src.Id;
_nasFormError = null;
_nasName = src.Name;
_nasConnection = src.ConnectionName;
_nasSourceRef = src.SourceReference;
_nasFilter = src.ConditionFilter;
_nasDescription = src.Description;
_nasIsLocked = src.IsLocked;
}
private void CancelNativeSourceForm()
{
_showNativeSourceForm = false;
_editNativeSourceId = null;
_nasFormError = null;
}
private async Task SaveNativeSource()
{
if (_selectedTemplate == null) return;
_nasFormError = null;
if (string.IsNullOrWhiteSpace(_nasName)) { _nasFormError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_nasConnection)) { _nasFormError = "Connection is required."; return; }
if (string.IsNullOrWhiteSpace(_nasSourceRef)) { _nasFormError = "Source reference is required."; return; }
if (_editNativeSourceId is int id)
{
var existing = await TemplateEngineRepository.GetTemplateNativeAlarmSourceByIdAsync(id);
if (existing == null) { _nasFormError = "Source no longer exists."; return; }
existing.ConnectionName = _nasConnection.Trim();
existing.SourceReference = _nasSourceRef.Trim();
existing.ConditionFilter = string.IsNullOrWhiteSpace(_nasFilter) ? null : _nasFilter.Trim();
existing.Description = string.IsNullOrWhiteSpace(_nasDescription) ? null : _nasDescription.Trim();
existing.IsLocked = _nasIsLocked;
await TemplateEngineRepository.UpdateTemplateNativeAlarmSourceAsync(existing);
await TemplateEngineRepository.SaveChangesAsync();
_showNativeSourceForm = false;
_editNativeSourceId = null;
_toast.ShowSuccess($"Native alarm source '{existing.Name}' updated.");
await LoadAsync();
return;
}
if (_nativeSources.Any(s => string.Equals(s.Name, _nasName.Trim(), StringComparison.OrdinalIgnoreCase)))
{
_nasFormError = $"A source named '{_nasName.Trim()}' already exists on this template.";
return;
}
var source = new TemplateNativeAlarmSource(_nasName.Trim())
{
TemplateId = _selectedTemplate.Id,
ConnectionName = _nasConnection.Trim(),
SourceReference = _nasSourceRef.Trim(),
ConditionFilter = string.IsNullOrWhiteSpace(_nasFilter) ? null : _nasFilter.Trim(),
Description = string.IsNullOrWhiteSpace(_nasDescription) ? null : _nasDescription.Trim(),
IsLocked = _nasIsLocked
};
await TemplateEngineRepository.AddTemplateNativeAlarmSourceAsync(source);
await TemplateEngineRepository.SaveChangesAsync();
_showNativeSourceForm = false;
_toast.ShowSuccess($"Native alarm source '{source.Name}' added.");
await LoadAsync();
}
private async Task DeleteNativeSource(TemplateNativeAlarmSource src)
{
var confirmed = await Dialog.ConfirmAsync("Delete Native Alarm Source", $"Delete native alarm source '{src.Name}'?", danger: true);
if (!confirmed) return;
await TemplateEngineRepository.DeleteTemplateNativeAlarmSourceAsync(src.Id);
await TemplateEngineRepository.SaveChangesAsync();
_toast.ShowSuccess($"Native alarm source '{src.Name}' deleted.");
await LoadAsync();
}
// ---- Scripts Tab ----
private RenderFragment RenderScriptsTab() => __builder =>
{
@@ -22,6 +22,8 @@ public class Instance
public ICollection<InstanceAlarmOverride> AlarmOverrides { get; set; } = new List<InstanceAlarmOverride>();
/// <summary>Data-connection bindings that map template tags to site data sources.</summary>
public ICollection<InstanceConnectionBinding> ConnectionBindings { get; set; } = new List<InstanceConnectionBinding>();
/// <summary>Per-instance overrides of template-defined native alarm source bindings.</summary>
public ICollection<InstanceNativeAlarmSourceOverride> NativeAlarmSourceOverrides { get; set; } = new List<InstanceNativeAlarmSourceOverride>();
/// <summary>
/// Initializes a new instance with the required unique name.
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
/// <summary>
/// Per-instance override of a template-defined native alarm source. The source
/// reference is the field that varies per physical instance (Tank01 vs Tank02),
/// so per-instance override is the common case. Mirrors
/// <c>InstanceAlarmOverride</c>; a null override field leaves the inherited
/// value unchanged.
/// </summary>
public class InstanceNativeAlarmSourceOverride
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the owning instance.</summary>
public int InstanceId { get; set; }
/// <summary>
/// Canonical name of the native alarm source being overridden — matches
/// <c>ResolvedNativeAlarmSource.CanonicalName</c> after flattening.
/// </summary>
public string SourceCanonicalName { get; set; }
/// <summary>Overrides the connection name when set; null = keep inherited.</summary>
public string? ConnectionNameOverride { get; set; }
/// <summary>Overrides the source reference when set; null = keep inherited.</summary>
public string? SourceReferenceOverride { get; set; }
/// <summary>Overrides the condition filter when set; null = keep inherited.</summary>
public string? ConditionFilterOverride { get; set; }
/// <summary>Initializes a new override for the specified source binding.</summary>
/// <param name="sourceCanonicalName">Canonical name of the source to override.</param>
public InstanceNativeAlarmSourceOverride(string sourceCanonicalName) =>
SourceCanonicalName = sourceCanonicalName ?? throw new ArgumentNullException(nameof(sourceCanonicalName));
}
@@ -38,6 +38,11 @@ public class Template
/// Collection of compositions defined in this template.
/// </summary>
public ICollection<TemplateComposition> Compositions { get; set; } = new List<TemplateComposition>();
/// <summary>
/// Collection of native alarm source bindings defined in this template
/// (read-only mirrors of OPC UA A&amp;C / MxAccess Gateway alarm feeds).
/// </summary>
public ICollection<TemplateNativeAlarmSource> NativeAlarmSources { get; set; } = new List<TemplateNativeAlarmSource>();
/// <summary>
/// True when this template was auto-derived to back a TemplateComposition
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
/// <summary>
/// A template-defined binding to a native alarm source: a data connection plus
/// a source reference (OPC UA SourceNode/notifier nodeId, or MxAccess
/// object/area). At deploy time the instance subscribes and mirrors all
/// conditions discovered under the source. Inheritance/lock semantics mirror
/// <see cref="TemplateAlarm"/>.
/// </summary>
public class TemplateNativeAlarmSource
{
/// <summary>Database primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the owning <see cref="Template"/>.</summary>
public int TemplateId { get; set; }
/// <summary>Unique source binding name within the template.</summary>
public string Name { get; set; }
/// <summary>Optional human-readable description.</summary>
public string? Description { get; set; }
/// <summary>Name of the data connection that owns the alarm feed.</summary>
public string ConnectionName { get; set; } = string.Empty;
/// <summary>Source reference (OPC UA SourceNode/notifier nodeId, or MxAccess object/area).</summary>
public string SourceReference { get; set; } = string.Empty;
/// <summary>Optional condition filter; null = mirror all conditions under the source.</summary>
public string? ConditionFilter { get; set; }
/// <summary>When true, this binding cannot be overridden in derived templates.</summary>
public bool IsLocked { get; set; }
/// <summary>True when copied from a base template and not yet overridden on the derived template.</summary>
public bool IsInherited { get; set; }
/// <summary>Set on a base binding; when true derived templates may not override it.</summary>
public bool LockedInDerived { get; set; }
/// <summary>Initializes a new binding with the specified name.</summary>
/// <param name="name">The unique binding name within the template.</param>
public TemplateNativeAlarmSource(string name) =>
Name = name ?? throw new ArgumentNullException(nameof(name));
}
@@ -0,0 +1,28 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
/// <summary>Callback invoked when a native alarm transition (incl. snapshot replay) arrives.</summary>
/// <param name="transition">The protocol-neutral transition emitted by the adapter.</param>
public delegate void AlarmTransitionCallback(NativeAlarmTransition transition);
/// <summary>
/// Optional capability for an <see cref="IDataConnection"/> implementation that
/// can mirror a source's native alarms (OPC UA Alarms &amp; Conditions, MxAccess
/// Gateway StreamAlarms). Mirrors the <see cref="IBrowsableDataConnection"/>
/// capability-interface pattern; consumed by the DataConnectionActor only.
/// </summary>
public interface IAlarmSubscribableConnection
{
/// <summary>
/// Subscribes to native alarm transitions for the conditions under
/// <paramref name="sourceReference"/>. The adapter replays a snapshot of
/// currently-active conditions (Snapshot…SnapshotComplete) on every
/// (re)subscribe. Returns a subscription id for <see cref="UnsubscribeAlarmsAsync"/>.
/// </summary>
Task<string> SubscribeAlarmsAsync(string sourceReference, string? conditionFilter,
AlarmTransitionCallback callback, CancellationToken cancellationToken = default);
/// <summary>Cancels an active alarm subscription by its id.</summary>
Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default);
}
@@ -96,6 +96,18 @@ public interface ITemplateEngineRepository
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteTemplateAlarmAsync(int id, CancellationToken cancellationToken = default);
// TemplateNativeAlarmSource
/// <summary>Retrieves a template native alarm source by ID.</summary>
Task<TemplateNativeAlarmSource?> GetTemplateNativeAlarmSourceByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves native alarm sources for a template.</summary>
Task<IReadOnlyList<TemplateNativeAlarmSource>> GetNativeAlarmSourcesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default);
/// <summary>Adds a new template native alarm source.</summary>
Task AddTemplateNativeAlarmSourceAsync(TemplateNativeAlarmSource source, CancellationToken cancellationToken = default);
/// <summary>Updates an existing template native alarm source.</summary>
Task UpdateTemplateNativeAlarmSourceAsync(TemplateNativeAlarmSource source, CancellationToken cancellationToken = default);
/// <summary>Deletes a template native alarm source by ID.</summary>
Task DeleteTemplateNativeAlarmSourceAsync(int id, CancellationToken cancellationToken = default);
// TemplateScript
/// <summary>Retrieves a template script by ID.</summary>
/// <param name="id">The script ID.</param>
@@ -214,6 +226,18 @@ public interface ITemplateEngineRepository
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default);
// InstanceNativeAlarmSourceOverride
/// <summary>Retrieves native alarm source overrides for an instance.</summary>
Task<IReadOnlyList<InstanceNativeAlarmSourceOverride>> GetNativeAlarmSourceOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>Retrieves a single native alarm source override by instance + source canonical name.</summary>
Task<InstanceNativeAlarmSourceOverride?> GetNativeAlarmSourceOverrideAsync(int instanceId, string sourceCanonicalName, CancellationToken cancellationToken = default);
/// <summary>Adds a new instance native alarm source override.</summary>
Task AddInstanceNativeAlarmSourceOverrideAsync(InstanceNativeAlarmSourceOverride ovr, CancellationToken cancellationToken = default);
/// <summary>Updates an existing instance native alarm source override.</summary>
Task UpdateInstanceNativeAlarmSourceOverrideAsync(InstanceNativeAlarmSourceOverride ovr, CancellationToken cancellationToken = default);
/// <summary>Deletes an instance native alarm source override by ID.</summary>
Task DeleteInstanceNativeAlarmSourceOverrideAsync(int id, CancellationToken cancellationToken = default);
// InstanceConnectionBinding
/// <summary>Retrieves connection bindings for an instance.</summary>
/// <param name="instanceId">The instance ID.</param>
@@ -0,0 +1,13 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
/// <summary>DCL → instance: a native alarm transition routed by source reference.</summary>
public record NativeAlarmTransitionUpdate(string ConnectionName, NativeAlarmTransition Transition);
/// <summary>
/// DCL → instance: the alarm feed for a source became unavailable (connection
/// lost). Consumers mark their mirrored alarms uncertain rather than clearing
/// them; the reconnect snapshot reconciles state.
/// </summary>
public record NativeAlarmSourceUnavailable(string ConnectionName, string SourceReference, DateTimeOffset Timestamp);
@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
/// <summary>
/// Sent by a NativeAlarmActor (via the DCL manager) to subscribe an instance to
/// native alarms for a source binding. The DataConnectionActor opens one alarm
/// feed per connection and routes transitions by source-object reference.
/// </summary>
public record SubscribeAlarmsRequest(
string CorrelationId,
string InstanceUniqueName,
string ConnectionName,
string SourceReference,
string? ConditionFilter,
DateTimeOffset Timestamp);
/// <summary>Reply to a <see cref="SubscribeAlarmsRequest"/>.</summary>
public record SubscribeAlarmsResponse(
string CorrelationId,
string InstanceUniqueName,
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);
/// <summary>Cancels a native alarm subscription for an instance + source.</summary>
public record UnsubscribeAlarmsRequest(
string CorrelationId,
string InstanceUniqueName,
string ConnectionName,
string SourceReference,
DateTimeOffset Timestamp);
@@ -45,3 +45,18 @@ public record DeleteInstanceAlarmOverrideCommand(
string AlarmCanonicalName);
public record ListInstanceAlarmOverridesCommand(int InstanceId);
// Native alarm source overrides (per-instance retarget of an inherited source binding;
// null fields keep the inherited value).
public record SetInstanceNativeAlarmSourceOverrideCommand(
int InstanceId,
string SourceCanonicalName,
string? ConnectionNameOverride,
string? SourceReferenceOverride,
string? ConditionFilterOverride);
public record DeleteInstanceNativeAlarmSourceOverrideCommand(
int InstanceId,
string SourceCanonicalName);
public record ListInstanceNativeAlarmSourceOverridesCommand(int InstanceId);
@@ -14,6 +14,11 @@ public record DeleteTemplateAttributeCommand(int AttributeId);
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
public record DeleteTemplateAlarmCommand(int AlarmId);
// Native alarm source bindings (read-only mirror of an OPC UA A&C / MxGateway source's alarms)
public record AddTemplateNativeAlarmSourceCommand(int TemplateId, string Name, string ConnectionName, string SourceReference, string? ConditionFilter, string? Description, bool IsLocked);
public record UpdateTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId, string Name, string ConnectionName, string SourceReference, string? ConditionFilter, string? Description, bool IsLocked);
public record DeleteTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId);
public record ListTemplateNativeAlarmSourcesCommand(int TemplateId);
public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
public record DeleteTemplateScriptCommand(int ScriptId);
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
@@ -26,4 +27,48 @@ public record AlarmStateChanged(
/// surface this to operators.
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>
/// Whether this alarm is computed at the site or mirrored from a native
/// source. Defaults to <see cref="AlarmKind.Computed"/>.
/// </summary>
public AlarmKind Kind { get; init; } = AlarmKind.Computed;
private AlarmConditionState? _condition;
/// <summary>
/// Unified A&amp;C-style condition (active/acked/shelved/suppressed + severity).
/// When not explicitly set, defaults to a computed mapping of
/// <see cref="State"/> + <see cref="Priority"/> so existing callers and
/// computed alarms carry a correct condition without extra work.
/// </summary>
public AlarmConditionState Condition
{
get => _condition ?? AlarmConditionStateFactory.ForComputed(State, Priority);
init => _condition = value;
}
/// <summary>Native per-condition key (e.g. "Tank01.Level.HiHi"); empty for computed alarms.</summary>
public string SourceReference { get; init; } = string.Empty;
/// <summary>Native alarm type name (e.g. "AnalogLimitAlarm.HiHi"); empty for computed alarms.</summary>
public string AlarmTypeName { get; init; } = string.Empty;
/// <summary>Native alarm category/taxonomy; empty for computed alarms.</summary>
public string Category { get; init; } = string.Empty;
/// <summary>Operator who acknowledged at the source (display-only); empty otherwise.</summary>
public string OperatorUser { get; init; } = string.Empty;
/// <summary>Operator comment captured at the source (display-only); empty otherwise.</summary>
public string OperatorComment { get; init; } = string.Empty;
/// <summary>When the native condition originally became active, if known.</summary>
public DateTimeOffset? OriginalRaiseTime { get; init; }
/// <summary>Current source value (display-only); empty for computed alarms.</summary>
public string CurrentValue { get; init; } = string.Empty;
/// <summary>Limit/threshold value for native limit alarms (display-only); empty otherwise.</summary>
public string LimitValue { get; init; } = string.Empty;
}
@@ -0,0 +1,24 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
/// <summary>
/// Unified, read-only alarm condition state. The OPC UA Part 9 sub-conditions
/// are orthogonal, and MxAccess's ACTIVE / ACTIVE_ACKED / INACTIVE map cleanly
/// onto them, so they are modeled as independent flags plus a 01000 severity.
/// Computed alarms populate this from State + Priority; native alarms mirror it
/// from the source.
/// </summary>
/// <param name="Active">Active vs Inactive.</param>
/// <param name="Acknowledged">Acked vs Unacked.</param>
/// <param name="Confirmed">Confirmed vs Unconfirmed; null when the condition is not confirmable.</param>
/// <param name="Shelve">Shelving sub-state.</param>
/// <param name="Suppressed">Whether the source has suppressed the condition.</param>
/// <param name="Severity">Severity on the unified 01000 scale.</param>
public record AlarmConditionState(
bool Active,
bool Acknowledged,
bool? Confirmed,
AlarmShelveState Shelve,
bool Suppressed,
int Severity);
@@ -0,0 +1,16 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
/// <summary>Builds <see cref="AlarmConditionState"/> values for the supported alarm kinds.</summary>
public static class AlarmConditionStateFactory
{
/// <summary>
/// Computed alarms have no native ack/shelve/suppress lifecycle: they are
/// auto-acked, never shelved or suppressed, not confirmable, and their
/// severity is the configured priority. Active mirrors the alarm State.
/// </summary>
public static AlarmConditionState ForComputed(AlarmState state, int priority) =>
new(Active: state == AlarmState.Active, Acknowledged: true, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: priority);
}
@@ -0,0 +1,39 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
/// <summary>
/// Protocol-neutral alarm transition emitted by an
/// <c>IAlarmSubscribableConnection</c> adapter (OPC UA A&amp;C or MxGateway).
/// Keyed by <see cref="SourceReference"/>; routed to instances by
/// <see cref="SourceObjectReference"/>.
/// </summary>
/// <param name="SourceReference">Stable per-condition key (e.g. "Tank01.Level.HiHi").</param>
/// <param name="SourceObjectReference">Owning source object used for instance routing (e.g. "Tank01").</param>
/// <param name="AlarmTypeName">Source alarm type (e.g. "AnalogLimitAlarm.HiHi").</param>
/// <param name="Kind">Transition classification, incl. snapshot replay sentinels.</param>
/// <param name="Condition">Mirrored condition state.</param>
/// <param name="Category">Source taxonomy/category.</param>
/// <param name="Description">Human-readable description from the source template.</param>
/// <param name="Message">Event message text.</param>
/// <param name="OperatorUser">Operator principal on ack transitions (display-only).</param>
/// <param name="OperatorComment">Operator comment on ack transitions (display-only).</param>
/// <param name="OriginalRaiseTime">When the condition originally became active, if known.</param>
/// <param name="TransitionTime">When this transition occurred.</param>
/// <param name="CurrentValue">Current source value (display-only).</param>
/// <param name="LimitValue">Limit/threshold value for limit alarms (display-only).</param>
public record NativeAlarmTransition(
string SourceReference,
string SourceObjectReference,
string AlarmTypeName,
AlarmTransitionKind Kind,
AlarmConditionState Condition,
string Category,
string Description,
string Message,
string OperatorUser,
string OperatorComment,
DateTimeOffset? OriginalRaiseTime,
DateTimeOffset TransitionTime,
string CurrentValue,
string LimitValue);
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
/// <summary>
/// Distinguishes how an alarm's state is produced: computed at the site from
/// attribute triggers, or mirrored read-only from a native source.
/// </summary>
public enum AlarmKind
{
/// <summary>Evaluated at the site by an AlarmActor from attribute triggers.</summary>
Computed,
/// <summary>Mirrored from an OPC UA Alarms &amp; Conditions server.</summary>
NativeOpcUa,
/// <summary>Mirrored from the MxAccess Gateway alarm feed.</summary>
NativeMxAccess
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
/// <summary>
/// OPC UA Part 9 shelving sub-state of an alarm condition. Mirrored read-only
/// from the source; computed alarms are always <see cref="Unshelved"/>.
/// </summary>
public enum AlarmShelveState
{
Unshelved,
OneShotShelved,
TimedShelved,
PermanentShelved
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
/// <summary>
/// Classifies a <c>NativeAlarmTransition</c> emitted by an
/// <c>IAlarmSubscribableConnection</c> adapter. <see cref="Snapshot"/> and
/// <see cref="SnapshotComplete"/> carry the initial active-condition replay
/// produced on every (re)subscribe so consumers can re-seed state.
/// </summary>
public enum AlarmTransitionKind
{
Snapshot,
SnapshotComplete,
Raise,
Acknowledge,
Clear,
Retrigger,
StateChange
}
@@ -23,6 +23,8 @@ public sealed record FlattenedConfiguration
public IReadOnlyList<ResolvedAlarm> Alarms { get; init; } = [];
/// <summary>Gets the resolved scripts.</summary>
public IReadOnlyList<ResolvedScript> Scripts { get; init; } = [];
/// <summary>Gets the resolved native alarm source bindings.</summary>
public IReadOnlyList<ResolvedNativeAlarmSource> NativeAlarmSources { get; init; } = [];
/// <summary>Gets the UTC timestamp when this configuration was generated.</summary>
public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow;
@@ -125,6 +127,25 @@ public sealed record ResolvedAlarm
public string Source { get; init; } = "Template";
}
/// <summary>
/// A fully resolved native alarm source binding (data connection + source
/// reference). Conditions under the source are discovered at runtime; this
/// record only carries the binding, not individual alarms.
/// </summary>
public sealed record ResolvedNativeAlarmSource
{
/// <summary>Gets the path-qualified canonical name.</summary>
public string CanonicalName { get; init; } = string.Empty;
/// <summary>Gets the data connection name that owns the alarm feed.</summary>
public string ConnectionName { get; init; } = string.Empty;
/// <summary>Gets the source reference (OPC UA SourceNode/notifier nodeId, or MxAccess object/area).</summary>
public string SourceReference { get; init; } = string.Empty;
/// <summary>Gets the optional condition filter; null = mirror all conditions under the source.</summary>
public string? ConditionFilter { get; init; }
/// <summary>Gets the source of this binding: "Template", "Inherited", "Composed", or "Override".</summary>
public string Source { get; init; } = "Template";
}
/// <summary>
/// A fully resolved script with code, trigger config, parameters, and return definition.
/// </summary>
@@ -80,5 +80,6 @@ public enum ValidationCategory
OnTriggerScriptNotFound,
CrossCallViolation,
MissingMetadata,
ConnectionConfig
ConnectionConfig,
NativeAlarmSourceInvalid
}
@@ -67,7 +67,24 @@ public class StreamRelayActor : ReceiveActor
Priority = msg.Priority,
Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp),
Level = MapAlarmLevel(msg.Level),
Message = msg.Message ?? string.Empty
Message = msg.Message ?? string.Empty,
// Native alarm enrichment (additive — computed alarms map their default condition).
Kind = msg.Kind.ToString(),
Active = msg.Condition.Active,
Acknowledged = msg.Condition.Acknowledged,
Confirmed = msg.Condition.Confirmed ?? false,
ShelveState = AlarmShelveStateCodec.ToWire(msg.Condition.Shelve),
Suppressed = msg.Condition.Suppressed,
SourceReference = msg.SourceReference ?? string.Empty,
AlarmTypeName = msg.AlarmTypeName ?? string.Empty,
Category = msg.Category ?? string.Empty,
OperatorUser = msg.OperatorUser ?? string.Empty,
OperatorComment = msg.OperatorComment ?? string.Empty,
OriginalRaiseTime = msg.OriginalRaiseTime.HasValue
? Timestamp.FromDateTimeOffset(msg.OriginalRaiseTime.Value)
: null,
CurrentValue = msg.CurrentValue ?? string.Empty,
LimitValue = msg.LimitValue ?? string.Empty
}
};
@@ -0,0 +1,21 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// <summary>
/// Maps <see cref="AlarmShelveState"/> to/from the wire string carried in the
/// <c>AlarmStateUpdate.shelve_state</c> proto field. The wire form is the enum
/// member name; an empty or unrecognized string parses back to
/// <see cref="AlarmShelveState.Unshelved"/> (the safe default).
/// </summary>
public static class AlarmShelveStateCodec
{
/// <summary>Returns the wire string for a shelve state (the enum member name).</summary>
public static string ToWire(AlarmShelveState state) => state.ToString();
/// <summary>Parses a wire string back to a shelve state; defaults to <see cref="AlarmShelveState.Unshelved"/>.</summary>
public static AlarmShelveState Parse(string? wire) =>
Enum.TryParse<AlarmShelveState>(wire, ignoreCase: true, out var state)
? state
: AlarmShelveState.Unshelved;
}
@@ -3,6 +3,7 @@ using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using Google.Protobuf.WellKnownTypes;
@@ -233,11 +234,32 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
evt.AlarmChanged.Timestamp.ToDateTimeOffset())
{
Level = MapAlarmLevel(evt.AlarmChanged.Level),
Message = evt.AlarmChanged.Message ?? string.Empty
Message = evt.AlarmChanged.Message ?? string.Empty,
// Native alarm enrichment (additive — computed alarms carry their default condition).
Kind = ParseAlarmKind(evt.AlarmChanged.Kind),
Condition = new AlarmConditionState(
Active: evt.AlarmChanged.Active,
Acknowledged: evt.AlarmChanged.Acknowledged,
Confirmed: evt.AlarmChanged.Confirmed,
Shelve: AlarmShelveStateCodec.Parse(evt.AlarmChanged.ShelveState),
Suppressed: evt.AlarmChanged.Suppressed,
Severity: evt.AlarmChanged.Priority),
SourceReference = evt.AlarmChanged.SourceReference ?? string.Empty,
AlarmTypeName = evt.AlarmChanged.AlarmTypeName ?? string.Empty,
Category = evt.AlarmChanged.Category ?? string.Empty,
OperatorUser = evt.AlarmChanged.OperatorUser ?? string.Empty,
OperatorComment = evt.AlarmChanged.OperatorComment ?? string.Empty,
OriginalRaiseTime = evt.AlarmChanged.OriginalRaiseTime?.ToDateTimeOffset(),
CurrentValue = evt.AlarmChanged.CurrentValue ?? string.Empty,
LimitValue = evt.AlarmChanged.LimitValue ?? string.Empty
},
_ => null
};
/// <summary>Parses the wire "kind" string back to <see cref="AlarmKind"/>; defaults to Computed.</summary>
internal static AlarmKind ParseAlarmKind(string? kind) =>
System.Enum.TryParse<AlarmKind>(kind, ignoreCase: true, out var k) ? k : AlarmKind.Computed;
/// <summary>
/// Maps proto Quality enum to domain string. Internal for testability.
/// </summary>
@@ -66,6 +66,23 @@ message AlarmStateUpdate {
google.protobuf.Timestamp timestamp = 5;
AlarmLevelEnum level = 6; // ALARM_LEVEL_NONE for binary trigger types; set by HiLo.
string message = 7; // Optional per-band operator message; empty when unset.
// Native alarm enrichment (additive computed alarms leave these at defaults).
// kind: "Computed" | "NativeOpcUa" | "NativeMxAccess".
string kind = 8;
bool active = 9; // unified condition: active vs inactive
bool acknowledged = 10; // acked vs unacked
bool confirmed = 11; // confirmed (false when not confirmable)
string shelve_state = 12; // Unshelved | OneShotShelved | TimedShelved | PermanentShelved
bool suppressed = 13;
string source_reference = 14; // native per-condition key; empty for computed
string alarm_type_name = 15;
string category = 16;
string operator_user = 17;
string operator_comment = 18;
google.protobuf.Timestamp original_raise_time = 19; // null when unknown
string current_value = 20;
string limit_value = 21;
}
// Audit Log (#23) telemetry: single lifecycle event ferried from a site SQLite
@@ -36,68 +36,75 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
"KAkSFgoOYXR0cmlidXRlX3BhdGgYAiABKAkSFgoOYXR0cmlidXRlX25hbWUY",
"AyABKAkSDQoFdmFsdWUYBCABKAkSJAoHcXVhbGl0eRgFIAEoDjITLnNpdGVz",
"dHJlYW0uUXVhbGl0eRItCgl0aW1lc3RhbXAYBiABKAsyGi5nb29nbGUucHJv",
"dG9idWYuVGltZXN0YW1wIuwBChBBbGFybVN0YXRlVXBkYXRlEhwKFGluc3Rh",
"dG9idWYuVGltZXN0YW1wIrgEChBBbGFybVN0YXRlVXBkYXRlEhwKFGluc3Rh",
"bmNlX3VuaXF1ZV9uYW1lGAEgASgJEhIKCmFsYXJtX25hbWUYAiABKAkSKQoF",
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkivQQKDUF1ZGl0RXZlbnRE",
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
"cmNlX3NpdGVfaWQYBiABKAkSGgoSc291cmNlX2luc3RhbmNlX2lkGAcgASgJ",
"EhUKDXNvdXJjZV9zY3JpcHQYCCABKAkSDQoFYWN0b3IYCSABKAkSDgoGdGFy",
"Z2V0GAogASgJEg4KBnN0YXR1cxgLIAEoCRIwCgtodHRwX3N0YXR1cxgMIAEo",
"CzIbLmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjAKC2R1cmF0aW9uX21z",
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl",
"Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAkS",
"EwoLc291cmNlX25vZGUYFiABKAkiPAoPQXVkaXRFdmVudEJhdGNoEikKBmV2",
"ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0byInCglJbmdl",
"c3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRzGAEgAygJIokDChZTaXRlQ2Fs",
"bE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJ",
"Eg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9z",
"aXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRITCgtyZXRyeV9jb3VudBgGIAEo",
"BRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0dHBfc3RhdHVzGAggASgLMhsu",
"Z29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSMgoOY3JlYXRlZF9hdF91dGMY",
"CSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRf",
"YXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIzCg90",
"ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0",
"YW1wEhMKC3NvdXJjZV9ub2RlGAwgASgJIoABChVDYWNoZWRUZWxlbWV0cnlQ",
"YWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0",
"RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNp",
"dGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gS",
"MgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5",
"UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRj",
"GAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9z",
"aXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50",
"cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2",
"YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJ",
"RUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThAC",
"Eg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1f",
"U1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIW",
"ChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQ",
"QUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FM",
"QVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoV",
"QUxBUk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2aWNl",
"ElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0",
"cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcK",
"EUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0",
"Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxl",
"bWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0",
"ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0",
"cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxs",
"QXVkaXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRp",
"b24uR3JwY2IGcHJvdG8z"));
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkSDAoEa2luZBgIIAEoCRIO",
"CgZhY3RpdmUYCSABKAgSFAoMYWNrbm93bGVkZ2VkGAogASgIEhEKCWNvbmZp",
"cm1lZBgLIAEoCBIUCgxzaGVsdmVfc3RhdGUYDCABKAkSEgoKc3VwcHJlc3Nl",
"ZBgNIAEoCBIYChBzb3VyY2VfcmVmZXJlbmNlGA4gASgJEhcKD2FsYXJtX3R5",
"cGVfbmFtZRgPIAEoCRIQCghjYXRlZ29yeRgQIAEoCRIVCg1vcGVyYXRvcl91",
"c2VyGBEgASgJEhgKEG9wZXJhdG9yX2NvbW1lbnQYEiABKAkSNwoTb3JpZ2lu",
"YWxfcmFpc2VfdGltZRgTIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh",
"bXASFQoNY3VycmVudF92YWx1ZRgUIAEoCRITCgtsaW1pdF92YWx1ZRgVIAEo",
"CSK9BAoNQXVkaXRFdmVudER0bxIQCghldmVudF9pZBgBIAEoCRIzCg9vY2N1",
"cnJlZF9hdF91dGMYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
"Eg8KB2NoYW5uZWwYAyABKAkSDAoEa2luZBgEIAEoCRIWCg5jb3JyZWxhdGlv",
"bl9pZBgFIAEoCRIWCg5zb3VyY2Vfc2l0ZV9pZBgGIAEoCRIaChJzb3VyY2Vf",
"aW5zdGFuY2VfaWQYByABKAkSFQoNc291cmNlX3NjcmlwdBgIIAEoCRINCgVh",
"Y3RvchgJIAEoCRIOCgZ0YXJnZXQYCiABKAkSDgoGc3RhdHVzGAsgASgJEjAK",
"C2h0dHBfc3RhdHVzGAwgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFs",
"dWUSMAoLZHVyYXRpb25fbXMYDSABKAsyGy5nb29nbGUucHJvdG9idWYuSW50",
"MzJWYWx1ZRIVCg1lcnJvcl9tZXNzYWdlGA4gASgJEhQKDGVycm9yX2RldGFp",
"bBgPIAEoCRIXCg9yZXF1ZXN0X3N1bW1hcnkYECABKAkSGAoQcmVzcG9uc2Vf",
"c3VtbWFyeRgRIAEoCRIZChFwYXlsb2FkX3RydW5jYXRlZBgSIAEoCBINCgVl",
"eHRyYRgTIAEoCRIUCgxleGVjdXRpb25faWQYFCABKAkSGwoTcGFyZW50X2V4",
"ZWN1dGlvbl9pZBgVIAEoCRITCgtzb3VyY2Vfbm9kZRgWIAEoCSI8Cg9BdWRp",
"dEV2ZW50QmF0Y2gSKQoGZXZlbnRzGAEgAygLMhkuc2l0ZXN0cmVhbS5BdWRp",
"dEV2ZW50RHRvIicKCUluZ2VzdEFjaxIaChJhY2NlcHRlZF9ldmVudF9pZHMY",
"ASADKAkiiQMKFlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8SHAoUdHJhY2tlZF9v",
"cGVyYXRpb25faWQYASABKAkSDwoHY2hhbm5lbBgCIAEoCRIOCgZ0YXJnZXQY",
"AyABKAkSEwoLc291cmNlX3NpdGUYBCABKAkSDgoGc3RhdHVzGAUgASgJEhMK",
"C3JldHJ5X2NvdW50GAYgASgFEhIKCmxhc3RfZXJyb3IYByABKAkSMAoLaHR0",
"cF9zdGF0dXMYCCABKAsyGy5nb29nbGUucHJvdG9idWYuSW50MzJWYWx1ZRIy",
"Cg5jcmVhdGVkX2F0X3V0YxgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l",
"c3RhbXASMgoOdXBkYXRlZF9hdF91dGMYCiABKAsyGi5nb29nbGUucHJvdG9i",
"dWYuVGltZXN0YW1wEjMKD3Rlcm1pbmFsX2F0X3V0YxgLIAEoCzIaLmdvb2ds",
"ZS5wcm90b2J1Zi5UaW1lc3RhbXASEwoLc291cmNlX25vZGUYDCABKAkigAEK",
"FUNhY2hlZFRlbGVtZXRyeVBhY2tldBIuCgthdWRpdF9ldmVudBgBIAEoCzIZ",
"LnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxI3CgtvcGVyYXRpb25hbBgCIAEo",
"CzIiLnNpdGVzdHJlYW0uU2l0ZUNhbGxPcGVyYXRpb25hbER0byJKChRDYWNo",
"ZWRUZWxlbWV0cnlCYXRjaBIyCgdwYWNrZXRzGAEgAygLMiEuc2l0ZXN0cmVh",
"bS5DYWNoZWRUZWxlbWV0cnlQYWNrZXQiWwoWUHVsbEF1ZGl0RXZlbnRzUmVx",
"dWVzdBItCglzaW5jZV91dGMYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt",
"ZXN0YW1wEhIKCmJhdGNoX3NpemUYAiABKAUiXAoXUHVsbEF1ZGl0RXZlbnRz",
"UmVzcG9uc2USKQoGZXZlbnRzGAEgAygLMhkuc2l0ZXN0cmVhbS5BdWRpdEV2",
"ZW50RHRvEhYKDm1vcmVfYXZhaWxhYmxlGAIgASgIKlwKB1F1YWxpdHkSFwoT",
"UVVBTElUWV9VTlNQRUNJRklFRBAAEhAKDFFVQUxJVFlfR09PRBABEhUKEVFV",
"QUxJVFlfVU5DRVJUQUlOEAISDwoLUVVBTElUWV9CQUQQAypdCg5BbGFybVN0",
"YXRlRW51bRIbChdBTEFSTV9TVEFURV9VTlNQRUNJRklFRBAAEhYKEkFMQVJN",
"X1NUQVRFX05PUk1BTBABEhYKEkFMQVJNX1NUQVRFX0FDVElWRRACKoUBCg5B",
"bGFybUxldmVsRW51bRIUChBBTEFSTV9MRVZFTF9OT05FEAASEwoPQUxBUk1f",
"TEVWRUxfTE9XEAESFwoTQUxBUk1fTEVWRUxfTE9XX0xPVxACEhQKEEFMQVJN",
"X0xFVkVMX0hJR0gQAxIZChVBTEFSTV9MRVZFTF9ISUdIX0hJR0gQBDLhAgoR",
"U2l0ZVN0cmVhbVNlcnZpY2USVQoRU3Vic2NyaWJlSW5zdGFuY2USIS5zaXRl",
"c3RyZWFtLkluc3RhbmNlU3RyZWFtUmVxdWVzdBobLnNpdGVzdHJlYW0uU2l0",
"ZVN0cmVhbUV2ZW50MAESRwoRSW5nZXN0QXVkaXRFdmVudHMSGy5zaXRlc3Ry",
"ZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrElAK",
"FUluZ2VzdENhY2hlZFRlbGVtZXRyeRIgLnNpdGVzdHJlYW0uQ2FjaGVkVGVs",
"ZW1ldHJ5QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJaCg9QdWxsQXVk",
"aXRFdmVudHMSIi5zaXRlc3RyZWFtLlB1bGxBdWRpdEV2ZW50c1JlcXVlc3Qa",
"Iy5zaXRlc3RyZWFtLlB1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlQiuqAihaQi5N",
"T00uV1cuU2NhZGFCcmlkZ2UuQ29tbXVuaWNhdGlvbi5HcnBjYgZwcm90bzM="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.Quality), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateEnum), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.InstanceStreamRequest.Parser, new[]{ "CorrelationId", "InstanceUniqueName" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AttributeValueUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message", "Kind", "Active", "Acknowledged", "Confirmed", "ShelveState", "Suppressed", "SourceReference", "AlarmTypeName", "Category", "OperatorUser", "OperatorComment", "OriginalRaiseTime", "CurrentValue", "LimitValue" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId", "ParentExecutionId", "SourceNode" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null),
@@ -1141,6 +1148,20 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
timestamp_ = other.timestamp_ != null ? other.timestamp_.Clone() : null;
level_ = other.level_;
message_ = other.message_;
kind_ = other.kind_;
active_ = other.active_;
acknowledged_ = other.acknowledged_;
confirmed_ = other.confirmed_;
shelveState_ = other.shelveState_;
suppressed_ = other.suppressed_;
sourceReference_ = other.sourceReference_;
alarmTypeName_ = other.alarmTypeName_;
category_ = other.category_;
operatorUser_ = other.operatorUser_;
operatorComment_ = other.operatorComment_;
originalRaiseTime_ = other.originalRaiseTime_ != null ? other.originalRaiseTime_.Clone() : null;
currentValue_ = other.currentValue_;
limitValue_ = other.limitValue_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -1240,6 +1261,196 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
}
}
/// <summary>Field number for the "kind" field.</summary>
public const int KindFieldNumber = 8;
private string kind_ = "";
/// <summary>
/// Native alarm enrichment (additive — computed alarms leave these at defaults).
/// kind: "Computed" | "NativeOpcUa" | "NativeMxAccess".
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string Kind {
get { return kind_; }
set {
kind_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "active" field.</summary>
public const int ActiveFieldNumber = 9;
private bool active_;
/// <summary>
/// unified condition: active vs inactive
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool Active {
get { return active_; }
set {
active_ = value;
}
}
/// <summary>Field number for the "acknowledged" field.</summary>
public const int AcknowledgedFieldNumber = 10;
private bool acknowledged_;
/// <summary>
/// acked vs unacked
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool Acknowledged {
get { return acknowledged_; }
set {
acknowledged_ = value;
}
}
/// <summary>Field number for the "confirmed" field.</summary>
public const int ConfirmedFieldNumber = 11;
private bool confirmed_;
/// <summary>
/// confirmed (false when not confirmable)
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool Confirmed {
get { return confirmed_; }
set {
confirmed_ = value;
}
}
/// <summary>Field number for the "shelve_state" field.</summary>
public const int ShelveStateFieldNumber = 12;
private string shelveState_ = "";
/// <summary>
/// Unshelved | OneShotShelved | TimedShelved | PermanentShelved
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string ShelveState {
get { return shelveState_; }
set {
shelveState_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "suppressed" field.</summary>
public const int SuppressedFieldNumber = 13;
private bool suppressed_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool Suppressed {
get { return suppressed_; }
set {
suppressed_ = value;
}
}
/// <summary>Field number for the "source_reference" field.</summary>
public const int SourceReferenceFieldNumber = 14;
private string sourceReference_ = "";
/// <summary>
/// native per-condition key; empty for computed
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string SourceReference {
get { return sourceReference_; }
set {
sourceReference_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "alarm_type_name" field.</summary>
public const int AlarmTypeNameFieldNumber = 15;
private string alarmTypeName_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string AlarmTypeName {
get { return alarmTypeName_; }
set {
alarmTypeName_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "category" field.</summary>
public const int CategoryFieldNumber = 16;
private string category_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string Category {
get { return category_; }
set {
category_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "operator_user" field.</summary>
public const int OperatorUserFieldNumber = 17;
private string operatorUser_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string OperatorUser {
get { return operatorUser_; }
set {
operatorUser_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "operator_comment" field.</summary>
public const int OperatorCommentFieldNumber = 18;
private string operatorComment_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string OperatorComment {
get { return operatorComment_; }
set {
operatorComment_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "original_raise_time" field.</summary>
public const int OriginalRaiseTimeFieldNumber = 19;
private global::Google.Protobuf.WellKnownTypes.Timestamp originalRaiseTime_;
/// <summary>
/// null when unknown
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public global::Google.Protobuf.WellKnownTypes.Timestamp OriginalRaiseTime {
get { return originalRaiseTime_; }
set {
originalRaiseTime_ = value;
}
}
/// <summary>Field number for the "current_value" field.</summary>
public const int CurrentValueFieldNumber = 20;
private string currentValue_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string CurrentValue {
get { return currentValue_; }
set {
currentValue_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "limit_value" field.</summary>
public const int LimitValueFieldNumber = 21;
private string limitValue_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string LimitValue {
get { return limitValue_; }
set {
limitValue_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -1262,6 +1473,20 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (!object.Equals(Timestamp, other.Timestamp)) return false;
if (Level != other.Level) return false;
if (Message != other.Message) return false;
if (Kind != other.Kind) return false;
if (Active != other.Active) return false;
if (Acknowledged != other.Acknowledged) return false;
if (Confirmed != other.Confirmed) return false;
if (ShelveState != other.ShelveState) return false;
if (Suppressed != other.Suppressed) return false;
if (SourceReference != other.SourceReference) return false;
if (AlarmTypeName != other.AlarmTypeName) return false;
if (Category != other.Category) return false;
if (OperatorUser != other.OperatorUser) return false;
if (OperatorComment != other.OperatorComment) return false;
if (!object.Equals(OriginalRaiseTime, other.OriginalRaiseTime)) return false;
if (CurrentValue != other.CurrentValue) return false;
if (LimitValue != other.LimitValue) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -1276,6 +1501,20 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (timestamp_ != null) hash ^= Timestamp.GetHashCode();
if (Level != global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmLevelEnum.AlarmLevelNone) hash ^= Level.GetHashCode();
if (Message.Length != 0) hash ^= Message.GetHashCode();
if (Kind.Length != 0) hash ^= Kind.GetHashCode();
if (Active != false) hash ^= Active.GetHashCode();
if (Acknowledged != false) hash ^= Acknowledged.GetHashCode();
if (Confirmed != false) hash ^= Confirmed.GetHashCode();
if (ShelveState.Length != 0) hash ^= ShelveState.GetHashCode();
if (Suppressed != false) hash ^= Suppressed.GetHashCode();
if (SourceReference.Length != 0) hash ^= SourceReference.GetHashCode();
if (AlarmTypeName.Length != 0) hash ^= AlarmTypeName.GetHashCode();
if (Category.Length != 0) hash ^= Category.GetHashCode();
if (OperatorUser.Length != 0) hash ^= OperatorUser.GetHashCode();
if (OperatorComment.Length != 0) hash ^= OperatorComment.GetHashCode();
if (originalRaiseTime_ != null) hash ^= OriginalRaiseTime.GetHashCode();
if (CurrentValue.Length != 0) hash ^= CurrentValue.GetHashCode();
if (LimitValue.Length != 0) hash ^= LimitValue.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -1322,6 +1561,62 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
output.WriteRawTag(58);
output.WriteString(Message);
}
if (Kind.Length != 0) {
output.WriteRawTag(66);
output.WriteString(Kind);
}
if (Active != false) {
output.WriteRawTag(72);
output.WriteBool(Active);
}
if (Acknowledged != false) {
output.WriteRawTag(80);
output.WriteBool(Acknowledged);
}
if (Confirmed != false) {
output.WriteRawTag(88);
output.WriteBool(Confirmed);
}
if (ShelveState.Length != 0) {
output.WriteRawTag(98);
output.WriteString(ShelveState);
}
if (Suppressed != false) {
output.WriteRawTag(104);
output.WriteBool(Suppressed);
}
if (SourceReference.Length != 0) {
output.WriteRawTag(114);
output.WriteString(SourceReference);
}
if (AlarmTypeName.Length != 0) {
output.WriteRawTag(122);
output.WriteString(AlarmTypeName);
}
if (Category.Length != 0) {
output.WriteRawTag(130, 1);
output.WriteString(Category);
}
if (OperatorUser.Length != 0) {
output.WriteRawTag(138, 1);
output.WriteString(OperatorUser);
}
if (OperatorComment.Length != 0) {
output.WriteRawTag(146, 1);
output.WriteString(OperatorComment);
}
if (originalRaiseTime_ != null) {
output.WriteRawTag(154, 1);
output.WriteMessage(OriginalRaiseTime);
}
if (CurrentValue.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(CurrentValue);
}
if (LimitValue.Length != 0) {
output.WriteRawTag(170, 1);
output.WriteString(LimitValue);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -1360,6 +1655,62 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
output.WriteRawTag(58);
output.WriteString(Message);
}
if (Kind.Length != 0) {
output.WriteRawTag(66);
output.WriteString(Kind);
}
if (Active != false) {
output.WriteRawTag(72);
output.WriteBool(Active);
}
if (Acknowledged != false) {
output.WriteRawTag(80);
output.WriteBool(Acknowledged);
}
if (Confirmed != false) {
output.WriteRawTag(88);
output.WriteBool(Confirmed);
}
if (ShelveState.Length != 0) {
output.WriteRawTag(98);
output.WriteString(ShelveState);
}
if (Suppressed != false) {
output.WriteRawTag(104);
output.WriteBool(Suppressed);
}
if (SourceReference.Length != 0) {
output.WriteRawTag(114);
output.WriteString(SourceReference);
}
if (AlarmTypeName.Length != 0) {
output.WriteRawTag(122);
output.WriteString(AlarmTypeName);
}
if (Category.Length != 0) {
output.WriteRawTag(130, 1);
output.WriteString(Category);
}
if (OperatorUser.Length != 0) {
output.WriteRawTag(138, 1);
output.WriteString(OperatorUser);
}
if (OperatorComment.Length != 0) {
output.WriteRawTag(146, 1);
output.WriteString(OperatorComment);
}
if (originalRaiseTime_ != null) {
output.WriteRawTag(154, 1);
output.WriteMessage(OriginalRaiseTime);
}
if (CurrentValue.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(CurrentValue);
}
if (LimitValue.Length != 0) {
output.WriteRawTag(170, 1);
output.WriteString(LimitValue);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -1391,6 +1742,48 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (Message.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(Message);
}
if (Kind.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(Kind);
}
if (Active != false) {
size += 1 + 1;
}
if (Acknowledged != false) {
size += 1 + 1;
}
if (Confirmed != false) {
size += 1 + 1;
}
if (ShelveState.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(ShelveState);
}
if (Suppressed != false) {
size += 1 + 1;
}
if (SourceReference.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceReference);
}
if (AlarmTypeName.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(AlarmTypeName);
}
if (Category.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(Category);
}
if (OperatorUser.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(OperatorUser);
}
if (OperatorComment.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(OperatorComment);
}
if (originalRaiseTime_ != null) {
size += 2 + pb::CodedOutputStream.ComputeMessageSize(OriginalRaiseTime);
}
if (CurrentValue.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(CurrentValue);
}
if (LimitValue.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(LimitValue);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -1427,6 +1820,51 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
if (other.Message.Length != 0) {
Message = other.Message;
}
if (other.Kind.Length != 0) {
Kind = other.Kind;
}
if (other.Active != false) {
Active = other.Active;
}
if (other.Acknowledged != false) {
Acknowledged = other.Acknowledged;
}
if (other.Confirmed != false) {
Confirmed = other.Confirmed;
}
if (other.ShelveState.Length != 0) {
ShelveState = other.ShelveState;
}
if (other.Suppressed != false) {
Suppressed = other.Suppressed;
}
if (other.SourceReference.Length != 0) {
SourceReference = other.SourceReference;
}
if (other.AlarmTypeName.Length != 0) {
AlarmTypeName = other.AlarmTypeName;
}
if (other.Category.Length != 0) {
Category = other.Category;
}
if (other.OperatorUser.Length != 0) {
OperatorUser = other.OperatorUser;
}
if (other.OperatorComment.Length != 0) {
OperatorComment = other.OperatorComment;
}
if (other.originalRaiseTime_ != null) {
if (originalRaiseTime_ == null) {
OriginalRaiseTime = new global::Google.Protobuf.WellKnownTypes.Timestamp();
}
OriginalRaiseTime.MergeFrom(other.OriginalRaiseTime);
}
if (other.CurrentValue.Length != 0) {
CurrentValue = other.CurrentValue;
}
if (other.LimitValue.Length != 0) {
LimitValue = other.LimitValue;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -1477,6 +1915,65 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
Message = input.ReadString();
break;
}
case 66: {
Kind = input.ReadString();
break;
}
case 72: {
Active = input.ReadBool();
break;
}
case 80: {
Acknowledged = input.ReadBool();
break;
}
case 88: {
Confirmed = input.ReadBool();
break;
}
case 98: {
ShelveState = input.ReadString();
break;
}
case 104: {
Suppressed = input.ReadBool();
break;
}
case 114: {
SourceReference = input.ReadString();
break;
}
case 122: {
AlarmTypeName = input.ReadString();
break;
}
case 130: {
Category = input.ReadString();
break;
}
case 138: {
OperatorUser = input.ReadString();
break;
}
case 146: {
OperatorComment = input.ReadString();
break;
}
case 154: {
if (originalRaiseTime_ == null) {
OriginalRaiseTime = new global::Google.Protobuf.WellKnownTypes.Timestamp();
}
input.ReadMessage(OriginalRaiseTime);
break;
}
case 162: {
CurrentValue = input.ReadString();
break;
}
case 170: {
LimitValue = input.ReadString();
break;
}
}
}
#endif
@@ -1527,6 +2024,65 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
Message = input.ReadString();
break;
}
case 66: {
Kind = input.ReadString();
break;
}
case 72: {
Active = input.ReadBool();
break;
}
case 80: {
Acknowledged = input.ReadBool();
break;
}
case 88: {
Confirmed = input.ReadBool();
break;
}
case 98: {
ShelveState = input.ReadString();
break;
}
case 104: {
Suppressed = input.ReadBool();
break;
}
case 114: {
SourceReference = input.ReadString();
break;
}
case 122: {
AlarmTypeName = input.ReadString();
break;
}
case 130: {
Category = input.ReadString();
break;
}
case 138: {
OperatorUser = input.ReadString();
break;
}
case 146: {
OperatorComment = input.ReadString();
break;
}
case 154: {
if (originalRaiseTime_ == null) {
OriginalRaiseTime = new global::Google.Protobuf.WellKnownTypes.Timestamp();
}
input.ReadMessage(OriginalRaiseTime);
break;
}
case 162: {
CurrentValue = input.ReadString();
break;
}
case 170: {
LimitValue = input.ReadString();
break;
}
}
}
}
@@ -53,10 +53,40 @@ public class InstanceConfiguration : IEntityTypeConfiguration<Instance>
.HasForeignKey(b => b.InstanceId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(i => i.NativeAlarmSourceOverrides)
.WithOne()
.HasForeignKey(o => o.InstanceId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(i => new { i.SiteId, i.UniqueName }).IsUnique();
}
}
public class InstanceNativeAlarmSourceOverrideConfiguration : IEntityTypeConfiguration<InstanceNativeAlarmSourceOverride>
{
/// <summary>Configures the EF Core mapping for <see cref="InstanceNativeAlarmSourceOverride"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<InstanceNativeAlarmSourceOverride> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.SourceCanonicalName)
.IsRequired()
.HasMaxLength(400); // Larger than names to fit composed paths.
builder.Property(o => o.ConnectionNameOverride)
.HasMaxLength(200);
builder.Property(o => o.SourceReferenceOverride)
.HasMaxLength(1000);
builder.Property(o => o.ConditionFilterOverride)
.HasMaxLength(1000);
builder.HasIndex(o => new { o.InstanceId, o.SourceCanonicalName }).IsUnique();
}
}
public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration<InstanceAttributeOverride>
{
/// <summary>Configures the EF Core mapping for <see cref="InstanceAttributeOverride"/>.</summary>
@@ -58,6 +58,41 @@ public class TemplateConfiguration : IEntityTypeConfiguration<Template>
.WithOne()
.HasForeignKey(c => c.TemplateId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(t => t.NativeAlarmSources)
.WithOne()
.HasForeignKey(s => s.TemplateId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public class TemplateNativeAlarmSourceConfiguration : IEntityTypeConfiguration<TemplateNativeAlarmSource>
{
/// <summary>Configures the EF Core mapping for <see cref="TemplateNativeAlarmSource"/>.</summary>
/// <param name="builder">The entity type builder.</param>
public void Configure(EntityTypeBuilder<TemplateNativeAlarmSource> builder)
{
builder.HasKey(s => s.Id);
builder.Property(s => s.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(s => s.Description)
.HasMaxLength(2000);
builder.Property(s => s.ConnectionName)
.IsRequired()
.HasMaxLength(200);
builder.Property(s => s.SourceReference)
.IsRequired()
.HasMaxLength(1000);
builder.Property(s => s.ConditionFilter)
.HasMaxLength(1000);
builder.HasIndex(s => new { s.TemplateId, s.Name }).IsUnique();
}
}
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
/// Factory for creating DbContext instances at design time (used by dotnet ef tooling).
/// Resolves the connection string from the Host's appsettings files, or — for environments
/// where those files are not present — from the
/// <c>SCADALINK_DESIGNTIME_CONNECTIONSTRING</c> environment variable.
/// <c>SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING</c> environment variable.
/// </summary>
/// <remarks>
/// There is deliberately no hardcoded fallback connection string. A credential literal in
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
/// </remarks>
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ScadaBridgeDbContext>
{
private const string EnvironmentVariableName = "SCADALINK_DESIGNTIME_CONNECTIONSTRING";
private const string EnvironmentVariableName = "SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING";
private const string ConfigurationKey = "ScadaBridge:Database:ConfigurationDb";
/// <summary>
@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddNativeAlarmSources : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "InstanceNativeAlarmSourceOverrides",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
InstanceId = table.Column<int>(type: "int", nullable: false),
SourceCanonicalName = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
ConnectionNameOverride = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
SourceReferenceOverride = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ConditionFilterOverride = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_InstanceNativeAlarmSourceOverrides", x => x.Id);
table.ForeignKey(
name: "FK_InstanceNativeAlarmSourceOverrides_Instances_InstanceId",
column: x => x.InstanceId,
principalTable: "Instances",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TemplateNativeAlarmSources",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
TemplateId = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
ConnectionName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
SourceReference = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
ConditionFilter = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
IsLocked = table.Column<bool>(type: "bit", nullable: false),
IsInherited = table.Column<bool>(type: "bit", nullable: false),
LockedInDerived = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TemplateNativeAlarmSources", x => x.Id);
table.ForeignKey(
name: "FK_TemplateNativeAlarmSources_Templates_TemplateId",
column: x => x.TemplateId,
principalTable: "Templates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_InstanceNativeAlarmSourceOverrides_InstanceId_SourceCanonicalName",
table: "InstanceNativeAlarmSourceOverrides",
columns: new[] { "InstanceId", "SourceCanonicalName" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TemplateNativeAlarmSources_TemplateId_Name",
table: "TemplateNativeAlarmSources",
columns: new[] { "TemplateId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InstanceNativeAlarmSourceOverrides");
migrationBuilder.DropTable(
name: "TemplateNativeAlarmSources");
}
}
}
@@ -786,6 +786,42 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("InstanceConnectionBindings");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ConditionFilterOverride")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("ConnectionNameOverride")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("InstanceId")
.HasColumnType("int");
b.Property<string>("SourceCanonicalName")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.Property<string>("SourceReferenceOverride")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.HasKey("Id");
b.HasIndex("InstanceId", "SourceCanonicalName")
.IsUnique();
b.ToTable("InstanceNativeAlarmSourceOverrides");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b =>
{
b.Property<string>("NotificationId")
@@ -1383,6 +1419,57 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("TemplateFolders");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ConditionFilter")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("ConnectionName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<bool>("IsInherited")
.HasColumnType("bit");
b.Property<bool>("IsLocked")
.HasColumnType("bit");
b.Property<bool>("LockedInDerived")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("SourceReference")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int>("TemplateId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TemplateId", "Name")
.IsUnique();
b.ToTable("TemplateNativeAlarmSources");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b =>
{
b.Property<int>("Id")
@@ -1533,6 +1620,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.IsRequired();
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b =>
{
b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null)
.WithMany("NativeAlarmSourceOverrides")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b =>
{
b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null)
@@ -1620,6 +1716,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b =>
{
b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null)
.WithMany("NativeAlarmSources")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b =>
{
b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null)
@@ -1641,6 +1746,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.Navigation("AttributeOverrides");
b.Navigation("ConnectionBindings");
b.Navigation("NativeAlarmSourceOverrides");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b =>
@@ -1656,6 +1763,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.Navigation("Compositions");
b.Navigation("NativeAlarmSources");
b.Navigation("Scripts");
});
#pragma warning restore 612, 618
@@ -29,6 +29,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Include(t => t.Compositions)
.Include(t => t.NativeAlarmSources)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
}
@@ -59,6 +60,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Include(t => t.Compositions)
.Include(t => t.NativeAlarmSources)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
@@ -71,6 +73,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Include(t => t.Compositions)
.Include(t => t.NativeAlarmSources)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
@@ -188,6 +191,45 @@ public class TemplateEngineRepository : ITemplateEngineRepository
}
}
// TemplateNativeAlarmSource
/// <inheritdoc />
public async Task<TemplateNativeAlarmSource?> GetTemplateNativeAlarmSourceByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.TemplateNativeAlarmSources.FindAsync(new object[] { id }, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateNativeAlarmSource>> GetNativeAlarmSourcesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _context.TemplateNativeAlarmSources
.Where(s => s.TemplateId == templateId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddTemplateNativeAlarmSourceAsync(TemplateNativeAlarmSource source, CancellationToken cancellationToken = default)
{
await _context.TemplateNativeAlarmSources.AddAsync(source, cancellationToken);
}
/// <inheritdoc />
public Task UpdateTemplateNativeAlarmSourceAsync(TemplateNativeAlarmSource source, CancellationToken cancellationToken = default)
{
_context.TemplateNativeAlarmSources.Update(source);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteTemplateNativeAlarmSourceAsync(int id, CancellationToken cancellationToken = default)
{
var source = await _context.TemplateNativeAlarmSources.FindAsync(new object[] { id }, cancellationToken);
if (source != null)
{
_context.TemplateNativeAlarmSources.Remove(source);
}
}
// TemplateScript
/// <inheritdoc />
@@ -275,6 +317,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.AsSplitQuery()
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
}
@@ -286,6 +329,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
@@ -306,6 +350,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
@@ -317,6 +362,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.AsSplitQuery()
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
}
@@ -419,6 +465,46 @@ public class TemplateEngineRepository : ITemplateEngineRepository
}
}
// InstanceNativeAlarmSourceOverride
/// <inheritdoc />
public async Task<IReadOnlyList<InstanceNativeAlarmSourceOverride>> GetNativeAlarmSourceOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _context.InstanceNativeAlarmSourceOverrides
.Where(o => o.InstanceId == instanceId)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<InstanceNativeAlarmSourceOverride?> GetNativeAlarmSourceOverrideAsync(int instanceId, string sourceCanonicalName, CancellationToken cancellationToken = default)
{
return await _context.InstanceNativeAlarmSourceOverrides
.FirstOrDefaultAsync(o => o.InstanceId == instanceId && o.SourceCanonicalName == sourceCanonicalName, cancellationToken);
}
/// <inheritdoc />
public async Task AddInstanceNativeAlarmSourceOverrideAsync(InstanceNativeAlarmSourceOverride ovr, CancellationToken cancellationToken = default)
{
await _context.InstanceNativeAlarmSourceOverrides.AddAsync(ovr, cancellationToken);
}
/// <inheritdoc />
public Task UpdateInstanceNativeAlarmSourceOverrideAsync(InstanceNativeAlarmSourceOverride ovr, CancellationToken cancellationToken = default)
{
_context.InstanceNativeAlarmSourceOverrides.Update(ovr);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceNativeAlarmSourceOverrideAsync(int id, CancellationToken cancellationToken = default)
{
var ovr = await _context.InstanceNativeAlarmSourceOverrides.FindAsync(new object[] { id }, cancellationToken);
if (ovr != null)
{
_context.InstanceNativeAlarmSourceOverrides.Remove(ovr);
}
}
// InstanceConnectionBinding
/// <inheritdoc />
@@ -55,6 +55,8 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
public DbSet<TemplateComposition> TemplateCompositions => Set<TemplateComposition>();
/// <summary>Gets the set of template folders.</summary>
public DbSet<TemplateFolder> TemplateFolders => Set<TemplateFolder>();
/// <summary>Gets the set of template native alarm source bindings.</summary>
public DbSet<TemplateNativeAlarmSource> TemplateNativeAlarmSources => Set<TemplateNativeAlarmSource>();
// Instances
/// <summary>Gets the set of instances.</summary>
@@ -65,6 +67,8 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
public DbSet<InstanceAlarmOverride> InstanceAlarmOverrides => Set<InstanceAlarmOverride>();
/// <summary>Gets the set of instance connection bindings.</summary>
public DbSet<InstanceConnectionBinding> InstanceConnectionBindings => Set<InstanceConnectionBinding>();
/// <summary>Gets the set of instance native alarm source overrides.</summary>
public DbSet<InstanceNativeAlarmSourceOverride> InstanceNativeAlarmSourceOverrides => Set<InstanceNativeAlarmSourceOverride>();
/// <summary>Gets the set of areas.</summary>
public DbSet<Area> Areas => Set<Area>();
@@ -3,6 +3,7 @@ using Akka.Event;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
@@ -93,6 +94,18 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
/// </summary>
private readonly Dictionary<string, IActorRef> _subscribers = new();
// ── Native alarm subscriptions (Task-10) ──
// The connection opens one alarm feed per source reference; transitions are
// routed to subscribers (NativeAlarmActors) by source-object reference.
/// <summary>sourceReference → set of subscriber actor refs (NativeAlarmActors), for routing + ref-count.</summary>
private readonly Dictionary<string, HashSet<IActorRef>> _alarmSourceSubscribers = new();
/// <summary>sourceReference → optional condition filter (first subscriber wins).</summary>
private readonly Dictionary<string, string?> _alarmSourceFilter = new();
/// <summary>sourceReference → adapter alarm subscription id.</summary>
private readonly Dictionary<string, string> _alarmSubscriptionIds = new();
/// <summary>sourceReferences whose adapter SubscribeAlarmsAsync is currently in flight.</summary>
private readonly HashSet<string> _alarmSubscribesInFlight = new();
/// <summary>
/// Tracks total subscribed and resolved tags for health reporting.
/// </summary>
@@ -227,6 +240,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case SubscribeTagsRequest:
case WriteTagRequest:
case UnsubscribeTagsRequest:
case SubscribeAlarmsRequest:
case UnsubscribeAlarmsRequest:
Stash.Stash();
break;
case SubscribeCompleted sc:
@@ -234,6 +249,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc);
break;
case AlarmSubscribeCompleted asc:
HandleAlarmSubscribeCompleted(asc);
break;
case AlarmTransitionReceived:
// No live feed yet in Connecting; ignore (snapshot replays on subscribe).
break;
case BrowseNodeCommand browse:
// Browse is an interactive design-time query; never stash. The
// adapter has no session yet in this state, so reply with a
@@ -289,6 +310,18 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case UnsubscribeTagsRequest req:
HandleUnsubscribe(req);
break;
case SubscribeAlarmsRequest areq:
HandleSubscribeAlarms(areq);
break;
case UnsubscribeAlarmsRequest areq:
HandleUnsubscribeAlarms(areq);
break;
case AlarmSubscribeCompleted asc:
HandleAlarmSubscribeCompleted(asc);
break;
case AlarmTransitionReceived atr:
HandleAlarmTransitionReceived(atr);
break;
case WriteTagRequest req:
HandleWrite(req);
break;
@@ -398,6 +431,10 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// WP-9: Push bad quality for all subscribed tags on disconnect
PushBadQualityForAllTags();
// Task-10: notify native alarm subscribers the source feed is unavailable
// (mark mirrored alarms uncertain; the reconnect snapshot reconciles them).
PushAlarmSourceUnavailable();
// Schedule reconnect attempt
Timers.StartSingleTimer("reconnect", new AttemptConnect(), _options.ReconnectInterval);
}
@@ -414,15 +451,23 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
break;
case SubscribeTagsRequest:
case WriteTagRequest:
case SubscribeAlarmsRequest:
Stash.Stash();
break;
case UnsubscribeTagsRequest req:
// Allow unsubscribe even during reconnect (for cleanup on instance stop)
HandleUnsubscribe(req);
break;
case UnsubscribeAlarmsRequest areq:
// Allow alarm unsubscribe during reconnect (cleanup on instance stop).
HandleUnsubscribeAlarms(areq);
break;
case TagValueReceived:
// Ignore — stale callback from previous connection
break;
case AlarmTransitionReceived:
// Ignore — stale alarm callback from previous connection; ReSubscribeAll re-seeds.
break;
case TagResolutionSucceeded:
case TagResolutionFailed:
// Ignore — stale results from previous connection; ReSubscribeAll runs after reconnect
@@ -432,6 +477,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc);
break;
case AlarmSubscribeCompleted asc:
HandleAlarmSubscribeCompleted(asc);
break;
case BrowseNodeCommand browse:
// Browse is design-time and never stashed. While reconnecting
// the adapter has no live session, so the adapter call will
@@ -505,6 +553,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// WP-10: Transparent re-subscribe — re-establish all active subscriptions
ReSubscribeAll();
// Task-10: re-establish native alarm feeds (source replays a snapshot).
ReSubscribeAllAlarms();
BecomeConnected();
}
@@ -1406,6 +1456,160 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_healthCollector.UpdateTagQuality(_connectionName, _tagsGoodQuality, _tagsBadQuality, _tagsUncertainQuality);
}
// ── Native alarm subscriptions (Task-10) ──
private void HandleSubscribeAlarms(SubscribeAlarmsRequest request)
{
var subscriber = Sender;
var now = DateTimeOffset.UtcNow;
if (_adapter is not IAlarmSubscribableConnection alarmable)
{
subscriber.Tell(new SubscribeAlarmsResponse(
request.CorrelationId, request.InstanceUniqueName, false,
$"Connection '{_connectionName}' is not alarm-capable.", now));
return;
}
// Register the subscriber for routing (idempotent) before issuing the
// adapter subscribe so a transition that arrives mid-subscribe is routed.
if (!_alarmSourceSubscribers.TryGetValue(request.SourceReference, out var subs))
{
subs = new HashSet<IActorRef>();
_alarmSourceSubscribers[request.SourceReference] = subs;
}
subs.Add(subscriber);
_alarmSourceFilter[request.SourceReference] = request.ConditionFilter;
// If the adapter feed for this source is already (being) established, the
// existing subscription serves the new subscriber too.
if (_alarmSubscriptionIds.ContainsKey(request.SourceReference) ||
_alarmSubscribesInFlight.Contains(request.SourceReference))
{
subscriber.Tell(new SubscribeAlarmsResponse(
request.CorrelationId, request.InstanceUniqueName, true, null, now));
return;
}
_alarmSubscribesInFlight.Add(request.SourceReference);
var self = Self;
var generation = _adapterGeneration;
var sourceRef = request.SourceReference;
var filter = request.ConditionFilter;
var corr = request.CorrelationId;
var inst = request.InstanceUniqueName;
alarmable.SubscribeAlarmsAsync(sourceRef, filter,
t => self.Tell(new AlarmTransitionReceived(t, generation)))
.ContinueWith(task => task.IsCompletedSuccessfully
? new AlarmSubscribeCompleted(sourceRef, true, task.Result, null, subscriber, corr, inst) as object
: new AlarmSubscribeCompleted(sourceRef, false, null,
task.Exception?.GetBaseException().Message ?? "Unknown error", subscriber, corr, inst))
.PipeTo(self);
}
private void HandleAlarmSubscribeCompleted(AlarmSubscribeCompleted msg)
{
_alarmSubscribesInFlight.Remove(msg.SourceReference);
if (msg.Success && msg.SubscriptionId != null)
{
_alarmSubscriptionIds[msg.SourceReference] = msg.SubscriptionId;
_log.Info("[{0}] Alarm feed subscribed for source {1}", _connectionName, msg.SourceReference);
}
else if (!msg.Success)
{
_log.Warning("[{0}] Alarm subscribe failed for source {1}: {2}",
_connectionName, msg.SourceReference, msg.Error);
}
// ReplyTo is null for reconnect re-subscribes (no original requester to answer).
msg.ReplyTo?.Tell(new SubscribeAlarmsResponse(
msg.CorrelationId ?? string.Empty, msg.InstanceUniqueName ?? string.Empty,
msg.Success, msg.Error, DateTimeOffset.UtcNow));
}
private void HandleAlarmTransitionReceived(AlarmTransitionReceived msg)
{
// DataConnectionLayer-011: drop transitions from a disposed adapter after failover.
if (msg.AdapterGeneration != _adapterGeneration)
return;
var transition = msg.Transition;
var notified = new HashSet<IActorRef>();
foreach (var (sourceRef, subs) in _alarmSourceSubscribers)
{
// A subscriber bound to source S receives a transition whose source
// object (or full reference) falls under S.
var match = transition.SourceObjectReference.StartsWith(sourceRef, StringComparison.Ordinal)
|| transition.SourceReference.StartsWith(sourceRef, StringComparison.Ordinal);
if (!match)
continue;
foreach (var sub in subs)
{
if (notified.Add(sub))
sub.Tell(new NativeAlarmTransitionUpdate(_connectionName, transition));
}
}
}
private void HandleUnsubscribeAlarms(UnsubscribeAlarmsRequest request)
{
if (!_alarmSourceSubscribers.TryGetValue(request.SourceReference, out var subs))
return;
subs.Remove(Sender);
if (subs.Count > 0)
return;
// No subscribers remain for this source — tear down the adapter feed.
_alarmSourceSubscribers.Remove(request.SourceReference);
_alarmSourceFilter.Remove(request.SourceReference);
if (_alarmSubscriptionIds.Remove(request.SourceReference, out var subId) &&
_adapter is IAlarmSubscribableConnection alarmable)
{
_ = alarmable.UnsubscribeAlarmsAsync(subId);
}
}
/// <summary>Re-establishes all native alarm feeds after a reconnect; the source replays a snapshot.</summary>
private void ReSubscribeAllAlarms()
{
if (_adapter is not IAlarmSubscribableConnection alarmable || _alarmSourceSubscribers.Count == 0)
return;
_alarmSubscriptionIds.Clear();
_alarmSubscribesInFlight.Clear();
var self = Self;
var generation = _adapterGeneration;
foreach (var sourceRef in _alarmSourceSubscribers.Keys.ToList())
{
var sr = sourceRef;
var filter = _alarmSourceFilter.GetValueOrDefault(sourceRef);
_alarmSubscribesInFlight.Add(sr);
alarmable.SubscribeAlarmsAsync(sr, filter,
t => self.Tell(new AlarmTransitionReceived(t, generation)))
.ContinueWith(task => task.IsCompletedSuccessfully
? new AlarmSubscribeCompleted(sr, true, task.Result, null, null, null, null) as object
: new AlarmSubscribeCompleted(sr, false, null,
task.Exception?.GetBaseException().Message ?? "Unknown error", null, null, null))
.PipeTo(self);
}
}
/// <summary>Notifies alarm subscribers that the source feed is unavailable (connection lost).</summary>
private void PushAlarmSourceUnavailable()
{
var now = DateTimeOffset.UtcNow;
foreach (var (sourceRef, subs) in _alarmSourceSubscribers)
{
foreach (var sub in subs)
sub.Tell(new NativeAlarmSourceUnavailable(_connectionName, sourceRef, now));
}
}
// ── Internal messages ──
internal record AttemptConnect;
@@ -1420,5 +1624,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
bool ConnectionLevelFailure = false);
internal record SubscribeCompleted(
SubscribeTagsRequest Request, IActorRef ReplyTo, IReadOnlyList<SubscribeTagResult> Results);
internal record AlarmTransitionReceived(NativeAlarmTransition Transition, int AdapterGeneration);
internal record AlarmSubscribeCompleted(
string SourceReference, bool Success, string? SubscriptionId, string? Error,
IActorRef? ReplyTo, string? CorrelationId, string? InstanceUniqueName);
public record GetHealthReport;
}
@@ -43,6 +43,8 @@ public class DataConnectionManagerActor : ReceiveActor
Receive<CreateConnectionCommand>(HandleCreateConnection);
Receive<SubscribeTagsRequest>(HandleRoute);
Receive<UnsubscribeTagsRequest>(HandleRoute);
Receive<SubscribeAlarmsRequest>(HandleRouteAlarms);
Receive<UnsubscribeAlarmsRequest>(HandleRouteAlarms);
Receive<WriteTagRequest>(HandleRouteWrite);
Receive<RemoveConnectionCommand>(HandleRemoveConnection);
Receive<GetAllHealthReports>(HandleGetAllHealthReports);
@@ -101,6 +103,31 @@ public class DataConnectionManagerActor : ReceiveActor
_log.Warning("No connection actor for {0} during unsubscribe", request.ConnectionName);
}
/// <summary>
/// Routes a native alarm subscribe to the <see cref="DataConnectionActor"/> that owns
/// the named connection (the NativeAlarmActor sends here, not to the child directly).
/// </summary>
private void HandleRouteAlarms(SubscribeAlarmsRequest request)
{
if (_connectionActors.TryGetValue(request.ConnectionName, out var actor))
actor.Forward(request);
else
{
_log.Warning("No connection actor for {0} during alarm subscribe", request.ConnectionName);
Sender.Tell(new SubscribeAlarmsResponse(
request.CorrelationId, request.InstanceUniqueName, false,
$"Unknown connection: {request.ConnectionName}", DateTimeOffset.UtcNow));
}
}
private void HandleRouteAlarms(UnsubscribeAlarmsRequest request)
{
if (_connectionActors.TryGetValue(request.ConnectionName, out var actor))
actor.Forward(request);
else
_log.Warning("No connection actor for {0} during alarm unsubscribe", request.ConnectionName);
}
private void HandleRouteWrite(WriteTagRequest request)
{
if (_connectionActors.TryGetValue(request.ConnectionName, out var actor))
@@ -1,4 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
@@ -69,6 +70,17 @@ public interface IMxGatewayClient : IAsyncDisposable
/// <param name="onUpdate">Callback invoked per advised-tag value change.</param>
/// <param name="ct">Cancellation token; ends the loop when cancelled.</param>
Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default);
/// <summary>
/// Long-running consumer of the gateway's session-less StreamAlarms feed. Emits a
/// Snapshot…SnapshotComplete replay of active alarms then live transitions. Re-opens
/// the stream internally on transport faults (the source replays a fresh snapshot).
/// Completes only when <paramref name="ct"/> is cancelled.
/// </summary>
/// <param name="alarmFilterPrefix">Optional source-reference prefix to scope the feed; null = gateway-wide.</param>
/// <param name="onTransition">Callback invoked per native alarm transition.</param>
/// <param name="ct">Cancellation token; ends the loop when cancelled.</param>
Task RunAlarmStreamAsync(string? alarmFilterPrefix, Action<NativeAlarmTransition> onTransition, CancellationToken ct = default);
}
/// <summary>Builds <see cref="IMxGatewayClient"/> instances.</summary>
@@ -1,4 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
@@ -81,6 +82,22 @@ public interface IOpcUaClient : IAsyncDisposable
/// <returns>A task representing the asynchronous operation.</returns>
Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to OPC UA Alarms &amp; Conditions events under
/// <paramref name="sourceNodeId"/> (or the Server object when null). On
/// (re)subscribe the adapter issues a ConditionRefresh and replays the
/// active conditions as Snapshot…SnapshotComplete transitions. Returns a
/// handle for <see cref="RemoveAlarmSubscriptionAsync"/>.
/// </summary>
Task<string> CreateAlarmSubscriptionAsync(
string? sourceNodeId,
string? conditionFilter,
Action<NativeAlarmTransition> onTransition,
CancellationToken cancellationToken = default);
/// <summary>Removes an alarm-event subscription by handle.</summary>
Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default);
/// <summary>
/// Reads the current value of a node.
/// </summary>
@@ -182,6 +199,19 @@ internal class StubOpcUaClient : IOpcUaClient
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<string> CreateAlarmSubscriptionAsync(
string? sourceNodeId, string? conditionFilter,
Action<NativeAlarmTransition> onTransition, CancellationToken cancellationToken = default)
{
// Stub: no events. Real A&C subscription lives in RealOpcUaClient.
return Task.FromResult(Guid.NewGuid().ToString());
}
/// <inheritdoc />
public Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)
@@ -0,0 +1,102 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ProtoConditionState = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState;
using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind;
// Alias the Commons alarm types so their simple names bind here, unambiguous
// against the colliding gateway proto enums above.
using NativeAlarmTransition = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition;
using AlarmConditionState = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.AlarmConditionState;
using AlarmTransitionKind = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTransitionKind;
using AlarmShelveState = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmShelveState;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// Pure mapping from MxAccess Gateway alarm-feed proto messages to the
/// protocol-neutral <see cref="NativeAlarmTransition"/> shape. The gateway proto
/// enums (AlarmConditionState / AlarmTransitionKind) collide with the Commons
/// alarm enums, so they are aliased here. Unit-tested without a live gateway.
/// </summary>
public static class MxGatewayAlarmMapper
{
/// <summary>Clamps the gateway severity onto the unified 01000 scale.</summary>
public static int NormalizeSeverity(int severity) => Math.Clamp(severity, 0, 1000);
/// <summary>Maps a gateway condition-state + severity to the unified condition.</summary>
public static AlarmConditionState MapConditionState(ProtoConditionState state, int severity)
{
var (active, acked) = state switch
{
ProtoConditionState.Active => (true, false),
ProtoConditionState.ActiveAcked => (true, true),
ProtoConditionState.Inactive => (false, true),
_ => (false, true)
};
return new AlarmConditionState(active, acked, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
}
/// <summary>Maps a gateway transition kind to the unified transition kind.</summary>
public static AlarmTransitionKind MapKind(ProtoTransitionKind kind) => kind switch
{
ProtoTransitionKind.Raise => AlarmTransitionKind.Raise,
ProtoTransitionKind.Acknowledge => AlarmTransitionKind.Acknowledge,
ProtoTransitionKind.Clear => AlarmTransitionKind.Clear,
ProtoTransitionKind.Retrigger => AlarmTransitionKind.Retrigger,
_ => AlarmTransitionKind.StateChange
};
/// <summary>Derives the mirrored condition from a transition kind + severity.</summary>
private static AlarmConditionState ConditionFromKind(ProtoTransitionKind kind, int severity)
{
var (active, acked) = kind switch
{
ProtoTransitionKind.Raise => (true, false),
ProtoTransitionKind.Acknowledge => (true, true),
ProtoTransitionKind.Retrigger => (true, false),
ProtoTransitionKind.Clear => (false, true),
_ => (false, true)
};
return new AlarmConditionState(active, acked, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
}
/// <summary>Maps a live <see cref="OnAlarmTransitionEvent"/> to a transition.</summary>
public static NativeAlarmTransition MapTransition(OnAlarmTransitionEvent body) => new(
SourceReference: body.AlarmFullReference,
SourceObjectReference: body.SourceObjectReference,
AlarmTypeName: body.AlarmTypeName,
Kind: MapKind(body.TransitionKind),
Condition: ConditionFromKind(body.TransitionKind, body.Severity),
Category: body.Category,
Description: body.Description,
Message: body.Description,
OperatorUser: body.OperatorUser,
OperatorComment: body.OperatorComment,
OriginalRaiseTime: body.OriginalRaiseTimestamp?.ToDateTimeOffset(),
TransitionTime: body.TransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
CurrentValue: "",
LimitValue: "");
/// <summary>The end-of-snapshot sentinel transition (no condition payload).</summary>
public static NativeAlarmTransition SnapshotComplete() => new(
"", "", "", AlarmTransitionKind.SnapshotComplete,
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
/// <summary>Maps one initial-snapshot <see cref="ActiveAlarmSnapshot"/> entry to a Snapshot transition.</summary>
public static NativeAlarmTransition MapSnapshot(ActiveAlarmSnapshot snapshot) => new(
SourceReference: snapshot.AlarmFullReference,
SourceObjectReference: snapshot.SourceObjectReference,
AlarmTypeName: snapshot.AlarmTypeName,
Kind: AlarmTransitionKind.Snapshot,
Condition: MapConditionState(snapshot.CurrentState, snapshot.Severity),
Category: snapshot.Category,
Description: snapshot.Description,
Message: snapshot.Description,
OperatorUser: snapshot.OperatorUser,
OperatorComment: snapshot.OperatorComment,
OriginalRaiseTime: snapshot.OriginalRaiseTimestamp?.ToDateTimeOffset(),
TransitionTime: snapshot.LastTransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
CurrentValue: "",
LimitValue: "");
}
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <see cref="Disconnected"/>, the actor disposes this adapter, creates a fresh one,
/// reconnects and re-subscribes all tags.
/// </summary>
public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection
{
private readonly IMxGatewayClientFactory _clientFactory;
private readonly ILogger<MxGatewayDataConnection> _logger;
@@ -28,6 +28,15 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
private ConnectionHealth _status = ConnectionHealth.Disconnected;
private CancellationTokenSource? _eventLoopCts;
// Native alarm feed: the gateway StreamAlarms RPC is session-less and
// gateway-wide, so one shared feed serves the whole connection. The
// DataConnectionActor routes transitions to instances by source reference,
// so a single shared callback (the first registered) suffices; subscriptions
// are ref-counted so the feed stops when the last one is removed.
private CancellationTokenSource? _alarmCts;
private int _alarmSubCount;
private readonly object _alarmLock = new();
// subscriptionId → (tagPath, callback) so the event loop can route updates by tag,
// plus tagPath → subscriptionId for reverse lookup. Concurrent because the event
// loop reads from a background thread while Subscribe/Unsubscribe mutate.
@@ -112,6 +121,13 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
_eventLoopCts?.Cancel();
lock (_alarmLock)
{
_alarmCts?.Cancel();
_alarmCts?.Dispose();
_alarmCts = null;
_alarmSubCount = 0;
}
if (_client is not null)
await _client.DisconnectAsync(cancellationToken);
_status = ConnectionHealth.Disconnected;
@@ -134,6 +150,43 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
}
/// <inheritdoc />
public Task<string> SubscribeAlarmsAsync(
string sourceReference, string? conditionFilter,
AlarmTransitionCallback callback, CancellationToken cancellationToken = default)
{
lock (_alarmLock)
{
_alarmSubCount++;
if (_alarmCts == null)
{
_alarmCts = new CancellationTokenSource();
var token = _alarmCts.Token;
var client = _client!;
// Gateway-wide feed (null prefix); the actor filters per source reference.
_ = Task.Run(() => client.RunAlarmStreamAsync(null, t => callback(t), token), token);
}
}
return Task.FromResult(Guid.NewGuid().ToString());
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
lock (_alarmLock)
{
if (_alarmSubCount > 0)
_alarmSubCount--;
if (_alarmSubCount == 0)
{
_alarmCts?.Cancel();
_alarmCts?.Dispose();
_alarmCts = null;
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
{
@@ -0,0 +1,52 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// Pure mapping helpers turning OPC UA Alarms &amp; Conditions event fields into the
/// protocol-neutral <see cref="AlarmConditionState"/> / transition shape. Kept
/// free of any OPC UA SDK types so it is unit-testable without a live server;
/// the SDK field extraction lives in <c>RealOpcUaClient</c> and is exercised by
/// the live smoke test (Task 28).
/// </summary>
public static class OpcUaAlarmMapper
{
/// <summary>Clamps an OPC UA severity (11000, sometimes out of range) to the unified 01000 scale.</summary>
public static int NormalizeSeverity(int severity) => Math.Clamp(severity, 0, 1000);
/// <summary>Builds an <see cref="AlarmConditionState"/> from the orthogonal A&amp;C sub-states.</summary>
public static AlarmConditionState BuildCondition(
bool active, bool acked, bool? confirmed, AlarmShelveState shelve, bool suppressed, int severity) =>
new(Active: active, Acknowledged: acked, Confirmed: confirmed,
Shelve: shelve, Suppressed: suppressed, Severity: NormalizeSeverity(severity));
/// <summary>
/// Derives the transition kind from the change in active/acked sub-states.
/// Acknowledgement takes precedence over an active/inactive edge when both
/// change in the same event; an unchanged event is reported as a StateChange.
/// </summary>
public static AlarmTransitionKind DeriveKind(bool prevAcked, bool nowAcked, bool prevActive, bool nowActive)
{
if (!prevAcked && nowAcked)
return AlarmTransitionKind.Acknowledge;
if (!prevActive && nowActive)
return AlarmTransitionKind.Raise;
if (prevActive && !nowActive)
return AlarmTransitionKind.Clear;
if (prevActive && nowActive)
return AlarmTransitionKind.Retrigger;
return AlarmTransitionKind.StateChange;
}
/// <summary>Maps the OPC UA ShelvingState current-state node name to the shelve enum.</summary>
public static AlarmShelveState MapShelve(string? shelvingStateName) => shelvingStateName switch
{
"OneShotShelved" => AlarmShelveState.OneShotShelved,
"TimedShelved" => AlarmShelveState.TimedShelved,
// OPC UA does not expose a distinct "permanent" shelve; treat any other
// shelved name as one-shot and "Unshelved"/null as unshelved.
null or "Unshelved" => AlarmShelveState.Unshelved,
_ => AlarmShelveState.OneShotShelved
};
}
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// - Read/Write → Read/Write service calls
/// - Quality → OPC UA StatusCode mapping
/// </summary>
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection
{
private readonly IOpcUaClientFactory _clientFactory;
private readonly ILogger<OpcUaDataConnection> _logger;
@@ -174,6 +174,27 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection
cancellationToken);
}
/// <inheritdoc />
public async Task<string> SubscribeAlarmsAsync(
string sourceReference, string? conditionFilter,
AlarmTransitionCallback callback, CancellationToken cancellationToken = default)
{
EnsureConnected();
// The client maps OPC UA A&C event fields → NativeAlarmTransition via
// OpcUaAlarmMapper and replays a snapshot on (re)subscribe.
return await _client!.CreateAlarmSubscriptionAsync(
sourceReference, conditionFilter,
transition => callback(transition),
cancellationToken);
}
/// <inheritdoc />
public async Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
if (_client != null)
await _client.RemoveAlarmSubscriptionAsync(subscriptionId, cancellationToken);
}
/// <inheritdoc />
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
@@ -246,6 +246,61 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
}
}
/// <inheritdoc />
public async Task RunAlarmStreamAsync(
string? alarmFilterPrefix, Action<Commons.Types.Alarms.NativeAlarmTransition> onTransition,
CancellationToken ct = default)
{
var reconnectDelay = TimeSpan.FromSeconds(5);
while (!ct.IsCancellationRequested)
{
try
{
var request = new StreamAlarmsRequest
{
ClientCorrelationId = Guid.NewGuid().ToString("N"),
AlarmFilterPrefix = alarmFilterPrefix ?? string.Empty,
};
await foreach (var message in _client!.StreamAlarmsAsync(request, ct)
.WithCancellation(ct).ConfigureAwait(false))
{
if (ct.IsCancellationRequested) break;
switch (message.PayloadCase)
{
case AlarmFeedMessage.PayloadOneofCase.ActiveAlarm:
onTransition(MxGatewayAlarmMapper.MapSnapshot(message.ActiveAlarm));
break;
case AlarmFeedMessage.PayloadOneofCase.Transition:
onTransition(MxGatewayAlarmMapper.MapTransition(message.Transition));
break;
case AlarmFeedMessage.PayloadOneofCase.SnapshotComplete:
onTransition(MxGatewayAlarmMapper.SnapshotComplete());
break;
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return; // clean shutdown
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"MxGateway alarm stream faulted; reopening in {DelaySeconds}s", reconnectDelay.TotalSeconds);
}
try
{
await Task.Delay(reconnectDelay, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
@@ -5,6 +5,8 @@ using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
@@ -24,6 +26,13 @@ public class RealOpcUaClient : IOpcUaClient
// Clear() is undefined behaviour, so they must be ConcurrentDictionary.
private readonly ConcurrentDictionary<string, MonitoredItem> _monitoredItems = new();
private readonly ConcurrentDictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
// Task-11: native alarm (A&C) event subscriptions, keyed by handle.
private readonly ConcurrentDictionary<string, MonitoredItem> _alarmItems = new();
// Per-handle "currently inside a ConditionRefresh replay" flag → Snapshot kind.
private readonly ConcurrentDictionary<string, bool> _alarmInRefresh = new();
// Per-handle last (active, acked) by source reference, to derive transition kind.
private readonly ConcurrentDictionary<string, Dictionary<string, (bool Active, bool Acked)>> _alarmLastState = new();
// DataConnectionLayer-013: int flag toggled with Interlocked.Exchange so the
// once-only ConnectionLost guard in OnSessionKeepAlive is atomic, not just visible.
// 0 = not fired, 1 = fired.
@@ -220,6 +229,185 @@ public class RealOpcUaClient : IOpcUaClient
}
}
// ── Native alarm (Alarms & Conditions) subscription (Task-11) ──
// Behavioral correctness verified against a live A&C server in Task 28; only
// the OpcUaAlarmMapper value→state logic is unit-tested.
// Fixed select-clause order; parsed by index in HandleAlarmEvent.
private static readonly string[] AlarmStateFields =
["EventType", "SourceNode", "SourceName", "Time", "Message", "Severity"];
/// <inheritdoc />
public async Task<string> CreateAlarmSubscriptionAsync(
string? sourceNodeId, string? conditionFilter,
Action<NativeAlarmTransition> onTransition, CancellationToken cancellationToken = default)
{
if (_subscription == null || _session == null)
throw new InvalidOperationException("Not connected.");
var handle = Guid.NewGuid().ToString();
_alarmInRefresh[handle] = false;
_alarmLastState[handle] = new Dictionary<string, (bool, bool)>(StringComparer.Ordinal);
var startNode = string.IsNullOrEmpty(sourceNodeId) ? ObjectIds.Server : NodeId.Parse(sourceNodeId);
var item = new MonitoredItem(_subscription.DefaultItem)
{
DisplayName = $"alarm:{sourceNodeId ?? "Server"}",
StartNodeId = startNode,
AttributeId = Attributes.EventNotifier,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = 0,
QueueSize = 1000,
Filter = BuildAlarmEventFilter()
};
item.Notification += (_, e) =>
{
if (e.NotificationValue is EventFieldList efl)
HandleAlarmEvent(handle, efl, onTransition);
};
_subscription.AddItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
_alarmItems[handle] = item;
// Replay currently-active conditions as a Snapshot…SnapshotComplete sequence.
await TriggerConditionRefreshAsync(handle, cancellationToken);
return handle;
}
/// <inheritdoc />
public async Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
{
if (_subscription != null && _alarmItems.TryRemove(subscriptionHandle, out var item))
{
_subscription.RemoveItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
}
_alarmInRefresh.TryRemove(subscriptionHandle, out _);
_alarmLastState.TryRemove(subscriptionHandle, out _);
}
/// <summary>
/// Builds the event filter selecting the base event fields plus the
/// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror.
/// </summary>
private static EventFilter BuildAlarmEventFilter()
{
var filter = new EventFilter();
foreach (var name in AlarmStateFields)
filter.SelectClauses.Add(SelectField(ObjectTypeIds.BaseEventType, name));
// Two-state sub-condition /Id booleans + shelving current-state + identity.
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "Id")); // 6
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "Id")); // 7
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "ConfirmedState", "Id"));// 8
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "SuppressedState", "Id")); // 9
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ShelvingState", "CurrentState"));// 10
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
return filter;
}
private static SimpleAttributeOperand SelectField(NodeId typeDefinitionId, params string[] browse)
{
var path = new QualifiedNameCollection();
foreach (var b in browse)
path.Add(new QualifiedName(b));
return new SimpleAttributeOperand
{
TypeDefinitionId = typeDefinitionId,
BrowsePath = path,
AttributeId = Attributes.Value
};
}
private async Task TriggerConditionRefreshAsync(string handle, CancellationToken cancellationToken)
{
try
{
// ConditionRefresh replays active conditions; RefreshStart/End events
// bracket the replay so HandleAlarmEvent can mark them Snapshot.
await _session!.CallAsync(
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh,
cancellationToken, _subscription!.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ConditionRefresh failed for alarm subscription {Handle}", handle);
}
}
private void HandleAlarmEvent(string handle, EventFieldList efl, Action<NativeAlarmTransition> onTransition)
{
var fields = efl.EventFields;
if (fields == null || fields.Count < AlarmStateFields.Length)
return;
var eventType = fields[0].Value as NodeId;
// RefreshStart/End bracket the snapshot replay.
if (eventType == ObjectTypeIds.RefreshStartEventType)
{
_alarmInRefresh[handle] = true;
return;
}
if (eventType == ObjectTypeIds.RefreshEndEventType)
{
_alarmInRefresh[handle] = false;
onTransition(SnapshotComplete());
return;
}
var sourceName = fields[1].Value is NodeId ? (fields[2].Value as string ?? "") : (fields[2].Value as string ?? "");
var conditionName = fields.Count > 11 ? fields[11].Value as string : null;
var sourceObjectRef = sourceName;
var sourceRef = string.IsNullOrEmpty(conditionName) ? sourceName : $"{sourceName}.{conditionName}";
if (string.IsNullOrEmpty(sourceRef))
return; // not a condition event we can key
var time = fields[3].Value is DateTime dt ? new DateTimeOffset(dt, TimeSpan.Zero) : DateTimeOffset.UtcNow;
var message = (fields[4].Value as LocalizedText)?.Text ?? "";
var severity = fields[5].Value is null ? 0 : Convert.ToInt32(fields[5].Value);
var active = fields.Count > 6 && fields[6].Value is bool a && a;
var acked = fields.Count <= 7 || fields[7].Value is not bool ak || ak; // default acked when absent
bool? confirmed = fields.Count > 8 && fields[8].Value is bool cf ? cf : null;
var suppressed = fields.Count > 9 && fields[9].Value is bool sp && sp;
var shelve = OpcUaAlarmMapper.MapShelve(fields.Count > 10 ? (fields[10].Value as LocalizedText)?.Text : null);
var comment = fields.Count > 12 ? (fields[12].Value as LocalizedText)?.Text ?? "" : "";
var inRefresh = _alarmInRefresh.GetValueOrDefault(handle);
var lastState = _alarmLastState.GetValueOrDefault(handle);
var (prevActive, prevAcked) = lastState != null && lastState.TryGetValue(sourceRef, out var prev) ? prev : (false, true);
var kind = inRefresh
? AlarmTransitionKind.Snapshot
: OpcUaAlarmMapper.DeriveKind(prevAcked, acked, prevActive, active);
lastState?.TryAdd(sourceRef, (active, acked));
if (lastState != null) lastState[sourceRef] = (active, acked);
onTransition(new NativeAlarmTransition(
SourceReference: sourceRef,
SourceObjectReference: sourceObjectRef,
AlarmTypeName: eventType?.ToString() ?? "",
Kind: kind,
Condition: OpcUaAlarmMapper.BuildCondition(active, acked, confirmed, shelve, suppressed, severity),
Category: "",
Description: "",
Message: message,
OperatorUser: "",
OperatorComment: comment,
OriginalRaiseTime: null,
TransitionTime: time,
CurrentValue: "",
LimitValue: ""));
}
private static NativeAlarmTransition SnapshotComplete() => new(
"", "", "", AlarmTransitionKind.SnapshotComplete,
new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
/// <inheritdoc />
public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)
@@ -1,45 +0,0 @@
using Akka.Cluster;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
/// <summary>
/// Health check that returns healthy only if this node is the active (leader) node
/// in the Akka.NET cluster. Used by Traefik to route traffic to the active node.
/// </summary>
public class ActiveNodeHealthCheck : IHealthCheck
{
private readonly AkkaHostedService _akkaService;
/// <summary>Initializes a new <see cref="ActiveNodeHealthCheck"/> with the given Akka hosted service.</summary>
/// <param name="akkaService">The Akka hosted service providing access to the actor system and cluster state.</param>
public ActiveNodeHealthCheck(AkkaHostedService akkaService)
{
_akkaService = akkaService;
}
/// <summary>Returns healthy if this node is the cluster leader (active node); otherwise returns unhealthy.</summary>
/// <param name="context">Health check context providing registration details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var system = _akkaService.ActorSystem;
if (system == null)
return Task.FromResult(HealthCheckResult.Unhealthy("ActorSystem not yet available."));
var cluster = Cluster.Get(system);
var self = cluster.SelfMember;
if (self.Status != MemberStatus.Up)
return Task.FromResult(HealthCheckResult.Unhealthy($"Node not Up (status: {self.Status})."));
var leader = cluster.State.Leader;
if (leader != null && leader == self.Address)
return Task.FromResult(HealthCheckResult.Healthy("Active node (cluster leader)."));
return Task.FromResult(HealthCheckResult.Unhealthy("Standby node (not cluster leader)."));
}
}

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