Compare commits
41 Commits
4881f9c23c
...
f428804bef
| Author | SHA1 | Date | |
|---|---|---|---|
| f428804bef | |||
| 9afcdc996e | |||
| add7210d9e | |||
| f4ae44aacc | |||
| 27d5701d99 | |||
| d24c00ec2c | |||
| 003e54c1fb | |||
| 2b7c765a58 | |||
| 046797e699 | |||
| b03ab11d8a | |||
| 60f8e2c9a7 | |||
| 1f6c4207df | |||
| a6dcbf62cd | |||
| c4cea9eea5 | |||
| 3bf1d26d79 | |||
| b1df6d5beb | |||
| 0c6f9a9cff | |||
| 50176765fe | |||
| 20b41b8965 | |||
| bca21ffb95 | |||
| 6d318586d1 | |||
| 376dac4895 | |||
| fda7ac9c50 | |||
| 24fd7bee53 | |||
| b44a844152 | |||
| 3650a89fcd | |||
| c7411700dc | |||
| 0d30b7dec0 | |||
| 1fbb814daa | |||
| d3b3d15018 | |||
| ba278736af | |||
| e5392d2c7b | |||
| fc05ba1f6a | |||
| aedd17ca7f | |||
| 63f1ec282f | |||
| 913441972e | |||
| ea14ace150 | |||
| edc2dacf6c | |||
| 696da92c3a | |||
| 09e19db4e9 | |||
| dadebbe227 |
@@ -99,6 +99,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 + 0–1000 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.
|
||||
|
||||
@@ -42,11 +42,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 | Central–Site 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 | Central–Site 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 +57,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 +67,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -760,7 +760,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 +886,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
|
||||
|
||||
|
||||
@@ -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 1–1000 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) // 0–1000, 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 0–1000; 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 0–1000. **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)"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 **0–1000 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).
|
||||
|
||||
@@ -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 0–1000 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,
|
||||
|
||||
@@ -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 1–7 are unchanged, and fields 8–21 carry the enriched native-alarm state. Old clients that only read fields 1–7 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 8–21 — 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 8–21 followed this process.
|
||||
|
||||
#### gRPC Connection Keepalive
|
||||
|
||||
Three layers of dead-client detection prevent orphan streams on site nodes:
|
||||
|
||||
@@ -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:
|
||||
@@ -248,6 +270,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
|
||||
|
||||
@@ -173,6 +173,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` (0–1000) |
|
||||
| `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 +297,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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -34,9 +34,10 @@ Deployment Manager Singleton (Cluster Singleton)
|
||||
│ │ └── Script Execution Actor — short-lived, per invocation
|
||||
│ ├── Script Actor ("CalculateOEE") — coordinator
|
||||
│ │ └── Script Execution Actor — short-lived, per invocation
|
||||
│ ├── Alarm Actor ("OverTemp") — coordinator
|
||||
│ ├── Alarm Actor ("OverTemp") — coordinator (computed)
|
||||
│ │ └── Alarm Execution Actor — short-lived, per on-trigger invocation
|
||||
│ └── Alarm Actor ("LowPressure") — coordinator
|
||||
│ ├── Alarm Actor ("LowPressure") — coordinator (computed)
|
||||
│ └── Native Alarm Actor ("OpcUaServer1") — read-only mirror, peer to Alarm Actor
|
||||
├── Instance Actor ("MachineA-002")
|
||||
│ └── ...
|
||||
└── ...
|
||||
@@ -204,6 +205,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 +430,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 **0–1000 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.
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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",
|
||||
|
||||
+196
@@ -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 & 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.
|
||||
|
||||
+32
@@ -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&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 & 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);
|
||||
}
|
||||
+24
@@ -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&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 0–1000 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 0–1000 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&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 & 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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>
|
||||
|
||||
+35
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1776
File diff suppressed because it is too large
Load Diff
+86
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -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
|
||||
|
||||
+86
@@ -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 & 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 0–1000 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 & 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 (1–1000, sometimes out of range) to the unified 0–1000 scale.</summary>
|
||||
public static int NormalizeSeverity(int severity) => Math.Clamp(severity, 0, 1000);
|
||||
|
||||
/// <summary>Builds an <see cref="AlarmConditionState"/> from the orthogonal A&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)
|
||||
|
||||
@@ -183,6 +183,7 @@ public class ManagementActor : ReceiveActor
|
||||
or DeleteDataConnectionCommand
|
||||
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
|
||||
or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
|
||||
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
|
||||
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
|
||||
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
|
||||
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
|
||||
@@ -204,6 +205,7 @@ public class ManagementActor : ReceiveActor
|
||||
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
|
||||
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
||||
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
|
||||
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
|
||||
or GetDeploymentDiffCommand
|
||||
or MgmtDeployArtifactsCommand
|
||||
or QueryDeploymentsCommand
|
||||
@@ -233,6 +235,10 @@ public class ManagementActor : ReceiveActor
|
||||
AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user.Username),
|
||||
UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user.Username),
|
||||
DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user.Username),
|
||||
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd),
|
||||
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd),
|
||||
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd),
|
||||
ListTemplateNativeAlarmSourcesCommand cmd => await HandleListNativeAlarmSources(sp, cmd),
|
||||
AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user.Username),
|
||||
UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user.Username),
|
||||
DeleteTemplateScriptCommand cmd => await HandleDeleteScript(sp, cmd, user.Username),
|
||||
@@ -261,6 +267,9 @@ public class ManagementActor : ReceiveActor
|
||||
SetInstanceAlarmOverrideCommand cmd => await HandleSetInstanceAlarmOverride(sp, cmd, user),
|
||||
DeleteInstanceAlarmOverrideCommand cmd => await HandleDeleteInstanceAlarmOverride(sp, cmd, user),
|
||||
ListInstanceAlarmOverridesCommand cmd => await HandleListInstanceAlarmOverrides(sp, cmd, user),
|
||||
SetInstanceNativeAlarmSourceOverrideCommand cmd => await HandleSetInstanceNativeAlarmSourceOverride(sp, cmd, user),
|
||||
DeleteInstanceNativeAlarmSourceOverrideCommand cmd => await HandleDeleteInstanceNativeAlarmSourceOverride(sp, cmd, user),
|
||||
ListInstanceNativeAlarmSourceOverridesCommand cmd => await HandleListInstanceNativeAlarmSourceOverrides(sp, cmd, user),
|
||||
|
||||
// Sites
|
||||
ListSitesCommand => await HandleListSites(sp, user),
|
||||
@@ -765,6 +774,58 @@ public class ManagementActor : ReceiveActor
|
||||
return await repo.GetAlarmOverridesByInstanceIdAsync(cmd.InstanceId);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleSetInstanceNativeAlarmSourceOverride(
|
||||
IServiceProvider sp, SetInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
|
||||
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, cmd.SourceCanonicalName);
|
||||
if (existing == null)
|
||||
{
|
||||
var ovr = new InstanceNativeAlarmSourceOverride(cmd.SourceCanonicalName)
|
||||
{
|
||||
InstanceId = cmd.InstanceId,
|
||||
ConnectionNameOverride = cmd.ConnectionNameOverride,
|
||||
SourceReferenceOverride = cmd.SourceReferenceOverride,
|
||||
ConditionFilterOverride = cmd.ConditionFilterOverride
|
||||
};
|
||||
await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
|
||||
await repo.SaveChangesAsync();
|
||||
return ovr;
|
||||
}
|
||||
|
||||
existing.ConnectionNameOverride = cmd.ConnectionNameOverride;
|
||||
existing.SourceReferenceOverride = cmd.SourceReferenceOverride;
|
||||
existing.ConditionFilterOverride = cmd.ConditionFilterOverride;
|
||||
await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
|
||||
await repo.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteInstanceNativeAlarmSourceOverride(
|
||||
IServiceProvider sp, DeleteInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
|
||||
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, cmd.SourceCanonicalName);
|
||||
if (existing != null)
|
||||
{
|
||||
await repo.DeleteInstanceNativeAlarmSourceOverrideAsync(existing.Id);
|
||||
await repo.SaveChangesAsync();
|
||||
}
|
||||
return cmd.SourceCanonicalName;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleListInstanceNativeAlarmSourceOverrides(
|
||||
IServiceProvider sp, ListInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
return await repo.GetNativeAlarmSourceOverridesByInstanceIdAsync(cmd.InstanceId);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleGetDeploymentDiff(IServiceProvider sp, GetDeploymentDiffCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||
@@ -1449,6 +1510,55 @@ public class ManagementActor : ReceiveActor
|
||||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||||
}
|
||||
|
||||
// ── Native alarm source bindings (read-only mirror; repository-direct CRUD) ──
|
||||
|
||||
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var source = new TemplateNativeAlarmSource(cmd.Name)
|
||||
{
|
||||
TemplateId = cmd.TemplateId,
|
||||
ConnectionName = cmd.ConnectionName,
|
||||
SourceReference = cmd.SourceReference,
|
||||
ConditionFilter = cmd.ConditionFilter,
|
||||
Description = cmd.Description,
|
||||
IsLocked = cmd.IsLocked
|
||||
};
|
||||
await repo.AddTemplateNativeAlarmSourceAsync(source);
|
||||
await repo.SaveChangesAsync();
|
||||
return source;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId)
|
||||
?? throw new ManagementCommandException($"Native alarm source {cmd.NativeAlarmSourceId} not found.");
|
||||
source.Name = cmd.Name;
|
||||
source.ConnectionName = cmd.ConnectionName;
|
||||
source.SourceReference = cmd.SourceReference;
|
||||
source.ConditionFilter = cmd.ConditionFilter;
|
||||
source.Description = cmd.Description;
|
||||
source.IsLocked = cmd.IsLocked;
|
||||
await repo.UpdateTemplateNativeAlarmSourceAsync(source);
|
||||
await repo.SaveChangesAsync();
|
||||
return source;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
|
||||
await repo.SaveChangesAsync();
|
||||
return cmd.NativeAlarmSourceId;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleListNativeAlarmSources(IServiceProvider sp, ListTemplateNativeAlarmSourcesCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
return await repo.GetNativeAlarmSourcesByTemplateIdAsync(cmd.TemplateId);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleAddScript(IServiceProvider sp, AddTemplateScriptCommand cmd, string user)
|
||||
{
|
||||
var svc = sp.GetRequiredService<TemplateService>();
|
||||
|
||||
@@ -422,8 +422,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
command.RevisionHash,
|
||||
isEnabled: true);
|
||||
|
||||
// Static overrides are reset on redeployment per design decision
|
||||
// Static overrides and mirrored native alarm state are reset on
|
||||
// redeployment per design decision — the new config may bind different
|
||||
// sources, and the source snapshot re-seeds the mirror on (re)subscribe.
|
||||
await _storage.ClearStaticOverridesAsync(instanceName);
|
||||
await _storage.ClearNativeAlarmsForInstanceAsync(instanceName);
|
||||
|
||||
// Replicate to standby node
|
||||
_replicationActor?.Tell(new ReplicateConfigDeploy(
|
||||
|
||||
@@ -48,6 +48,13 @@ public class InstanceActor : ReceiveActor
|
||||
private readonly Dictionary<string, int> _alarmPriorities = new();
|
||||
private readonly Dictionary<string, IActorRef> _scriptActors = new();
|
||||
private readonly Dictionary<string, IActorRef> _alarmActors = new();
|
||||
private readonly Dictionary<string, IActorRef> _nativeAlarmActors = new();
|
||||
/// <summary>
|
||||
/// Latest enriched <see cref="AlarmStateChanged"/> per alarm name (computed and
|
||||
/// native), so the DebugView snapshot carries the unified condition + native
|
||||
/// metadata rather than a bare State/Priority projection.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, AlarmStateChanged> _latestAlarmEvents = new();
|
||||
private FlattenedConfiguration? _configuration;
|
||||
|
||||
// DCL manager actor reference for subscribing to tag values
|
||||
@@ -505,6 +512,9 @@ public class InstanceActor : ReceiveActor
|
||||
{
|
||||
_alarmStates[changed.AlarmName] = changed.State;
|
||||
_alarmTimestamps[changed.AlarmName] = changed.Timestamp;
|
||||
// Retain the full enriched event (Kind, Condition, native metadata) so the
|
||||
// DebugView snapshot reflects it — native alarms have no _alarmActors entry.
|
||||
_latestAlarmEvents[changed.AlarmName] = changed;
|
||||
|
||||
// WP-23: Publish to site-wide stream
|
||||
_streamManager?.PublishAlarmStateChanged(changed);
|
||||
@@ -525,17 +535,10 @@ public class InstanceActor : ReceiveActor
|
||||
_attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
|
||||
_attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList();
|
||||
|
||||
var alarmStates = _alarmActors.Keys.Select(name => new AlarmStateChanged(
|
||||
_instanceUniqueName,
|
||||
name,
|
||||
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
|
||||
_alarmPriorities.GetValueOrDefault(name, 0),
|
||||
_alarmTimestamps[name])).ToList();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
_instanceUniqueName,
|
||||
attributeValues,
|
||||
alarmStates,
|
||||
BuildAlarmStatesSnapshot(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Sender.Tell(snapshot);
|
||||
@@ -545,6 +548,34 @@ public class InstanceActor : ReceiveActor
|
||||
_instanceUniqueName, request.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the alarm-state list for a DebugView snapshot. Prefers the latest
|
||||
/// enriched <see cref="AlarmStateChanged"/> per alarm (computed alarms that have
|
||||
/// fired plus all native alarms) and falls back to a bare Normal projection for
|
||||
/// computed alarms that have not yet emitted an event.
|
||||
/// </summary>
|
||||
private List<AlarmStateChanged> BuildAlarmStatesSnapshot()
|
||||
{
|
||||
var states = _latestAlarmEvents.Values.ToList();
|
||||
|
||||
foreach (var name in _alarmActors.Keys)
|
||||
{
|
||||
if (_latestAlarmEvents.ContainsKey(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
states.Add(new AlarmStateChanged(
|
||||
_instanceUniqueName,
|
||||
name,
|
||||
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
|
||||
_alarmPriorities.GetValueOrDefault(name, 0),
|
||||
_alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow)));
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-25: Debug view unsubscribe (SiteRuntime-013).
|
||||
/// This handler is a deliberate no-op acknowledgement: the Instance Actor holds
|
||||
@@ -576,17 +607,10 @@ public class InstanceActor : ReceiveActor
|
||||
_attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
|
||||
_attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList();
|
||||
|
||||
var alarmStates = _alarmActors.Keys.Select(name => new AlarmStateChanged(
|
||||
_instanceUniqueName,
|
||||
name,
|
||||
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
|
||||
_alarmPriorities.GetValueOrDefault(name, 0),
|
||||
_alarmTimestamps[name])).ToList();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
_instanceUniqueName,
|
||||
attributeValues,
|
||||
alarmStates,
|
||||
BuildAlarmStatesSnapshot(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Sender.Tell(snapshot);
|
||||
@@ -747,9 +771,55 @@ public class InstanceActor : ReceiveActor
|
||||
_alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
// Create Native Alarm Actors — read-only mirror of each bound source's
|
||||
// native alarms (peers to the computed Alarm Actors). They subscribe through
|
||||
// the DCL manager, so without one (e.g. in isolated tests) they are skipped.
|
||||
foreach (var nativeSource in _configuration.NativeAlarmSources)
|
||||
{
|
||||
if (_dclManager == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Instance {Instance}: native alarm source {Source} skipped — no DCL manager available",
|
||||
_instanceUniqueName, nativeSource.CanonicalName);
|
||||
continue;
|
||||
}
|
||||
|
||||
var nativeKind = ResolveNativeKind(nativeSource.ConnectionName);
|
||||
var props = Props.Create(() => new NativeAlarmActor(
|
||||
nativeSource,
|
||||
_instanceUniqueName,
|
||||
Self,
|
||||
_dclManager,
|
||||
_storage,
|
||||
_options,
|
||||
_logger,
|
||||
nativeKind));
|
||||
|
||||
var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}");
|
||||
_nativeAlarmActors[nativeSource.CanonicalName] = actorRef;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Instance {Instance}: created {Scripts} script actors and {Alarms} alarm actors",
|
||||
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count);
|
||||
"Instance {Instance}: created {Scripts} script actors, {Alarms} alarm actors, {NativeAlarms} native alarm actors",
|
||||
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count, _nativeAlarmActors.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a bound connection's protocol to the native alarm kind stamped on
|
||||
/// emitted events. MxAccess Gateway connections yield
|
||||
/// <see cref="AlarmKind.NativeMxAccess"/>; everything else (OPC UA) yields
|
||||
/// <see cref="AlarmKind.NativeOpcUa"/>.
|
||||
/// </summary>
|
||||
private AlarmKind ResolveNativeKind(string connectionName)
|
||||
{
|
||||
var protocol = _configuration?.Connections is { } connections
|
||||
&& connections.TryGetValue(connectionName, out var cfg)
|
||||
? cfg.Protocol
|
||||
: null;
|
||||
|
||||
return protocol != null && protocol.Contains("Mx", StringComparison.OrdinalIgnoreCase)
|
||||
? AlarmKind.NativeMxAccess
|
||||
: AlarmKind.NativeOpcUa;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
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 ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Task 15: Native Alarm Actor — child of Instance Actor, peer to the computed
|
||||
/// <see cref="AlarmActor"/>. Mirrors a single source binding's native alarms
|
||||
/// read-only: subscribes through the DCL, applies snapshot/live transitions,
|
||||
/// retains active+unacked conditions, persists to site SQLite, and emits an
|
||||
/// enriched <see cref="AlarmStateChanged"/> upward for every transition.
|
||||
///
|
||||
/// State is keyed by <see cref="NativeAlarmTransition.SourceReference"/> (the
|
||||
/// stable per-condition key). A snapshot replay (Snapshot* sentinels) atomically
|
||||
/// swaps the mirrored set so conditions that cleared while disconnected emit a
|
||||
/// return-to-normal. Persistence is best-effort/fire-and-forget — a failed write
|
||||
/// never blocks the actor or aborts the emit.
|
||||
///
|
||||
/// Supervision: parent Instance Actor uses Resume for coordinator children.
|
||||
/// </summary>
|
||||
public class NativeAlarmActor : ReceiveActor
|
||||
{
|
||||
private readonly ResolvedNativeAlarmSource _source;
|
||||
private readonly string _instanceName;
|
||||
private readonly IActorRef _instanceActor;
|
||||
private readonly IActorRef _dclManager;
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly ILogger _logger;
|
||||
private readonly AlarmKind _nativeKind;
|
||||
|
||||
/// <summary>Current mirrored conditions, keyed by source reference.</summary>
|
||||
private readonly Dictionary<string, NativeAlarmTransition> _alarms = new();
|
||||
|
||||
/// <summary>Buffer accumulating a snapshot replay until <see cref="AlarmTransitionKind.SnapshotComplete"/>.</summary>
|
||||
private readonly Dictionary<string, NativeAlarmTransition> _snapshotBuffer = new();
|
||||
|
||||
private ICancelable? _retryTimer;
|
||||
|
||||
/// <summary>Initializes a new <see cref="NativeAlarmActor"/> for a single resolved source binding.</summary>
|
||||
/// <param name="source">The resolved native alarm source (connection + source reference + filter).</param>
|
||||
/// <param name="instanceName">The owning instance's unique name.</param>
|
||||
/// <param name="instanceActor">Parent instance actor; receives emitted <see cref="AlarmStateChanged"/>.</param>
|
||||
/// <param name="dclManager">DCL target the subscribe request is sent to.</param>
|
||||
/// <param name="storage">Site-local SQLite store for rehydration + persistence.</param>
|
||||
/// <param name="options">Site runtime options (alarm cap, retry interval).</param>
|
||||
/// <param name="logger">Logger for diagnostics.</param>
|
||||
/// <param name="nativeKind">Alarm kind to stamp on emitted events (OPC UA vs MxAccess); set by the
|
||||
/// Instance Actor from the connection protocol. Defaults to <see cref="AlarmKind.NativeOpcUa"/>.</param>
|
||||
public NativeAlarmActor(
|
||||
ResolvedNativeAlarmSource source,
|
||||
string instanceName,
|
||||
IActorRef instanceActor,
|
||||
IActorRef dclManager,
|
||||
SiteStorageService storage,
|
||||
SiteRuntimeOptions options,
|
||||
ILogger logger,
|
||||
AlarmKind nativeKind = AlarmKind.NativeOpcUa)
|
||||
{
|
||||
_source = source;
|
||||
_instanceName = instanceName;
|
||||
_instanceActor = instanceActor;
|
||||
_dclManager = dclManager;
|
||||
_storage = storage;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
_nativeKind = nativeKind;
|
||||
|
||||
Receive<RehydrationCompleted>(HandleRehydration);
|
||||
Receive<NativeAlarmTransitionUpdate>(HandleTransition);
|
||||
Receive<NativeAlarmSourceUnavailable>(HandleSourceUnavailable);
|
||||
Receive<SubscribeAlarmsResponse>(HandleSubscribeResponse);
|
||||
Receive<RetrySubscribe>(_ => SendSubscribe());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart()
|
||||
{
|
||||
base.PreStart();
|
||||
|
||||
// Rehydrate last-known state from SQLite (non-blocking), then subscribe.
|
||||
// A fresh source snapshot will reconcile/replace this once it arrives.
|
||||
_storage.GetNativeAlarmsAsync(_instanceName, _source.CanonicalName)
|
||||
.PipeTo(Self, Self, rows => new RehydrationCompleted(rows));
|
||||
|
||||
SendSubscribe();
|
||||
|
||||
_logger.LogInformation(
|
||||
"NativeAlarmActor started for {Source} ({Connection}:{Ref}) on {Instance}",
|
||||
_source.CanonicalName, _source.ConnectionName, _source.SourceReference, _instanceName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop()
|
||||
{
|
||||
_retryTimer?.Cancel();
|
||||
base.PostStop();
|
||||
}
|
||||
|
||||
private void SendSubscribe()
|
||||
{
|
||||
_dclManager.Tell(new SubscribeAlarmsRequest(
|
||||
CorrelationId: Guid.NewGuid().ToString("N"),
|
||||
InstanceUniqueName: _instanceName,
|
||||
ConnectionName: _source.ConnectionName,
|
||||
SourceReference: _source.SourceReference,
|
||||
ConditionFilter: _source.ConditionFilter,
|
||||
Timestamp: DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private void HandleRehydration(RehydrationCompleted msg)
|
||||
{
|
||||
foreach (var row in msg.Rows)
|
||||
{
|
||||
// A live transition that arrived before the read completed wins.
|
||||
if (_alarms.ContainsKey(row.SourceReference))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AlarmConditionState? condition;
|
||||
try
|
||||
{
|
||||
condition = JsonSerializer.Deserialize<AlarmConditionState>(row.ConditionJson);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Discarding unreadable persisted native alarm {Ref} on {Instance}",
|
||||
row.SourceReference, _instanceName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (condition is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Metadata (type/category/message) is not persisted — rehydrate the
|
||||
// condition + key so the DebugView reflects last-known state until the
|
||||
// first source snapshot replaces it.
|
||||
var t = new NativeAlarmTransition(
|
||||
row.SourceReference, string.Empty, string.Empty, AlarmTransitionKind.Snapshot,
|
||||
condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty,
|
||||
null, row.LastTransitionAt, string.Empty, string.Empty);
|
||||
_alarms[row.SourceReference] = t;
|
||||
Emit(t, t.Condition);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTransition(NativeAlarmTransitionUpdate update)
|
||||
{
|
||||
var t = update.Transition;
|
||||
switch (t.Kind)
|
||||
{
|
||||
case AlarmTransitionKind.Snapshot:
|
||||
_snapshotBuffer[t.SourceReference] = t;
|
||||
break;
|
||||
|
||||
case AlarmTransitionKind.SnapshotComplete:
|
||||
ApplySnapshotSwap();
|
||||
break;
|
||||
|
||||
default:
|
||||
ApplyLiveTransition(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically replaces the mirrored set with the buffered snapshot: conditions
|
||||
/// no longer present emit a return-to-normal (and drop out); present conditions
|
||||
/// are upserted, persisted, and emitted.
|
||||
/// </summary>
|
||||
private void ApplySnapshotSwap()
|
||||
{
|
||||
foreach (var (sourceRef, prior) in _alarms)
|
||||
{
|
||||
if (!_snapshotBuffer.ContainsKey(sourceRef))
|
||||
{
|
||||
Emit(prior, prior.Condition with { Active = false });
|
||||
PersistDelete(sourceRef);
|
||||
}
|
||||
}
|
||||
|
||||
_alarms.Clear();
|
||||
foreach (var (sourceRef, t) in _snapshotBuffer)
|
||||
{
|
||||
_alarms[sourceRef] = t;
|
||||
PersistUpsert(t);
|
||||
Emit(t, t.Condition);
|
||||
}
|
||||
|
||||
_snapshotBuffer.Clear();
|
||||
EnforceCap();
|
||||
}
|
||||
|
||||
private void ApplyLiveTransition(NativeAlarmTransition t)
|
||||
{
|
||||
// Ignore stale (out-of-order) transitions for a condition we already hold.
|
||||
if (_alarms.TryGetValue(t.SourceReference, out var existing) && t.TransitionTime < existing.TransitionTime)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_alarms[t.SourceReference] = t;
|
||||
PersistUpsert(t);
|
||||
Emit(t, t.Condition);
|
||||
|
||||
// Retention: a resolved condition (inactive AND acknowledged) drops out of
|
||||
// the mirror — the return-to-normal has already been emitted above.
|
||||
if (!t.Condition.Active && t.Condition.Acknowledged)
|
||||
{
|
||||
_alarms.Remove(t.SourceReference);
|
||||
PersistDelete(t.SourceReference);
|
||||
}
|
||||
|
||||
EnforceCap();
|
||||
}
|
||||
|
||||
private void HandleSourceUnavailable(NativeAlarmSourceUnavailable msg)
|
||||
{
|
||||
// Keep last-known conditions (uncertain) rather than clearing them; the
|
||||
// reconnect snapshot reconciles state. No emit — avoids a flap.
|
||||
_logger.LogWarning(
|
||||
"Native alarm feed unavailable for {Source} ({Connection}) on {Instance}; retaining {Count} mirrored alarms pending reconnect snapshot",
|
||||
_source.CanonicalName, msg.ConnectionName, _instanceName, _alarms.Count);
|
||||
}
|
||||
|
||||
private void HandleSubscribeResponse(SubscribeAlarmsResponse resp)
|
||||
{
|
||||
if (resp.Success)
|
||||
{
|
||||
_logger.LogInformation("Native alarm subscription established for {Source} on {Instance}",
|
||||
_source.CanonicalName, _instanceName);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Native alarm subscription failed for {Source} on {Instance}: {Error}; retrying in {RetryMs}ms",
|
||||
_source.CanonicalName, _instanceName, resp.ErrorMessage, _options.NativeAlarmRetryIntervalMs);
|
||||
|
||||
_retryTimer?.Cancel();
|
||||
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
|
||||
TimeSpan.FromMilliseconds(_options.NativeAlarmRetryIntervalMs),
|
||||
Self, RetrySubscribe.Instance, Self);
|
||||
}
|
||||
|
||||
/// <summary>Caps the mirrored set, dropping the oldest conditions (and logging — no silent truncation).</summary>
|
||||
private void EnforceCap()
|
||||
{
|
||||
var cap = _options.MirroredAlarmCapPerSource;
|
||||
if (_alarms.Count <= cap)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var overflow = _alarms.Values
|
||||
.OrderBy(a => a.TransitionTime)
|
||||
.Take(_alarms.Count - cap)
|
||||
.Select(a => a.SourceReference)
|
||||
.ToList();
|
||||
|
||||
foreach (var sourceRef in overflow)
|
||||
{
|
||||
_alarms.Remove(sourceRef);
|
||||
PersistDelete(sourceRef);
|
||||
_logger.LogWarning(
|
||||
"Native alarm cap {Cap} exceeded for {Source} on {Instance}; dropped oldest mirrored alarm {Ref}",
|
||||
cap, _source.CanonicalName, _instanceName, sourceRef);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds and tells the parent an enriched <see cref="AlarmStateChanged"/> for a condition.</summary>
|
||||
private void Emit(NativeAlarmTransition t, AlarmConditionState condition)
|
||||
{
|
||||
var change = new AlarmStateChanged(
|
||||
_instanceName,
|
||||
t.SourceReference,
|
||||
condition.Active ? AlarmState.Active : AlarmState.Normal,
|
||||
condition.Severity,
|
||||
t.TransitionTime)
|
||||
{
|
||||
Kind = _nativeKind,
|
||||
Condition = condition,
|
||||
SourceReference = t.SourceReference,
|
||||
AlarmTypeName = t.AlarmTypeName,
|
||||
Category = t.Category,
|
||||
Message = t.Message,
|
||||
OperatorUser = t.OperatorUser,
|
||||
OperatorComment = t.OperatorComment,
|
||||
OriginalRaiseTime = t.OriginalRaiseTime,
|
||||
CurrentValue = t.CurrentValue,
|
||||
LimitValue = t.LimitValue
|
||||
};
|
||||
|
||||
_instanceActor.Tell(change);
|
||||
}
|
||||
|
||||
private void PersistUpsert(NativeAlarmTransition t)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(t.Condition);
|
||||
_storage.UpsertNativeAlarmAsync(_instanceName, _source.CanonicalName, t.SourceReference, json, t.TransitionTime)
|
||||
.ContinueWith(
|
||||
task => _logger.LogWarning(task.Exception,
|
||||
"Failed to persist native alarm {Ref} on {Instance}", t.SourceReference, _instanceName),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
private void PersistDelete(string sourceReference)
|
||||
{
|
||||
_storage.DeleteNativeAlarmAsync(_instanceName, _source.CanonicalName, sourceReference)
|
||||
.ContinueWith(
|
||||
task => _logger.LogWarning(task.Exception,
|
||||
"Failed to delete native alarm {Ref} on {Instance}", sourceReference, _instanceName),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
// ── Internal messages ──
|
||||
|
||||
private sealed record RehydrationCompleted(IReadOnlyList<NativeAlarmRow> Rows);
|
||||
|
||||
private sealed class RetrySubscribe
|
||||
{
|
||||
public static readonly RetrySubscribe Instance = new();
|
||||
private RetrySubscribe() { }
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,15 @@ public class SiteStorageService
|
||||
oauth_config TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS native_alarm_state (
|
||||
instance_unique_name TEXT NOT NULL,
|
||||
source_canonical_name TEXT NOT NULL,
|
||||
source_reference TEXT NOT NULL,
|
||||
condition_json TEXT NOT NULL,
|
||||
last_transition_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_unique_name, source_canonical_name, source_reference)
|
||||
);
|
||||
";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
|
||||
@@ -235,6 +244,14 @@ public class SiteStorageService
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = (SqliteTransaction)transaction;
|
||||
cmd.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
|
||||
cmd.Parameters.AddWithValue("@name", instanceName);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = (SqliteTransaction)transaction;
|
||||
@@ -346,6 +363,104 @@ public class SiteStorageService
|
||||
_logger.LogDebug("Cleared static overrides for {Instance}", instanceName);
|
||||
}
|
||||
|
||||
// ── Task 14: Native Alarm State store (read-only mirror of source A&C conditions) ──
|
||||
|
||||
/// <summary>
|
||||
/// Inserts or updates a single mirrored native alarm condition, keyed by
|
||||
/// (instance, source canonical name, source reference). Newer transitions overwrite older ones.
|
||||
/// </summary>
|
||||
public async Task UpsertNativeAlarmAsync(
|
||||
string instanceName, string sourceCanonicalName, string sourceReference,
|
||||
string conditionJson, DateTimeOffset lastTransitionAt)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO native_alarm_state
|
||||
(instance_unique_name, source_canonical_name, source_reference, condition_json, last_transition_at)
|
||||
VALUES (@name, @source, @ref, @json, @at)
|
||||
ON CONFLICT(instance_unique_name, source_canonical_name, source_reference) DO UPDATE SET
|
||||
condition_json = excluded.condition_json,
|
||||
last_transition_at = excluded.last_transition_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
command.Parameters.AddWithValue("@source", sourceCanonicalName);
|
||||
command.Parameters.AddWithValue("@ref", sourceReference);
|
||||
command.Parameters.AddWithValue("@json", conditionJson);
|
||||
command.Parameters.AddWithValue("@at", lastTransitionAt.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a single mirrored native alarm condition (e.g. a return-to-normal that drops out of retention).
|
||||
/// </summary>
|
||||
public async Task DeleteNativeAlarmAsync(string instanceName, string sourceCanonicalName, string sourceReference)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
DELETE FROM native_alarm_state
|
||||
WHERE instance_unique_name = @name
|
||||
AND source_canonical_name = @source
|
||||
AND source_reference = @ref";
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
command.Parameters.AddWithValue("@source", sourceCanonicalName);
|
||||
command.Parameters.AddWithValue("@ref", sourceReference);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all mirrored native alarm conditions for an instance's source binding,
|
||||
/// used to rehydrate a NativeAlarmActor on (re)start.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<NativeAlarmRow>> GetNativeAlarmsAsync(string instanceName, string sourceCanonicalName)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT source_reference, condition_json, last_transition_at
|
||||
FROM native_alarm_state
|
||||
WHERE instance_unique_name = @name AND source_canonical_name = @source";
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
command.Parameters.AddWithValue("@source", sourceCanonicalName);
|
||||
|
||||
var results = new List<NativeAlarmRow>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
results.Add(new NativeAlarmRow(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
DateTimeOffset.Parse(reader.GetString(2), null, System.Globalization.DateTimeStyles.RoundtripKind)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all mirrored native alarm conditions for an instance. Called on redeployment / stop.
|
||||
/// </summary>
|
||||
public async Task ClearNativeAlarmsForInstanceAsync(string instanceName)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
_logger.LogDebug("Cleared native alarm state for {Instance}", instanceName);
|
||||
}
|
||||
|
||||
// ── WP-33: Shared Script CRUD ──
|
||||
|
||||
/// <summary>
|
||||
@@ -699,3 +814,8 @@ public class StoredDataConnectionDefinition
|
||||
/// </summary>
|
||||
public int FailoverRetryCount { get; init; } = 3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table (Task 14).
|
||||
/// </summary>
|
||||
public record NativeAlarmRow(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt);
|
||||
|
||||
@@ -44,4 +44,16 @@ public class SiteRuntimeOptions
|
||||
/// Default: 8.
|
||||
/// </summary>
|
||||
public int ScriptExecutionThreadCount { get; set; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Max mirrored native alarms retained per source binding before older entries are dropped (logged).
|
||||
/// Default: 1000.
|
||||
/// </summary>
|
||||
public int MirroredAlarmCapPerSource { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Interval in milliseconds to retry a failed native alarm subscription.
|
||||
/// Default: 5000ms.
|
||||
/// </summary>
|
||||
public int NativeAlarmRetryIntervalMs { get; set; } = 5000;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,13 @@ public class FlatteningService
|
||||
// Step 7: Resolve alarm on-trigger script references to canonical names
|
||||
ResolveAlarmScriptReferences(alarms, alarmScriptIds, scriptCanonicalById);
|
||||
|
||||
// Step 7b: Resolve native alarm source bindings (connection + source
|
||||
// reference). Conditions under each source are discovered at runtime;
|
||||
// flattening only resolves the binding through inherit/compose/override.
|
||||
var nativeAlarmSources = ResolveInheritedNativeAlarmSources(templateChain, prefix: null);
|
||||
ResolveComposedNativeAlarmSources(templateChain, compositionMap, composedTemplateChains, nativeAlarmSources);
|
||||
ApplyInstanceNativeAlarmSourceOverrides(instance.NativeAlarmSourceOverrides, nativeAlarmSources);
|
||||
|
||||
// Step 8: Collect connection configurations for deployment packaging
|
||||
var connections = new Dictionary<string, ConnectionConfig>();
|
||||
foreach (var attr in attributes.Values)
|
||||
@@ -125,6 +132,7 @@ public class FlatteningService
|
||||
AreaId = instance.AreaId,
|
||||
Attributes = attributes.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Alarms = alarms.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
NativeAlarmSources = nativeAlarmSources.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Scripts = scripts.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Connections = connections.Count > 0 ? connections : null,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow
|
||||
@@ -649,6 +657,137 @@ public class FlatteningService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves native alarm source bindings from an inheritance chain. Keys of
|
||||
/// the returned dictionary are bare binding names (caller path-qualifies for
|
||||
/// composed modules). Derived templates win unless the base binding is
|
||||
/// locked; <see cref="TemplateNativeAlarmSource.IsInherited"/> placeholders
|
||||
/// never shadow a live base binding.
|
||||
/// </summary>
|
||||
private static Dictionary<string, ResolvedNativeAlarmSource> ResolveInheritedNativeAlarmSources(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
string? prefix)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedNativeAlarmSource>(StringComparer.Ordinal);
|
||||
// Tracks bindings the base locked, so derived templates cannot override them.
|
||||
var lockedNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var binding in template.NativeAlarmSources)
|
||||
{
|
||||
if (result.ContainsKey(binding.Name))
|
||||
{
|
||||
if (lockedNames.Contains(binding.Name))
|
||||
continue;
|
||||
// IsInherited rows on a derived template are placeholders that
|
||||
// must not shadow the live base binding.
|
||||
if (binding.IsInherited)
|
||||
continue;
|
||||
}
|
||||
|
||||
result[binding.Name] = new ResolvedNativeAlarmSource
|
||||
{
|
||||
CanonicalName = binding.Name,
|
||||
ConnectionName = binding.ConnectionName,
|
||||
SourceReference = binding.SourceReference,
|
||||
ConditionFilter = binding.ConditionFilter,
|
||||
Source = source
|
||||
};
|
||||
|
||||
if (binding.IsLocked)
|
||||
lockedNames.Add(binding.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveComposedNativeAlarmSources(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedNativeAlarmSource> sources)
|
||||
{
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
ResolveComposedNativeAlarmSourcesRecursive(
|
||||
composition, composition.InstanceName,
|
||||
compositionMap, composedTemplateChains, sources, new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively resolves native alarm source bindings of a composed module
|
||||
/// and nested modules, path-qualifying each canonical name with the
|
||||
/// accumulated <paramref name="prefix"/>. The source reference is a raw
|
||||
/// connection address, so (unlike alarm trigger configs) it is not rewritten.
|
||||
/// </summary>
|
||||
private static void ResolveComposedNativeAlarmSourcesRecursive(
|
||||
TemplateComposition composition,
|
||||
string prefix,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedNativeAlarmSource> sources,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
return;
|
||||
|
||||
var composedSources = ResolveInheritedNativeAlarmSources(composedChain, prefix);
|
||||
foreach (var (name, binding) in composedSources)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
if (!sources.ContainsKey(canonicalName))
|
||||
{
|
||||
sources[canonicalName] = binding with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!visited.Add(composedTemplate.Id))
|
||||
continue;
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
ResolveComposedNativeAlarmSourcesRecursive(
|
||||
nested, $"{prefix}.{nested.InstanceName}",
|
||||
compositionMap, composedTemplateChains, sources, visited);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyInstanceNativeAlarmSourceOverrides(
|
||||
ICollection<InstanceNativeAlarmSourceOverride> overrides,
|
||||
Dictionary<string, ResolvedNativeAlarmSource> sources)
|
||||
{
|
||||
foreach (var ovr in overrides)
|
||||
{
|
||||
if (!sources.TryGetValue(ovr.SourceCanonicalName, out var existing))
|
||||
continue; // Cannot add new bindings via overrides.
|
||||
|
||||
sources[ovr.SourceCanonicalName] = existing with
|
||||
{
|
||||
ConnectionName = ovr.ConnectionNameOverride ?? existing.ConnectionName,
|
||||
SourceReference = ovr.SourceReferenceOverride ?? existing.SourceReference,
|
||||
ConditionFilter = ovr.ConditionFilterOverride ?? existing.ConditionFilter,
|
||||
Source = "Override"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves scripts from an inheritance chain. The returned dictionary is
|
||||
/// keyed by bare script name. <paramref name="scriptCanonicalById"/> is
|
||||
|
||||
@@ -27,7 +27,8 @@ public class SemanticValidator
|
||||
/// <param name="sharedScripts">Shared scripts available for CallShared references.</param>
|
||||
public ValidationResult Validate(
|
||||
FlattenedConfiguration configuration,
|
||||
IReadOnlyList<ResolvedScript>? sharedScripts = null)
|
||||
IReadOnlyList<ResolvedScript>? sharedScripts = null,
|
||||
IReadOnlySet<string>? alarmCapableConnectionNames = null)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
@@ -215,6 +216,33 @@ public class SemanticValidator
|
||||
}
|
||||
}
|
||||
|
||||
// Native alarm source bindings: connection + source reference must be
|
||||
// present, and (when the alarm-capable connection set is supplied) the
|
||||
// connection must resolve to an alarm-capable site data connection.
|
||||
foreach (var nativeSource in configuration.NativeAlarmSources)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nativeSource.SourceReference))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid,
|
||||
$"Native alarm source '{nativeSource.CanonicalName}' has an empty source reference.",
|
||||
nativeSource.CanonicalName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(nativeSource.ConnectionName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid,
|
||||
$"Native alarm source '{nativeSource.CanonicalName}' has no data connection.",
|
||||
nativeSource.CanonicalName));
|
||||
}
|
||||
else if (alarmCapableConnectionNames is not null &&
|
||||
!alarmCapableConnectionNames.Contains(nativeSource.ConnectionName))
|
||||
{
|
||||
errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid,
|
||||
$"Native alarm source '{nativeSource.CanonicalName}' references connection '{nativeSource.ConnectionName}' which is not an alarm-capable data connection on this site.",
|
||||
nativeSource.CanonicalName));
|
||||
}
|
||||
}
|
||||
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,27 @@ public class CommandTreeTests
|
||||
Assert.DoesNotContain("--instance-name", optionNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateNativeAlarmSource_HasAddListRemove()
|
||||
{
|
||||
var template = TemplateCommands.Build(Url, Format, Username, Password);
|
||||
var group = template.Subcommands.Single(c => c.Name == "native-alarm-source");
|
||||
var subNames = group.Subcommands.Select(c => c.Name).ToHashSet();
|
||||
Assert.Contains("add", subNames);
|
||||
Assert.Contains("list", subNames);
|
||||
Assert.Contains("remove", subNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceNativeAlarmSource_HasSetAndClear()
|
||||
{
|
||||
var instance = InstanceCommands.Build(Url, Format, Username, Password);
|
||||
var group = instance.Subcommands.Single(c => c.Name == "native-alarm-source");
|
||||
var subNames = group.Subcommands.Select(c => c.Name).ToHashSet();
|
||||
Assert.Contains("set", subNames);
|
||||
Assert.Contains("clear", subNames);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(GetInstanceCommand))]
|
||||
[InlineData(typeof(ListSitesCommand))]
|
||||
@@ -141,6 +162,8 @@ public class CommandTreeTests
|
||||
[InlineData(typeof(ExportBundleCommand))]
|
||||
[InlineData(typeof(PreviewBundleCommand))]
|
||||
[InlineData(typeof(ImportBundleCommand))]
|
||||
[InlineData(typeof(AddTemplateNativeAlarmSourceCommand))]
|
||||
[InlineData(typeof(SetInstanceNativeAlarmSourceOverrideCommand))]
|
||||
public void CommandPayloadTypes_ResolveViaRegistry(Type commandType)
|
||||
{
|
||||
// GetCommandName throws ArgumentException for an unregistered type — the CLI
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
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 ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Task 23: the DebugView alarm table surfaces enriched native alarm state —
|
||||
/// kind, severity, source reference, and a composite condition badge set
|
||||
/// (Unacked / Shelved / Suppressed).
|
||||
/// </summary>
|
||||
public class DebugViewAlarmTableTests : BunitContext
|
||||
{
|
||||
private IRenderedComponent<DebugViewPage> RenderPage()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
|
||||
var repo = Substitute.For<ITemplateEngineRepository>();
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
|
||||
Services.AddSingleton(repo);
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
var comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
Services.AddSingleton(comms);
|
||||
|
||||
var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
|
||||
var debugStream = new DebugStreamService(
|
||||
comms, new ServiceCollection().BuildServiceProvider(), grpcFactory,
|
||||
NullLogger<DebugStreamService>.Instance);
|
||||
Services.AddSingleton(debugStream);
|
||||
|
||||
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
|
||||
var stubAuth = new StubAuthStateProvider(new AuthenticationState(new ClaimsPrincipal(identity)));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
|
||||
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
||||
|
||||
return Render<DebugViewPage>();
|
||||
}
|
||||
|
||||
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly AuthenticationState _state;
|
||||
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state);
|
||||
}
|
||||
|
||||
private static void SetField(DebugViewPage c, string name, object? value) =>
|
||||
typeof(DebugViewPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(c, value);
|
||||
|
||||
private static Dictionary<string, AlarmStateChanged> AlarmStates(DebugViewPage c) =>
|
||||
(Dictionary<string, AlarmStateChanged>)typeof(DebugViewPage)
|
||||
.GetField("_alarmStates", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
|
||||
|
||||
[Fact]
|
||||
public void AlarmTable_RendersNativeAlarm_WithSeverityKindAndShelvedBadge()
|
||||
{
|
||||
var cut = RenderPage();
|
||||
|
||||
var native = new AlarmStateChanged("inst", "T01.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Kind = AlarmKind.NativeOpcUa,
|
||||
SourceReference = "ns=2;s=Tank01.Level.HiHi",
|
||||
Condition = new AlarmConditionState(
|
||||
Active: true, Acknowledged: false, Confirmed: null,
|
||||
Shelve: AlarmShelveState.OneShotShelved, Suppressed: false, Severity: 800)
|
||||
};
|
||||
|
||||
cut.InvokeAsync(() =>
|
||||
{
|
||||
SetField(cut.Instance, "_connected", true);
|
||||
SetField(cut.Instance, "_snapshot",
|
||||
new DebugViewSnapshot("inst", new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged> { native }, DateTimeOffset.UtcNow));
|
||||
AlarmStates(cut.Instance)["T01.Hi"] = native;
|
||||
});
|
||||
cut.Render();
|
||||
|
||||
var markup = cut.Markup;
|
||||
Assert.Contains("800", markup); // severity surfaced
|
||||
Assert.Contains("Shelved", markup); // composite condition badge
|
||||
Assert.Contains("OPC UA", markup); // native kind badge
|
||||
Assert.Contains("ns=2;s=Tank01.Level.HiHi", markup); // source reference
|
||||
Assert.Contains("Unacked", markup); // active + unacknowledged
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Task 25: Instance Configure exposes a "Native Alarm Source Overrides" card —
|
||||
/// per-instance retarget of an inherited native alarm source binding (connection /
|
||||
/// source reference / filter; blank = inherited). <c>InstanceConfigure</c> is a
|
||||
/// heavyweight page (7 injected services incl. <c>InstanceService</c> and the
|
||||
/// flattening pipeline), so — consistent with the template-editor coverage — these
|
||||
/// are structural assertions over the component source that pin the card and its
|
||||
/// repository-direct upsert/clear wiring. The override CRUD itself is covered
|
||||
/// behaviorally by the ManagementActor native-alarm-source handler tests.
|
||||
/// </summary>
|
||||
public class InstanceConfigureNativeAlarmTests
|
||||
{
|
||||
private static string InstanceConfigureMarkup
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 6 && dir is not null; i++)
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
||||
"Components", "Pages", "Deployment", "InstanceConfigure.razor"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceConfigure_HasNativeAlarmSourceOverridesCard()
|
||||
{
|
||||
var markup = InstanceConfigureMarkup;
|
||||
Assert.Contains("Native Alarm Source Overrides", markup);
|
||||
Assert.Contains("Leave a field blank to keep the inherited value", markup);
|
||||
Assert.Contains("HasNativeOverride", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeOverrideRow_HasConnectionSourceRefAndFilterEditors()
|
||||
{
|
||||
var markup = InstanceConfigureMarkup;
|
||||
Assert.Contains("AlarmCapableConnections", markup); // dropdown filtered to OPC UA / MxGateway
|
||||
Assert.Contains("_nasConnEdit", markup);
|
||||
Assert.Contains("_nasRefEdit", markup);
|
||||
Assert.Contains("_nasFilterEdit", markup);
|
||||
Assert.Contains("(inherited)", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeOverride_WiresRepositoryUpsertAndClear()
|
||||
{
|
||||
var markup = InstanceConfigureMarkup;
|
||||
Assert.Contains("GetNativeAlarmSourceOverridesByInstanceIdAsync", markup);
|
||||
Assert.Contains("GetNativeAlarmSourceOverrideAsync", markup);
|
||||
Assert.Contains("AddInstanceNativeAlarmSourceOverrideAsync", markup);
|
||||
Assert.Contains("UpdateInstanceNativeAlarmSourceOverrideAsync", markup);
|
||||
Assert.Contains("DeleteInstanceNativeAlarmSourceOverrideAsync", markup);
|
||||
Assert.Contains("SaveChangesAsync", markup);
|
||||
Assert.Contains("SaveNativeOverride", markup);
|
||||
Assert.Contains("ClearNativeOverride", markup);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
|
||||
|
||||
/// <summary>
|
||||
/// Task 24: the template editor exposes a Native Alarm Sources subsection (a new
|
||||
/// tab) for authoring read-only native alarm bindings. <c>TemplateEdit</c> is a
|
||||
/// heavyweight page (~10 injected services with their own graphs); like the other
|
||||
/// TemplateEdit coverage in this suite (see <c>TestRunWarningTests</c>), these are
|
||||
/// structural assertions over the component source that pin the subsection's
|
||||
/// wiring — the tab, the authoring form (connection dropdown, source reference,
|
||||
/// filter, lock), and the repository-direct CRUD calls — so it cannot silently
|
||||
/// regress. The CRUD behaviour itself is covered end-to-end by the ManagementActor
|
||||
/// native-alarm-source handler tests.
|
||||
/// </summary>
|
||||
public class TemplateNativeAlarmSourceEditorTests
|
||||
{
|
||||
private static string TemplateEditMarkup
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 6 && dir is not null; i++)
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
||||
"Components", "Pages", "Design", "TemplateEdit.razor"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TemplateEditor_HasNativeAlarmsTab()
|
||||
{
|
||||
var markup = TemplateEditMarkup;
|
||||
Assert.Contains("_activeTab = \"native-alarms\"", markup);
|
||||
Assert.Contains("Native Alarms", markup);
|
||||
Assert.Contains("RenderNativeAlarmsTab", markup);
|
||||
Assert.Contains("Native Alarm Sources", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeAlarmsForm_HasConnectionSourceFilterAndLockFields()
|
||||
{
|
||||
var markup = TemplateEditMarkup;
|
||||
// Connection dropdown filtered to alarm-capable protocols.
|
||||
Assert.Contains("_alarmCapableConnections", markup);
|
||||
Assert.Contains("IsAlarmCapable", markup);
|
||||
Assert.Contains("OpcUa", markup);
|
||||
Assert.Contains("MxGateway", markup);
|
||||
// The authoring form fields.
|
||||
Assert.Contains("@bind=\"_nasName\"", markup);
|
||||
Assert.Contains("@bind=\"_nasConnection\"", markup);
|
||||
Assert.Contains("@bind=\"_nasSourceRef\"", markup);
|
||||
Assert.Contains("@bind=\"_nasFilter\"", markup);
|
||||
Assert.Contains("@bind=\"_nasIsLocked\"", markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeAlarmsCrud_WiresRepositoryAddUpdateDeleteWithSave()
|
||||
{
|
||||
var markup = TemplateEditMarkup;
|
||||
Assert.Contains("AddTemplateNativeAlarmSourceAsync", markup);
|
||||
Assert.Contains("UpdateTemplateNativeAlarmSourceAsync", markup);
|
||||
Assert.Contains("DeleteTemplateNativeAlarmSourceAsync", markup);
|
||||
Assert.Contains("GetNativeAlarmSourcesByTemplateIdAsync", markup);
|
||||
Assert.Contains("SaveChangesAsync", markup);
|
||||
// Add/edit/delete handlers are wired to the UI.
|
||||
Assert.Contains("BeginAddNativeSource", markup);
|
||||
Assert.Contains("BeginEditNativeSource", markup);
|
||||
Assert.Contains("SaveNativeSource", markup);
|
||||
Assert.Contains("DeleteNativeSource", markup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
|
||||
|
||||
public class NativeAlarmSourceEntityTests
|
||||
{
|
||||
[Fact]
|
||||
public void TemplateNativeAlarmSource_RequiresName()
|
||||
{
|
||||
var s = new TemplateNativeAlarmSource("PressureMon") { ConnectionName = "Plant", SourceReference = "ns=2;s=P1" };
|
||||
Assert.Equal("PressureMon", s.Name);
|
||||
Assert.False(s.IsLocked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlattenedConfiguration_HasNativeAlarmSourcesDefaultEmpty()
|
||||
{
|
||||
var f = new FlattenedConfiguration();
|
||||
Assert.Empty(f.NativeAlarmSources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvedNativeAlarmSource_DefaultsSourceTemplate()
|
||||
{
|
||||
var r = new ResolvedNativeAlarmSource { CanonicalName = "PressureMon", ConnectionName = "Plant", SourceReference = "ns=2;s=P1" };
|
||||
Assert.Equal("Template", r.Source);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
public class AlarmStateChangedEnrichmentTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_AreComputedKind_WithAutoAck()
|
||||
{
|
||||
var m = new AlarmStateChanged("inst", "HiAlarm", AlarmState.Active, 700, DateTimeOffset.UnixEpoch);
|
||||
Assert.Equal(AlarmKind.Computed, m.Kind);
|
||||
Assert.True(m.Condition.Acknowledged); // computed = auto-acked
|
||||
Assert.Equal(700, m.Condition.Severity); // severity defaults to Priority
|
||||
Assert.True(m.Condition.Active); // derived from State
|
||||
Assert.Equal("", m.SourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_ForComputed_MapsPriorityAndState()
|
||||
{
|
||||
var c = AlarmConditionStateFactory.ForComputed(AlarmState.Normal, priority: 250);
|
||||
Assert.False(c.Active);
|
||||
Assert.True(c.Acknowledged);
|
||||
Assert.Equal(250, c.Severity);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,25 @@ public class ManagementCommandRegistryTests
|
||||
Assert.Equal("CreateSite", ManagementCommandRegistry.GetCommandName(typeof(CreateSiteCommand)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NativeAlarmSourceCommands_AllRegistered()
|
||||
{
|
||||
Assert.Equal(typeof(AddTemplateNativeAlarmSourceCommand),
|
||||
ManagementCommandRegistry.Resolve("AddTemplateNativeAlarmSource"));
|
||||
Assert.Equal(typeof(UpdateTemplateNativeAlarmSourceCommand),
|
||||
ManagementCommandRegistry.Resolve("UpdateTemplateNativeAlarmSource"));
|
||||
Assert.Equal(typeof(DeleteTemplateNativeAlarmSourceCommand),
|
||||
ManagementCommandRegistry.Resolve("DeleteTemplateNativeAlarmSource"));
|
||||
Assert.Equal(typeof(ListTemplateNativeAlarmSourcesCommand),
|
||||
ManagementCommandRegistry.Resolve("ListTemplateNativeAlarmSources"));
|
||||
Assert.Equal(typeof(SetInstanceNativeAlarmSourceOverrideCommand),
|
||||
ManagementCommandRegistry.Resolve("SetInstanceNativeAlarmSourceOverride"));
|
||||
Assert.Equal(typeof(DeleteInstanceNativeAlarmSourceOverrideCommand),
|
||||
ManagementCommandRegistry.Resolve("DeleteInstanceNativeAlarmSourceOverride"));
|
||||
Assert.Equal(typeof(ListInstanceNativeAlarmSourceOverridesCommand),
|
||||
ManagementCommandRegistry.Resolve("ListInstanceNativeAlarmSourceOverrides"));
|
||||
}
|
||||
|
||||
/// <summary>A *Command record outside the Management namespace, for the negative test.</summary>
|
||||
private record UnregisteredFakeCommand(int Id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
public class NativeAlarmMessagesTests
|
||||
{
|
||||
[Fact]
|
||||
public void SubscribeAlarmsRequest_CarriesSourceAndFilter()
|
||||
{
|
||||
var r = new SubscribeAlarmsRequest("c1", "inst", "PlantOpcUa", "ns=2;s=Tank01", null, DateTimeOffset.UnixEpoch);
|
||||
Assert.Equal("ns=2;s=Tank01", r.SourceReference);
|
||||
Assert.Null(r.ConditionFilter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeAlarmTransitionUpdate_WrapsTransition()
|
||||
{
|
||||
var t = new NativeAlarmTransition("Tank01.Hi", "Tank01", "x", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500),
|
||||
"", "", "", "", "", null, DateTimeOffset.UnixEpoch, "", "");
|
||||
var u = new NativeAlarmTransitionUpdate("PlantOpcUa", t);
|
||||
Assert.Equal("PlantOpcUa", u.ConnectionName);
|
||||
Assert.Equal("Tank01", u.Transition.SourceObjectReference);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Alarms;
|
||||
|
||||
public class AlarmConditionStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void AlarmConditionState_DefaultsAreNormalUnshelved()
|
||||
{
|
||||
var s = new AlarmConditionState(
|
||||
Active: false, Acknowledged: true, Confirmed: null,
|
||||
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 0);
|
||||
|
||||
Assert.False(s.Active);
|
||||
Assert.Equal(AlarmShelveState.Unshelved, s.Shelve);
|
||||
Assert.Equal(0, s.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeAlarmTransition_CarriesSourceAndCondition()
|
||||
{
|
||||
var t = new NativeAlarmTransition(
|
||||
SourceReference: "Tank01.Level.HiHi", SourceObjectReference: "Tank01",
|
||||
AlarmTypeName: "AnalogLimitAlarm.HiHi", Kind: AlarmTransitionKind.Raise,
|
||||
Condition: new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800),
|
||||
Category: "Process", Description: "High level", Message: "level high",
|
||||
OperatorUser: "", OperatorComment: "",
|
||||
OriginalRaiseTime: null, TransitionTime: DateTimeOffset.UnixEpoch,
|
||||
CurrentValue: "92.1", LimitValue: "90");
|
||||
|
||||
Assert.Equal("Tank01", t.SourceObjectReference);
|
||||
Assert.Equal(800, t.Condition.Severity);
|
||||
Assert.Equal(AlarmTransitionKind.Raise, t.Kind);
|
||||
}
|
||||
}
|
||||
@@ -40,16 +40,31 @@ public class SiteStreamGrpcClientTests
|
||||
public void ConvertToDomainEvent_AlarmChanged_MapsCorrectly()
|
||||
{
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
var raiseTime = ts.AddMinutes(-30);
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-2",
|
||||
AlarmChanged = new AlarmStateUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Motor01",
|
||||
AlarmName = "OverTemp",
|
||||
AlarmName = "T01.Hi",
|
||||
State = AlarmStateEnum.AlarmStateActive,
|
||||
Priority = 3,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(ts)
|
||||
Priority = 850,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(ts),
|
||||
Kind = "NativeOpcUa",
|
||||
Active = true,
|
||||
Acknowledged = false,
|
||||
Confirmed = false,
|
||||
ShelveState = "TimedShelved",
|
||||
Suppressed = true,
|
||||
SourceReference = "T01.Hi",
|
||||
AlarmTypeName = "AnalogLimit.Hi",
|
||||
Category = "Process",
|
||||
OperatorUser = "op2",
|
||||
OperatorComment = "shelved",
|
||||
OriginalRaiseTime = Timestamp.FromDateTimeOffset(raiseTime),
|
||||
CurrentValue = "120",
|
||||
LimitValue = "100"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,10 +72,26 @@ public class SiteStreamGrpcClientTests
|
||||
|
||||
var alarm = Assert.IsType<AlarmStateChanged>(result);
|
||||
Assert.Equal("Site1.Motor01", alarm.InstanceUniqueName);
|
||||
Assert.Equal("OverTemp", alarm.AlarmName);
|
||||
Assert.Equal("T01.Hi", alarm.AlarmName);
|
||||
Assert.Equal(AlarmState.Active, alarm.State);
|
||||
Assert.Equal(3, alarm.Priority);
|
||||
Assert.Equal(850, alarm.Priority);
|
||||
Assert.Equal(ts, alarm.Timestamp);
|
||||
|
||||
// Native enrichment mapped back.
|
||||
Assert.Equal(AlarmKind.NativeOpcUa, alarm.Kind);
|
||||
Assert.True(alarm.Condition.Active);
|
||||
Assert.False(alarm.Condition.Acknowledged);
|
||||
Assert.Equal(AlarmShelveState.TimedShelved, alarm.Condition.Shelve);
|
||||
Assert.True(alarm.Condition.Suppressed);
|
||||
Assert.Equal(850, alarm.Condition.Severity);
|
||||
Assert.Equal("T01.Hi", alarm.SourceReference);
|
||||
Assert.Equal("AnalogLimit.Hi", alarm.AlarmTypeName);
|
||||
Assert.Equal("Process", alarm.Category);
|
||||
Assert.Equal("op2", alarm.OperatorUser);
|
||||
Assert.Equal("shelved", alarm.OperatorComment);
|
||||
Assert.Equal(raiseTime, alarm.OriginalRaiseTime);
|
||||
Assert.Equal("120", alarm.CurrentValue);
|
||||
Assert.Equal("100", alarm.LimitValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -3,6 +3,7 @@ using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
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 ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
@@ -57,8 +58,23 @@ public class StreamRelayActorTests : TestKit
|
||||
new StreamRelayActor(correlationId, channel.Writer)));
|
||||
|
||||
var timestamp = new DateTimeOffset(2026, 3, 21, 11, 0, 0, TimeSpan.Zero);
|
||||
var raiseTime = new DateTimeOffset(2026, 3, 21, 10, 0, 0, TimeSpan.Zero);
|
||||
var domainEvent = new AlarmStateChanged(
|
||||
"Site1.Pump01", "HighPressure", AlarmState.Active, 2, timestamp);
|
||||
"Site1.Pump01", "T01.Hi", AlarmState.Active, 900, timestamp)
|
||||
{
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
SourceReference = "T01.Hi",
|
||||
AlarmTypeName = "AnalogLimit.Hi",
|
||||
Category = "Process",
|
||||
OperatorUser = "op1",
|
||||
OperatorComment = "ack",
|
||||
OriginalRaiseTime = raiseTime,
|
||||
CurrentValue = "92",
|
||||
LimitValue = "90",
|
||||
Condition = new AlarmConditionState(
|
||||
Active: true, Acknowledged: true, Confirmed: null,
|
||||
Shelve: AlarmShelveState.OneShotShelved, Suppressed: false, Severity: 900)
|
||||
};
|
||||
|
||||
actor.Tell(domainEvent);
|
||||
|
||||
@@ -76,10 +92,25 @@ public class StreamRelayActorTests : TestKit
|
||||
|
||||
var alarm = protoEvent.AlarmChanged;
|
||||
Assert.Equal("Site1.Pump01", alarm.InstanceUniqueName);
|
||||
Assert.Equal("HighPressure", alarm.AlarmName);
|
||||
Assert.Equal("T01.Hi", alarm.AlarmName);
|
||||
Assert.Equal(AlarmStateEnum.AlarmStateActive, alarm.State);
|
||||
Assert.Equal(2, alarm.Priority);
|
||||
Assert.Equal(900, alarm.Priority);
|
||||
Assert.Equal(Timestamp.FromDateTimeOffset(timestamp), alarm.Timestamp);
|
||||
|
||||
// Native enrichment mapped out.
|
||||
Assert.Equal("NativeMxAccess", alarm.Kind);
|
||||
Assert.True(alarm.Active);
|
||||
Assert.True(alarm.Acknowledged);
|
||||
Assert.Equal("OneShotShelved", alarm.ShelveState);
|
||||
Assert.False(alarm.Suppressed);
|
||||
Assert.Equal("T01.Hi", alarm.SourceReference);
|
||||
Assert.Equal("AnalogLimit.Hi", alarm.AlarmTypeName);
|
||||
Assert.Equal("Process", alarm.Category);
|
||||
Assert.Equal("op1", alarm.OperatorUser);
|
||||
Assert.Equal("ack", alarm.OperatorComment);
|
||||
Assert.Equal(Timestamp.FromDateTimeOffset(raiseTime), alarm.OriginalRaiseTime);
|
||||
Assert.Equal("92", alarm.CurrentValue);
|
||||
Assert.Equal("90", alarm.LimitValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class NativeAlarmSourceSchemaTests
|
||||
{
|
||||
private static ScadaBridgeDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
var ctx = new ScadaBridgeDbContext(opts);
|
||||
ctx.Database.OpenConnection();
|
||||
ctx.Database.EnsureCreated();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TemplateNativeAlarmSource_PersistsViaTemplate()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
|
||||
var t = new Template("T1");
|
||||
t.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src1") { ConnectionName = "C", SourceReference = "r" });
|
||||
ctx.Templates.Add(t);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
Assert.Single(ctx.TemplateNativeAlarmSources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstanceNativeAlarmSourceOverride_DbSetIsMapped()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Assert.Equal(0, await ctx.InstanceNativeAlarmSourceOverrides.CountAsync());
|
||||
}
|
||||
}
|
||||
+28
@@ -147,4 +147,32 @@ public class TemplateEngineRepositoryTests : IDisposable
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(baseTemplate.Id, loaded!.ParentTemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAndGetNativeAlarmSourcesByTemplateId_RoundTrips()
|
||||
{
|
||||
var t = new Template("T");
|
||||
_context.Templates.Add(t);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await _repository.AddTemplateNativeAlarmSourceAsync(
|
||||
new TemplateNativeAlarmSource("S") { TemplateId = t.Id, ConnectionName = "C", SourceReference = "r" });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var list = await _repository.GetNativeAlarmSourcesByTemplateIdAsync(t.Id);
|
||||
Assert.Single(list);
|
||||
Assert.Equal("S", list[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplateWithChildren_IncludesNativeAlarmSources()
|
||||
{
|
||||
var t = new Template("T");
|
||||
t.NativeAlarmSources.Add(new TemplateNativeAlarmSource("S") { ConnectionName = "C", SourceReference = "r" });
|
||||
_context.Templates.Add(t);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetTemplateWithChildrenAsync(t.Id);
|
||||
Assert.Single(loaded!.NativeAlarmSources);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,12 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
ct.ThrowIfCancellationRequested(); // …or FaultEventLoop() faults it to simulate a stream break
|
||||
}
|
||||
|
||||
public Task RunAlarmStreamAsync(
|
||||
string? alarmFilterPrefix,
|
||||
Action<ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition> onTransition,
|
||||
CancellationToken ct = default)
|
||||
=> Task.CompletedTask; // no alarm feed in the fake
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>Simulate a stream break so the adapter raises Disconnected.</summary>
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>Task-10: native alarm subscribe + source-ref routing + unavailable signal.</summary>
|
||||
public class DataConnectionActorAlarmTests : TestKit
|
||||
{
|
||||
private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>();
|
||||
private readonly IDataConnectionFactory _factory = Substitute.For<IDataConnectionFactory>();
|
||||
private readonly DataConnectionOptions _options = new()
|
||||
{
|
||||
ReconnectInterval = TimeSpan.FromMilliseconds(100),
|
||||
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200),
|
||||
WriteTimeout = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
|
||||
private static NativeAlarmTransition Raise(string sourceRef, string sourceObj) =>
|
||||
new(sourceRef, sourceObj, "AnalogLimit.Hi", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500),
|
||||
"Process", "hi", "hi", "", "", null, DateTimeOffset.UtcNow, "92", "90");
|
||||
|
||||
[Fact]
|
||||
public void SubscribeAlarms_RoutesTransitionToInstanceSubscriber()
|
||||
{
|
||||
AlarmTransitionCallback? cb = null;
|
||||
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
((IAlarmSubscribableConnection)adapter)
|
||||
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult("alarm-sub-1"));
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
||||
"conn", adapter, _options, _health, _factory, "OpcUa")));
|
||||
|
||||
actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", null, DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeAlarmsResponse>(m => m.Success);
|
||||
|
||||
Assert.NotNull(cb);
|
||||
cb!(Raise("Tank01.Hi", "Tank01"));
|
||||
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.SourceObjectReference == "Tank01");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeAlarms_OnNonAlarmCapableAdapter_RepliesFailure()
|
||||
{
|
||||
var adapter = Substitute.For<IDataConnection>(); // not IAlarmSubscribableConnection
|
||||
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
||||
"conn", adapter, _options, _health, _factory, "OpcUa")));
|
||||
|
||||
actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Tank01", null, DateTimeOffset.UtcNow));
|
||||
ExpectMsg<SubscribeAlarmsResponse>(m => !m.Success && m.ErrorMessage != null);
|
||||
}
|
||||
}
|
||||
+17
@@ -58,6 +58,23 @@ public class DataConnectionManagerActorTests : TestKit
|
||||
Assert.Contains("Unknown connection", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeAlarmsToUnknownConnection_ReturnsError()
|
||||
{
|
||||
// Regression for the live integration gap: the NativeAlarmActor sends
|
||||
// SubscribeAlarmsRequest to the DCL manager, which must route it to the
|
||||
// named connection actor (or reply with an error) — not dead-letter it.
|
||||
var manager = Sys.ActorOf(Props.Create(() =>
|
||||
new DataConnectionManagerActor(_mockFactory, _options, _mockHealthCollector)));
|
||||
|
||||
manager.Tell(new SubscribeAlarmsRequest(
|
||||
"corr1", "inst1", "nonexistent", "ns=2;s=Tank01", null, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<SubscribeAlarmsResponse>();
|
||||
Assert.False(response.Success);
|
||||
Assert.Contains("Unknown connection", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DCL002_ConnectionActorCrash_PreservesSubscriptionState()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
using CommonsTransitionKind = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTransitionKind;
|
||||
using ProtoConditionState = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState;
|
||||
using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>Task-12: pure MxGateway alarm-feed proto → NativeAlarmTransition mapping.</summary>
|
||||
public class MxGatewayAlarmMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapTransition_AckTransition_IsAcknowledgedWithOperator()
|
||||
{
|
||||
var ev = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = ProtoTransitionKind.Acknowledge,
|
||||
Severity = 600,
|
||||
OperatorUser = "operator1",
|
||||
OperatorComment = "ack",
|
||||
Category = "Process",
|
||||
Description = "hi"
|
||||
};
|
||||
|
||||
var t = MxGatewayAlarmMapper.MapTransition(ev);
|
||||
|
||||
Assert.Equal(CommonsTransitionKind.Acknowledge, t.Kind);
|
||||
Assert.True(t.Condition.Active);
|
||||
Assert.True(t.Condition.Acknowledged);
|
||||
Assert.Equal(600, t.Condition.Severity);
|
||||
Assert.Equal("operator1", t.OperatorUser);
|
||||
Assert.Equal("Tank01", t.SourceObjectReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapConditionState_ActiveAcked_To_ActiveTrue_AckTrue()
|
||||
{
|
||||
var c = MxGatewayAlarmMapper.MapConditionState(ProtoConditionState.ActiveAcked, severity: 600);
|
||||
Assert.True(c.Active);
|
||||
Assert.True(c.Acknowledged);
|
||||
Assert.Equal(600, c.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSnapshot_ActiveUnacked_IsSnapshotKind()
|
||||
{
|
||||
var snap = new ActiveAlarmSnapshot
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.Hi",
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.Hi",
|
||||
CurrentState = ProtoConditionState.Active,
|
||||
Severity = 1500 // out of range — must clamp
|
||||
};
|
||||
|
||||
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
|
||||
|
||||
Assert.Equal(CommonsTransitionKind.Snapshot, t.Kind);
|
||||
Assert.True(t.Condition.Active);
|
||||
Assert.False(t.Condition.Acknowledged);
|
||||
Assert.Equal(1000, t.Condition.Severity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 28: live smoke test for the OPC UA Alarms & Conditions adapter
|
||||
/// (<see cref="OpcUaDataConnection"/> as <c>IAlarmSubscribableConnection</c>).
|
||||
///
|
||||
/// Round-trips against an alarm-capable OPC UA endpoint
|
||||
/// (<c>opc.tcp://localhost:50000</c> — the infra OPC PLC server, see
|
||||
/// <c>infra/docker-compose.yml</c>). Marked <c>[SkippableFact]</c> so it reports
|
||||
/// Skipped — not failed — when the endpoint is unreachable OR does not expose
|
||||
/// Alarms & Conditions (no ConditionRefresh snapshot arrives). The OPC PLC
|
||||
/// simulator does not reliably expose A&C; see
|
||||
/// <c>docs/test_infra/test_infra.md</c> for the alarm-capable-server requirement.
|
||||
///
|
||||
/// The pure field→transition mapping is covered without a server by
|
||||
/// <see cref="Adapters.OpcUaAlarmMapperTests"/>; this test proves the live
|
||||
/// event-subscription + ConditionRefresh path end to end when infra supports it.
|
||||
/// </summary>
|
||||
[Trait("Category", "RequiresOpcUa")]
|
||||
public class OpcUaAlarmLiveSmokeTests
|
||||
{
|
||||
private const string EndpointUrl = "opc.tcp://localhost:50000";
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SubscribeAlarms_DeliversConditionRefreshSnapshot()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(_ => { });
|
||||
var clientFactory = new RealOpcUaClientFactory(new OpcUaGlobalOptions(), loggerFactory);
|
||||
var adapter = new OpcUaDataConnection(clientFactory, NullLogger<OpcUaDataConnection>.Instance);
|
||||
|
||||
// Probe the endpoint. An unreachable infra server surfaces a socket/timeout
|
||||
// error from deep in the OPC Foundation SDK — treat as "infra not available".
|
||||
try
|
||||
{
|
||||
await adapter.ConnectAsync(new Dictionary<string, string> { ["EndpointUrl"] = EndpointUrl });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Skip.If(true, $"OPC UA test server not reachable on {EndpointUrl}: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshotComplete = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void OnTransition(NativeAlarmTransition t)
|
||||
{
|
||||
if (t.Kind == AlarmTransitionKind.SnapshotComplete)
|
||||
{
|
||||
snapshotComplete.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Empty source reference = mirror every condition under the server's
|
||||
// event notifier; ConditionRefresh replays the active set then a
|
||||
// SnapshotComplete sentinel.
|
||||
await adapter.SubscribeAlarmsAsync(sourceReference: string.Empty, conditionFilter: null, OnTransition);
|
||||
|
||||
var done = await Task.WhenAny(snapshotComplete.Task, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||
|
||||
// Reachable but no A&C snapshot within the window → the server does not
|
||||
// expose Alarms & Conditions. Skip rather than fail.
|
||||
Skip.IfNot(done == snapshotComplete.Task,
|
||||
$"OPC UA endpoint {EndpointUrl} reachable but delivered no A&C ConditionRefresh snapshot — " +
|
||||
"server likely does not expose Alarms & Conditions.");
|
||||
|
||||
Assert.True(snapshotComplete.Task.IsCompletedSuccessfully);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await adapter.DisconnectAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>Task-11: pure OPC UA A&C field → AlarmConditionState/transition mapping.</summary>
|
||||
public class OpcUaAlarmMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizeSeverity_ClampsTo0_1000()
|
||||
{
|
||||
Assert.Equal(1000, OpcUaAlarmMapper.NormalizeSeverity(5000));
|
||||
Assert.Equal(0, OpcUaAlarmMapper.NormalizeSeverity(-1));
|
||||
Assert.Equal(500, OpcUaAlarmMapper.NormalizeSeverity(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCondition_ActiveUnacked()
|
||||
{
|
||||
var c = OpcUaAlarmMapper.BuildCondition(active: true, acked: false, confirmed: null,
|
||||
shelve: AlarmShelveState.Unshelved, suppressed: false, severity: 700);
|
||||
Assert.True(c.Active);
|
||||
Assert.False(c.Acknowledged);
|
||||
Assert.Equal(700, c.Severity);
|
||||
Assert.Equal(AlarmShelveState.Unshelved, c.Shelve);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveKind_AckEdge_YieldsAcknowledge()
|
||||
{
|
||||
Assert.Equal(AlarmTransitionKind.Acknowledge,
|
||||
OpcUaAlarmMapper.DeriveKind(prevAcked: false, nowAcked: true, prevActive: true, nowActive: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveKind_ActiveEdge_YieldsRaise()
|
||||
{
|
||||
Assert.Equal(AlarmTransitionKind.Raise,
|
||||
OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: false, nowActive: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveKind_InactiveEdge_YieldsClear()
|
||||
{
|
||||
Assert.Equal(AlarmTransitionKind.Clear,
|
||||
OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: true, nowActive: false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OneShotShelved", AlarmShelveState.OneShotShelved)]
|
||||
[InlineData("TimedShelved", AlarmShelveState.TimedShelved)]
|
||||
[InlineData("Unshelved", AlarmShelveState.Unshelved)]
|
||||
[InlineData(null, AlarmShelveState.Unshelved)]
|
||||
public void MapShelve_MapsCurrentStateName(string? name, AlarmShelveState expected)
|
||||
{
|
||||
Assert.Equal(expected, OpcUaAlarmMapper.MapShelve(name));
|
||||
}
|
||||
}
|
||||
@@ -1416,4 +1416,85 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
// earlier Modified row's Overwrite action.
|
||||
Assert.Equal(Commons.Types.Transport.ResolutionAction.Skip, dupResolutions[0].Action);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Native alarm source CRUD (Task 21)
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AddTemplateNativeAlarmSource_WithDesignRole_ReturnsSuccess()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
|
||||
"Design");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
_templateRepo.ReceivedWithAnyArgs(1).AddTemplateNativeAlarmSourceAsync(default!, default);
|
||||
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddTemplateNativeAlarmSource_WithDeploymentRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, null, false),
|
||||
"Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Design", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListTemplateNativeAlarmSources_ReturnsData()
|
||||
{
|
||||
_templateRepo.GetNativeAlarmSourcesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<TemplateNativeAlarmSource>
|
||||
{
|
||||
new("Pressure") { Id = 1, TemplateId = 1, ConnectionName = "Opc", SourceReference = "ns=2;s=T01" }
|
||||
});
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new ListTemplateNativeAlarmSourcesCommand(1));
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Pressure", response.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess()
|
||||
{
|
||||
// No prior override → Add path.
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
|
||||
"Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
_templateRepo.ReceivedWithAnyArgs(1).AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
|
||||
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInstanceNativeAlarmSourceOverride_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
|
||||
"Design");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Deployment", response.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,35 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
Assert.Equal(AlarmState.Normal, clearMsg.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_ComputedAlarm_CarriesUnifiedConditionState()
|
||||
{
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||
PriorityLevel = 700
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
null, _sharedLibrary, _options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmKind.Computed, msg.Kind); // computed origin
|
||||
Assert.Equal(msg.Priority, msg.Condition.Severity); // severity tracks priority
|
||||
Assert.Equal(700, msg.Condition.Severity);
|
||||
Assert.True(msg.Condition.Active); // active alarm → condition active
|
||||
Assert.True(msg.Condition.Acknowledged); // computed alarms are auto-acked
|
||||
Assert.Equal(string.Empty, msg.SourceReference); // no native source reference
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmActor_RangeViolation_ActivatesOutsideRange()
|
||||
{
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
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 ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Task 16: InstanceActor spawns NativeAlarmActor children from the flattened
|
||||
/// configuration and surfaces enriched native alarm state in the DebugView snapshot.
|
||||
/// </summary>
|
||||
public class InstanceActorNativeAlarmTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly SiteRuntimeOptions _options = new();
|
||||
private readonly string _dbFile;
|
||||
|
||||
public InstanceActorNativeAlarmTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-native-{Guid.NewGuid():N}.db");
|
||||
_storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, IActorRef dclManager) =>
|
||||
ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dclManager)));
|
||||
|
||||
private static FlattenedConfiguration ConfigWithNativeSource(string instanceName) => new()
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
NativeAlarmSources =
|
||||
[
|
||||
new ResolvedNativeAlarmSource
|
||||
{
|
||||
CanonicalName = "Pressure",
|
||||
ConnectionName = "Opc",
|
||||
SourceReference = "ns=2;s=T01"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SpawnsNativeAlarmActor_WhichSubscribesViaDcl()
|
||||
{
|
||||
var dcl = CreateTestProbe();
|
||||
CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref);
|
||||
|
||||
var req = dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
Assert.Equal("inst", req.InstanceUniqueName);
|
||||
Assert.Equal("Opc", req.ConnectionName);
|
||||
Assert.Equal("ns=2;s=T01", req.SourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugViewSnapshot_IncludesNativeAlarm_AfterTransition()
|
||||
{
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref);
|
||||
|
||||
// Simulate the NativeAlarmActor emitting an enriched event upward.
|
||||
actor.Tell(new AlarmStateChanged("inst", "T01.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Kind = AlarmKind.NativeOpcUa,
|
||||
SourceReference = "T01.Hi",
|
||||
Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800)
|
||||
});
|
||||
|
||||
actor.Tell(new SubscribeDebugViewRequest("inst", "c"));
|
||||
var snap = ExpectMsg<DebugViewSnapshot>();
|
||||
|
||||
Assert.Contains(snap.AlarmStates, a =>
|
||||
a.SourceReference == "T01.Hi" && a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
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 ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Task 15: NativeAlarmActor mirrors a source's native alarms — subscribe on start,
|
||||
/// emit enriched AlarmStateChanged, snapshot atomic swap, retention, persistence.
|
||||
/// </summary>
|
||||
public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly string _dbFile;
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly SiteRuntimeOptions _options = new();
|
||||
|
||||
public NativeAlarmActorTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"naa-{Guid.NewGuid():N}.db");
|
||||
_storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private static ResolvedNativeAlarmSource Source() => new()
|
||||
{
|
||||
CanonicalName = "Pressure",
|
||||
ConnectionName = "Opc",
|
||||
SourceReference = "ns=2;s=T01"
|
||||
};
|
||||
|
||||
private static NativeAlarmTransition Transition(
|
||||
string sourceRef, AlarmTransitionKind kind, AlarmConditionState condition, DateTimeOffset? time = null) =>
|
||||
new(sourceRef, "T01", "AnalogLimit.Hi", kind, condition,
|
||||
"Process", "hi", "hi", "", "", null, time ?? DateTimeOffset.UtcNow, "92", "90");
|
||||
|
||||
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager) =>
|
||||
ActorOf(Props.Create(() => new NativeAlarmActor(
|
||||
Source(), "inst", instanceActor, dclManager, _storage, _options, NullLogger<NativeAlarmActor>.Instance)));
|
||||
|
||||
[Fact]
|
||||
public void SubscribeOnStart_SendsRequestForSourceBinding()
|
||||
{
|
||||
var dcl = CreateTestProbe();
|
||||
Spawn(CreateTestProbe().Ref, dcl.Ref);
|
||||
|
||||
var req = dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
Assert.Equal("inst", req.InstanceUniqueName);
|
||||
Assert.Equal("Opc", req.ConnectionName);
|
||||
Assert.Equal("ns=2;s=T01", req.SourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Raise_EmitsEnrichedAlarmStateChanged()
|
||||
{
|
||||
var instance = CreateTestProbe();
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = Spawn(instance.Ref, dcl.Ref);
|
||||
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
||||
|
||||
var emitted = instance.ExpectMsg<AlarmStateChanged>();
|
||||
Assert.Equal(AlarmKind.NativeOpcUa, emitted.Kind);
|
||||
Assert.Equal("T01.Hi", emitted.SourceReference);
|
||||
Assert.Equal(AlarmState.Active, emitted.State);
|
||||
Assert.Equal(800, emitted.Condition.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotComplete_WithMissingPriorAlarm_EmitsReturnToNormal()
|
||||
{
|
||||
var instance = CreateTestProbe();
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = Spawn(instance.Ref, dcl.Ref);
|
||||
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
||||
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
||||
|
||||
// A fresh snapshot that no longer contains T01.Hi means it cleared at the source.
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.SnapshotComplete,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
||||
|
||||
var cleared = instance.ExpectMsg<AlarmStateChanged>();
|
||||
Assert.Equal("T01.Hi", cleared.SourceReference);
|
||||
Assert.Equal(AlarmState.Normal, cleared.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OlderTransition_IsIgnored()
|
||||
{
|
||||
var instance = CreateTestProbe();
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = Spawn(instance.Ref, dcl.Ref);
|
||||
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
|
||||
var t0 = DateTimeOffset.UtcNow;
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
|
||||
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
||||
|
||||
// An out-of-order (older) transition must not overwrite newer state.
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.Clear,
|
||||
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(-30))));
|
||||
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
if (File.Exists(_dbFile))
|
||||
{
|
||||
File.Delete(_dbFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Task 14: site-local SQLite <c>native_alarm_state</c> store — mirrored native alarm
|
||||
/// condition snapshots keyed by (instance, source canonical name, source reference).
|
||||
/// </summary>
|
||||
public class NativeAlarmStateStoreTests : IAsyncLifetime, IDisposable
|
||||
{
|
||||
private readonly string _dbFile;
|
||||
private SiteStorageService _storage = null!;
|
||||
|
||||
public NativeAlarmStateStoreTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"nas-{Guid.NewGuid():N}.db");
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger<SiteStorageService>.Instance);
|
||||
await _storage.InitializeAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_Then_Get_RoundTrips()
|
||||
{
|
||||
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{\"Active\":true}", DateTimeOffset.UnixEpoch);
|
||||
|
||||
var rows = await _storage.GetNativeAlarmsAsync("inst", "Src");
|
||||
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("Tank01.Hi", rows[0].SourceReference);
|
||||
Assert.Equal("{\"Active\":true}", rows[0].ConditionJson);
|
||||
Assert.Equal(DateTimeOffset.UnixEpoch, rows[0].LastTransitionAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_SameKey_ReplacesConditionAndTimestamp()
|
||||
{
|
||||
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{\"Active\":true}", DateTimeOffset.UnixEpoch);
|
||||
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{\"Active\":false}", DateTimeOffset.UnixEpoch.AddMinutes(5));
|
||||
|
||||
var rows = await _storage.GetNativeAlarmsAsync("inst", "Src");
|
||||
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("{\"Active\":false}", rows[0].ConditionJson);
|
||||
Assert.Equal(DateTimeOffset.UnixEpoch.AddMinutes(5), rows[0].LastTransitionAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_ScopesToInstanceAndSourceCanonicalName()
|
||||
{
|
||||
await _storage.UpsertNativeAlarmAsync("inst", "SrcA", "Tank01.Hi", "{}", DateTimeOffset.UnixEpoch);
|
||||
await _storage.UpsertNativeAlarmAsync("inst", "SrcB", "Tank02.Hi", "{}", DateTimeOffset.UnixEpoch);
|
||||
await _storage.UpsertNativeAlarmAsync("other", "SrcA", "Tank09.Hi", "{}", DateTimeOffset.UnixEpoch);
|
||||
|
||||
var rows = await _storage.GetNativeAlarmsAsync("inst", "SrcA");
|
||||
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("Tank01.Hi", rows[0].SourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RemovesSingleRow()
|
||||
{
|
||||
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Hi", "{}", DateTimeOffset.UnixEpoch);
|
||||
await _storage.UpsertNativeAlarmAsync("inst", "Src", "Tank01.Lo", "{}", DateTimeOffset.UnixEpoch);
|
||||
|
||||
await _storage.DeleteNativeAlarmAsync("inst", "Src", "Tank01.Hi");
|
||||
|
||||
var rows = await _storage.GetNativeAlarmsAsync("inst", "Src");
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("Tank01.Lo", rows[0].SourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearForInstance_RemovesAllSourcesForInstanceOnly()
|
||||
{
|
||||
await _storage.UpsertNativeAlarmAsync("inst", "SrcA", "Tank01.Hi", "{}", DateTimeOffset.UnixEpoch);
|
||||
await _storage.UpsertNativeAlarmAsync("inst", "SrcB", "Tank02.Hi", "{}", DateTimeOffset.UnixEpoch);
|
||||
await _storage.UpsertNativeAlarmAsync("other", "SrcA", "Tank09.Hi", "{}", DateTimeOffset.UnixEpoch);
|
||||
|
||||
await _storage.ClearNativeAlarmsForInstanceAsync("inst");
|
||||
|
||||
Assert.Empty(await _storage.GetNativeAlarmsAsync("inst", "SrcA"));
|
||||
Assert.Empty(await _storage.GetNativeAlarmsAsync("inst", "SrcB"));
|
||||
Assert.Single(await _storage.GetNativeAlarmsAsync("other", "SrcA"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbFile))
|
||||
{
|
||||
File.Delete(_dbFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -666,4 +666,38 @@ public class FlatteningServiceTests
|
||||
// Parent module of a depth-2 script is the enclosing Pump module.
|
||||
Assert.Equal("MainPump", depth2.Scope.ParentPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_ResolvesNativeAlarmSources_FromTemplate()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Pressure")
|
||||
{ ConnectionName = "Opc", SourceReference = "ns=2;s=P1" });
|
||||
var result = _sut.Flatten(CreateInstance(), [template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Single(result.Value.NativeAlarmSources);
|
||||
Assert.Equal("Pressure", result.Value.NativeAlarmSources[0].CanonicalName);
|
||||
Assert.Equal("ns=2;s=P1", result.Value.NativeAlarmSources[0].SourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_InstanceOverride_ReplacesNativeAlarmSourceReference()
|
||||
{
|
||||
var template = CreateTemplate(1, "Base");
|
||||
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Pressure")
|
||||
{ ConnectionName = "Opc", SourceReference = "ns=2;s=DEFAULT" });
|
||||
var instance = CreateInstance();
|
||||
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Pressure")
|
||||
{ SourceReferenceOverride = "ns=2;s=Tank07" });
|
||||
var result = _sut.Flatten(instance, [template],
|
||||
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
|
||||
new Dictionary<int, IReadOnlyList<Template>>(),
|
||||
new Dictionary<int, DataConnection>());
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("ns=2;s=Tank07", result.Value.NativeAlarmSources[0].SourceReference);
|
||||
Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,42 @@ public class SemanticValidatorTests
|
||||
{
|
||||
private readonly SemanticValidator _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_NativeAlarmSource_UnknownConnection_ReturnsError()
|
||||
{
|
||||
var cfg = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "Ghost", SourceReference = "x" }]
|
||||
};
|
||||
var result = _sut.Validate(cfg, alarmCapableConnectionNames: new HashSet<string> { "RealConn" });
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NativeAlarmSource_EmptySourceRef_ReturnsError()
|
||||
{
|
||||
var cfg = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "RealConn", SourceReference = "" }]
|
||||
};
|
||||
var result = _sut.Validate(cfg, alarmCapableConnectionNames: new HashSet<string> { "RealConn" });
|
||||
Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NativeAlarmSource_ValidBinding_NoError()
|
||||
{
|
||||
var cfg = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Instance1",
|
||||
NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "RealConn", SourceReference = "ns=2;s=T1" }]
|
||||
};
|
||||
var result = _sut.Validate(cfg, alarmCapableConnectionNames: new HashSet<string> { "RealConn" });
|
||||
Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CallScriptTargetNotFound_ReturnsError()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user