Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f6655de27 | |||
| fbc9cf56df | |||
| 4c0e14fc5d | |||
| c75920c620 | |||
| a46ce90e6f | |||
| f113ca53a1 | |||
| f3616cc7fa | |||
| 57d5a8725f | |||
| 60d35a914f | |||
| b10e103bcf | |||
| 348ab16456 | |||
| c16f016f0a | |||
| 1d85db7b4e | |||
| 5ea5618315 | |||
| 38a0ad8ab4 | |||
| 5df2ef0d1e | |||
| e5785fd769 | |||
| 22370ca4da | |||
| e0a3fbf35b | |||
| 161ed6f80d |
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
|
|||||||
## Design Sources To Consult Before Non-Trivial Changes
|
## Design Sources To Consult Before Non-Trivial Changes
|
||||||
|
|
||||||
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
||||||
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=zb,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
- `glauth.md` — shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) and the role→capability mapping live there.
|
||||||
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||||
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
||||||
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# Alarm Subtag-Monitoring Fallback — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Approved (brainstorming), ready for implementation planning
|
||||||
|
**Branch:** `feat/alarm-subtag-fallback`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The gateway's central alarm feed (`GatewayAlarmMonitor` → worker
|
||||||
|
`WnWrapAlarmConsumer`) depends on the AVEVA wnwrap COM consumer
|
||||||
|
(`WNWRAPCONSUMERLib.wwAlarmConsumerClass`), which polls `GetXmlCurrentAlarms2`
|
||||||
|
on the worker STA. That provider can fail at the COM boundary (the older
|
||||||
|
`aaAlarmManagedClient` crashed on FILETIME marshaling; wnwrap can still return
|
||||||
|
failure HRESULTs or throw `COMException`). When it does, the gateway loses all
|
||||||
|
alarm visibility.
|
||||||
|
|
||||||
|
This design adds a **second alarm source** — direct monitoring of each alarm
|
||||||
|
attribute's subtags (`.active`, `.acked`, …) via the existing MXAccess
|
||||||
|
`AddItem`/`Advise` pipeline — and **fails over to it automatically when the
|
||||||
|
wnwrap provider breaks, then fails back automatically when it recovers**. The
|
||||||
|
subtag source can also be forced on by config.
|
||||||
|
|
||||||
|
## Decisions (locked during brainstorming)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Failover model | **Auto-failover + auto-failback** (both directions, runtime) |
|
||||||
|
| Watch-list source | **Galaxy Repository SQL discovery + config override** |
|
||||||
|
| Acknowledge in subtag mode | **Write the operator comment to the alarm's ack-comment subtag** (the write performs the ack) |
|
||||||
|
| Failure signal | **N consecutive wnwrap COM failures** (Subscribe / `GetXmlCurrentAlarms2` throws or returns a failure HRESULT) |
|
||||||
|
| Degraded-state visibility | **Both** — explicit field in the gRPC contract **and** dashboard + metrics |
|
||||||
|
| Synthesis location | **Worker-side** (`Approach A`) — keeps the parity rule "the gateway forwards only events the worker emits; it never synthesizes events" |
|
||||||
|
|
||||||
|
## Core principle
|
||||||
|
|
||||||
|
Subtag monitoring is, by definition, a **non-parity, lower-fidelity** alarm
|
||||||
|
source: it synthesizes alarm transitions from raw data changes, has no native
|
||||||
|
alarm GUID, no native original-raise timestamp, and a narrower field set. Per
|
||||||
|
`CLAUDE.md`, synthesizing events is allowed only as an explicit opt-in
|
||||||
|
non-parity mode. This design satisfies that by (a) doing the synthesis **inside
|
||||||
|
the worker** (so the gateway still only forwards worker-emitted events) and
|
||||||
|
(b) marking every degraded event and the whole feed as degraded so no client
|
||||||
|
mistakes it for the authoritative alarmmgr feed.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
GATEWAY (.NET 10, x64)
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ GatewayAlarmMonitor (BackgroundService) │
|
||||||
|
│ • resolves watch-list: Galaxy Repository SQL + config override │
|
||||||
|
│ • arms the worker with the watch-list at subscribe time │
|
||||||
|
│ • consumes AlarmProviderModeChanged → reflects mode into feed, │
|
||||||
|
│ /hubs/alarms dashboard hub, and metrics │
|
||||||
|
│ • forces a cache reconcile (QueryActiveAlarms) on every switch │
|
||||||
|
└───────────────────────────────┬───────────────────────────────────┘
|
||||||
|
│ IPC (WorkerEnvelope frames)
|
||||||
|
│ · SubscribeAlarms{ watch_list, failover cfg }
|
||||||
|
│ · AlarmProviderModeChanged{ mode, reason, hresult }
|
||||||
|
│ · OnAlarmTransitionEvent (degraded flag set in subtag mode)
|
||||||
|
▼
|
||||||
|
WORKER (.NET FW 4.8, x86, STA)
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AlarmDispatcher → FailoverAlarmConsumer : IMxAccessAlarmConsumer │
|
||||||
|
│ ├─ primary : WnWrapAlarmConsumer (wnwrap COM poll, unchanged) │
|
||||||
|
│ └─ standby : SubtagAlarmConsumer (AddItem/Advise on subtags) │
|
||||||
|
│ │
|
||||||
|
│ FailoverAlarmConsumer owns the state machine: │
|
||||||
|
│ PrimaryActive ──(N consecutive wnwrap COM failures)──▶ Degraded │
|
||||||
|
│ Degraded ──(M consecutive clean wnwrap probe polls)──▶ Primary │
|
||||||
|
│ on each switch: snapshot the now-active provider, hand off │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The failover state machine lives **worker-local** so the switch is instant — no
|
||||||
|
IPC round-trip at the moment alarmmgr dies. The gateway *arms* the standby
|
||||||
|
consumer up front (passes the watch-list at subscribe time) so it is ready
|
||||||
|
before it is ever needed.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Worker (`src/ZB.MOM.WW.MxGateway.Worker/MxAccess/`)
|
||||||
|
|
||||||
|
**`SubtagAlarmConsumer : IMxAccessAlarmConsumer` (new)** — the standby provider.
|
||||||
|
|
||||||
|
- On `Subscribe`, instead of wnwrap registration it `AddItem`/`Advise`s the
|
||||||
|
configured subtags for each watch-list entry on the existing STA (reuses the
|
||||||
|
worker's item-subscription machinery). Per attribute it advises at minimum
|
||||||
|
`.active` and `.acked`; optionally `.priority`/severity, `.descr`, value/limit
|
||||||
|
if present.
|
||||||
|
- Converts each `OnDataChange` into the same `MxAlarmTransitionEvent` the wnwrap
|
||||||
|
consumer emits, via the synthesis rules below, and raises
|
||||||
|
`AlarmTransitionEmitted`. Marks each as **degraded**.
|
||||||
|
- `SnapshotActiveAlarms()` returns the currently-active set computed from
|
||||||
|
last-known subtag values.
|
||||||
|
- `AcknowledgeByName(...)` resolves the watch-list entry's ack-comment subtag and
|
||||||
|
issues a `Write(comment)` on the STA. `AcknowledgeByGuid(...)` maps the
|
||||||
|
synthetic GUID (see below) back to a reference, then does the same. If the
|
||||||
|
attribute exposes no writable ack-comment subtag, returns a failure code that
|
||||||
|
the gateway surfaces as `FailedPrecondition`.
|
||||||
|
- `PollOnce()` is a no-op (subtag mode is event-driven via Advise).
|
||||||
|
|
||||||
|
**`FailoverAlarmConsumer : IMxAccessAlarmConsumer` (new)** — composite + state
|
||||||
|
machine. Owns the wnwrap consumer (primary) and the subtag consumer (standby),
|
||||||
|
forwards `AlarmTransitionEmitted` from whichever child is active, and raises a
|
||||||
|
new `ProviderModeChanged` event on every switch.
|
||||||
|
|
||||||
|
- **Failure counting:** wraps `Subscribe`/`PollOnce` on the primary; a thrown
|
||||||
|
`COMException` or a failure HRESULT increments a consecutive-failure counter,
|
||||||
|
reset to zero on any clean poll.
|
||||||
|
- **Failover** (`PrimaryActive → Degraded`): at `ConsecutiveFailureThreshold`
|
||||||
|
(default 3), ensures the standby is subscribed (it was armed at startup), sets
|
||||||
|
active = standby, snapshots the standby's active set for hand-off, and emits
|
||||||
|
`ProviderModeChanged(SUBTAG, reason, hresult)`.
|
||||||
|
- **Failback probe** (`Degraded → PrimaryActive`): while degraded, every
|
||||||
|
`FailbackProbeIntervalSeconds` (default 30) it re-attempts wnwrap
|
||||||
|
`Subscribe`+`PollOnce` on the STA. After `FailbackStableProbes` (default 3)
|
||||||
|
consecutive clean polls it switches active = primary, returns the standby to
|
||||||
|
standby, and emits `ProviderModeChanged(ALARMMGR, "recovered")`.
|
||||||
|
- **Hand-off:** on every switch it takes `SnapshotActiveAlarms()` from the
|
||||||
|
now-active provider so the gateway can reconcile and avoid spurious
|
||||||
|
raise/clear storms.
|
||||||
|
|
||||||
|
**`AlarmDispatcher` / `MxAccessAlarmEventSink` / `AlarmCommandHandler`
|
||||||
|
(changed, minimal)** — `AlarmDispatcher` holds a `FailoverAlarmConsumer` instead
|
||||||
|
of a bare `WnWrapAlarmConsumer`; it subscribes to `ProviderModeChanged` and
|
||||||
|
enqueues a mode-changed worker event. The ack path routes by active mode (native
|
||||||
|
wnwrap ack in alarmmgr mode; ack-comment write in subtag mode), but that routing
|
||||||
|
is entirely inside the consumer — the dispatcher just calls
|
||||||
|
`AcknowledgeByName`/`AcknowledgeByGuid`.
|
||||||
|
|
||||||
|
### Gateway (`src/ZB.MOM.WW.MxGateway.Server/`)
|
||||||
|
|
||||||
|
**Galaxy Repository discovery (new query)** — alongside the existing GR SQL
|
||||||
|
browse RPCs, a query "attributes that have alarms configured, with their
|
||||||
|
ack-comment subtag and area", scoped to the configured area. Merged with the
|
||||||
|
config override (explicit includes/excludes). Produces the watch-list of
|
||||||
|
`AlarmSubtagTarget`s.
|
||||||
|
|
||||||
|
**`GatewayAlarmMonitor` (changed)** — resolves the watch-list at subscribe time
|
||||||
|
and passes it to the worker; consumes `AlarmProviderModeChanged` and reflects
|
||||||
|
the current provider mode into (a) the `AlarmFeedMessage` provider-status,
|
||||||
|
(b) the `/hubs/alarms` dashboard hub, and (c) metrics; forces a reconcile
|
||||||
|
(`QueryActiveAlarms`) on every switch. Re-runs discovery on its existing
|
||||||
|
reconcile cadence and pushes an updated watch-list when the model changes.
|
||||||
|
|
||||||
|
**`AlarmsOptions` (extended)** — new `Fallback` sub-section (below).
|
||||||
|
|
||||||
|
### Contract (`src/ZB.MOM.WW.MxGateway.Contracts/Protos/`)
|
||||||
|
|
||||||
|
**`mxaccess_gateway.proto`:**
|
||||||
|
|
||||||
|
- `enum AlarmProviderMode { ALARM_PROVIDER_MODE_UNSPECIFIED = 0; ALARMMGR = 1; SUBTAG = 2; }`
|
||||||
|
- New `AlarmFeedMessage` oneof case `AlarmProviderStatus provider_status`,
|
||||||
|
carrying `{ AlarmProviderMode mode; bool degraded; string reason;
|
||||||
|
google.protobuf.Timestamp since; }`. Emitted on stream open and on every
|
||||||
|
change so a late-joining client immediately learns the mode.
|
||||||
|
- Add `bool degraded` + `AlarmProviderMode source_provider` to
|
||||||
|
`OnAlarmTransitionEvent` **and** `ActiveAlarmSnapshot`, so per-item provenance
|
||||||
|
is visible even mid-stream. All additions are new field numbers — backward
|
||||||
|
compatible; existing clients ignore them and keep seeing alarms.
|
||||||
|
|
||||||
|
**`mxaccess_worker.proto`:**
|
||||||
|
|
||||||
|
- Extend the alarm-subscribe command with: `AlarmProviderMode forced_mode`
|
||||||
|
(`UNSPECIFIED` = auto), `int32 consecutive_failure_threshold`,
|
||||||
|
`int32 failback_probe_interval_seconds`, `int32 failback_stable_probes`, and
|
||||||
|
`repeated AlarmSubtagTarget watch_list`, where `AlarmSubtagTarget =
|
||||||
|
{ string alarm_full_reference; string source_object_reference;
|
||||||
|
string active_subtag; string acked_subtag; string ack_comment_subtag;
|
||||||
|
string priority_subtag; }`.
|
||||||
|
- New worker→gateway event `AlarmProviderModeChanged { AlarmProviderMode mode;
|
||||||
|
string reason; int32 hresult; google.protobuf.Timestamp at; }`.
|
||||||
|
|
||||||
|
> Generated code under `Generated/` and `clients/*/generated*/` is rebuilt from
|
||||||
|
> these `.proto` files — never hand-edited. Every generated client touched by
|
||||||
|
> the contract is rebuilt per the source-update workflow.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
### Subtag synthesis rules
|
||||||
|
|
||||||
|
`SubtagAlarmConsumer` keeps last-known `(active, acked)` per watch-list entry and
|
||||||
|
emits transitions on change:
|
||||||
|
|
||||||
|
| Subtag change | Emitted transition | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `active` false → true | `RAISE` (state `UNACK_ALM`) | `original_raise_timestamp` = first-observed active time |
|
||||||
|
| `acked` false → true while `active` | `ACKNOWLEDGE` | `operator_user`/`operator_comment` from ack-comment subtag if advised |
|
||||||
|
| `active` true → false | `CLEAR` | maps to `AckRtn` if acked at clear, else `UnackRtn` |
|
||||||
|
| `active` stays true, re-alarm | `RETRIGGER` | **only** if a re-alarm counter subtag exists; otherwise not synthesized (documented limitation) |
|
||||||
|
|
||||||
|
Snapshot state mapping for `ActiveAlarmSnapshot.current_state`:
|
||||||
|
`active && !acked → ACTIVE`, `active && acked → ACTIVE_ACKED`,
|
||||||
|
`!active → INACTIVE`.
|
||||||
|
|
||||||
|
Field degradation in subtag mode:
|
||||||
|
- `alarm_full_reference` — from the watch-list entry (stable, drives ack-by-ref).
|
||||||
|
- Synthetic, deterministic GUID derived by hashing `alarm_full_reference` so
|
||||||
|
GUID-based ack still resolves; flagged `degraded = true`.
|
||||||
|
- `severity` — from the priority subtag if advised, else 0.
|
||||||
|
- `original_raise_timestamp` — first-observed active time (best effort).
|
||||||
|
- `transition_timestamp` — the `OnDataChange` timestamp.
|
||||||
|
- `category`/`description`/`current_value`/`limit_value` — populated only if the
|
||||||
|
corresponding subtag is advised; otherwise empty.
|
||||||
|
|
||||||
|
### Acknowledge
|
||||||
|
|
||||||
|
`AcknowledgeAlarm`/`AcknowledgeAlarmByName` are unchanged at the RPC surface.
|
||||||
|
`AlarmDispatcher` routes by active provider mode:
|
||||||
|
- **alarmmgr mode:** native wnwrap `AlarmAckByName`/`AlarmAckByGUID` (unchanged).
|
||||||
|
- **subtag mode:** resolve the target's `ack_comment_subtag`, `Write` the
|
||||||
|
operator comment via the existing worker write path on the STA. No writable
|
||||||
|
ack-comment subtag → `FailedPrecondition`.
|
||||||
|
|
||||||
|
### Provider-mode reflection
|
||||||
|
|
||||||
|
Worker `AlarmProviderModeChanged` → `GatewayAlarmMonitor` → (a) emit/refresh
|
||||||
|
`AlarmFeedMessage.provider_status` to every `StreamAlarms` subscriber, (b) push
|
||||||
|
to `/hubs/alarms`, (c) update metrics, (d) force a reconcile.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- **Both providers down** (subtag advise also failing): the monitor stays
|
||||||
|
faulted and keeps retrying both; acknowledge returns `Unavailable`. No silent
|
||||||
|
data loss — the feed reports degraded with reason.
|
||||||
|
- **Empty watch-list in subtag mode** (GR SQL unavailable, no config override):
|
||||||
|
log + metric `alarm_fallback_watchlist_empty`; the feed reports degraded +
|
||||||
|
empty; the gateway keeps re-running discovery on its reconcile cadence and
|
||||||
|
pushes an updated watch-list when one becomes available.
|
||||||
|
- **Switch hand-off:** every switch snapshots the now-active provider and
|
||||||
|
reconciles against the gateway cache to avoid a raise/clear storm.
|
||||||
|
- **STA affinity:** all subtag advise/write and wnwrap probe calls run on the
|
||||||
|
worker STA (reuse the existing affinity guard) to satisfy
|
||||||
|
`ThreadingModel=Apartment`.
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
- `mxgateway_alarm_provider_mode` (gauge: 1 = alarmmgr, 2 = subtag)
|
||||||
|
- `mxgateway_alarm_provider_switch_total{from,to,reason}` (counter)
|
||||||
|
- `mxgateway_alarm_fallback_watchlist_size` (gauge)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"MxGateway": {
|
||||||
|
"Alarms": {
|
||||||
|
"Enabled": true,
|
||||||
|
"SubscriptionExpression": "\\\\DESKTOP-6JL3KKO\\Galaxy!DEV",
|
||||||
|
"DefaultArea": "DEV",
|
||||||
|
"ReconcileIntervalSeconds": 30,
|
||||||
|
"Fallback": {
|
||||||
|
"Mode": "Auto", // Auto | ForceAlarmManager | ForceSubtag
|
||||||
|
"ConsecutiveFailureThreshold": 3,
|
||||||
|
"FailbackProbeIntervalSeconds": 30,
|
||||||
|
"FailbackStableProbes": 3,
|
||||||
|
"Discovery": {
|
||||||
|
"UseGalaxyRepository": true,
|
||||||
|
"Area": "", // defaults to Alarms.DefaultArea
|
||||||
|
"IncludeAttributes": [], // explicit additions
|
||||||
|
"ExcludeAttributes": []
|
||||||
|
},
|
||||||
|
"Subtags": {
|
||||||
|
"Active": "active",
|
||||||
|
"Acked": "acked",
|
||||||
|
"AckComment": "", // verified against MXAccess analysis
|
||||||
|
"Priority": "priority"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GatewayOptionsValidator` additions: `Mode = ForceSubtag` with empty discovery
|
||||||
|
result and no explicit `IncludeAttributes` → startup validation warning;
|
||||||
|
threshold/interval/probe values floored at sane minimums.
|
||||||
|
|
||||||
|
## Open item to confirm during implementation
|
||||||
|
|
||||||
|
The exact AVEVA subtag names (`.active`, `.acked`, the ack-comment attribute,
|
||||||
|
priority) must be confirmed against the MXAccess analysis project
|
||||||
|
(`C:\Users\dohertj2\Desktop\mxaccess`, `docs/MXAccess-Public-API.md`) and the
|
||||||
|
live Galaxy before wiring `SubtagAlarmConsumer`. The config `Subtags` block
|
||||||
|
exists precisely so the resolved names are not hard-coded.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
| Layer | Tests |
|
||||||
|
|---|---|
|
||||||
|
| Worker unit (`MxGateway.Worker.Tests`, x86) | `SubtagAlarmConsumer` synthesis — feed `OnDataChange` sequences, assert raise/ack/clear transitions, snapshot states, degraded flag, synthetic-GUID stability, ack-comment write routing |
|
||||||
|
| Worker unit | `FailoverAlarmConsumer` state machine — fake wnwrap throwing after K polls: assert switch at threshold, failback after stable probes, `ProviderModeChanged` emitted, no duplicate transitions across switch (hand-off reconcile) |
|
||||||
|
| Gateway unit (`MxGateway.Tests`, fake worker) | discovery + config-override merge; `GatewayAlarmMonitor` reflects mode into feed + hub; metrics increment on switch |
|
||||||
|
| Contract | proto round-trip for new fields; existing alarm tests unchanged (alarmmgr-mode regression — parity preserved) |
|
||||||
|
| Live (opt-in, `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`) | real subtag advise + ack-comment write against a live alarm; GR SQL discovery query against the `ZB` DB (gated like existing GR tests) |
|
||||||
|
|
||||||
|
## Docs to update in the same change
|
||||||
|
|
||||||
|
`gateway.md` (alarm provider section), `docs/DesignDecisions.md` (record the
|
||||||
|
fallback decision), `docs/GatewayConfiguration.md` (the `Fallback` block),
|
||||||
|
`docs/AlarmClientDiscovery.md` (subtag provider + synthesis rules),
|
||||||
|
`docs/Grpc.md` (the new `provider_status` / `degraded` fields), and any client
|
||||||
|
READMEs whose generated alarm types gain fields.
|
||||||
@@ -0,0 +1,860 @@
|
|||||||
|
# Alarm Subtag-Monitoring Fallback — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a second alarm source — direct MXAccess subtag monitoring — that the gateway auto-fails-over to when the wnwrap alarmmgr provider breaks, auto-fails-back to when it recovers, and can be forced on by config.
|
||||||
|
|
||||||
|
**Architecture:** Worker-side synthesis (parity rule preserved). A new `SubtagAlarmConsumer` (own `LMXProxyServerClass`, `AddItem`/`Advise` on alarm subtags) and a `FailoverAlarmConsumer` composite (state machine over the wnwrap primary + subtag standby) both implement the existing `IMxAccessAlarmConsumer` seam. The gateway resolves the subtag watch-list (Galaxy Repository SQL + config override), arms the worker at subscribe time, and reflects the live provider mode into the gRPC alarm feed, the dashboard hub, and metrics.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10 (gateway, x64) + .NET Framework 4.8 (worker, x86, STA), protobuf/gRPC, `Microsoft.Data.SqlClient` (Galaxy Repository), SignalR (dashboard), `System.Diagnostics.Metrics`, xUnit (plain `Assert`, no FluentAssertions).
|
||||||
|
|
||||||
|
**Design source:** `docs/plans/2026-06-13-alarm-subtag-fallback-design.md`
|
||||||
|
|
||||||
|
**Branch:** `feat/alarm-subtag-fallback` (already created)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions for every task
|
||||||
|
|
||||||
|
- **TDD:** write the failing test, run it red, implement, run it green, commit.
|
||||||
|
- **xUnit, plain `Assert.*`**, naming `Subject_Condition_Expected`. Worker fakes are sealed private nested classes that raise events.
|
||||||
|
- **Build/test commands:**
|
||||||
|
- Contracts regen: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
||||||
|
- Gateway: `dotnet build src/ZB.MOM.WW.MxGateway.Server` ; `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj`
|
||||||
|
- Worker (x86): `dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86` ; `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86`
|
||||||
|
- Single test: append `--filter FullyQualifiedName~<ClassOrMethod>`
|
||||||
|
- **Build is strict:** `TreatWarningsAsErrors=true`, nullable enabled. Add XML doc comments on public members (the repo runs a doc checker).
|
||||||
|
- **Generated code** under `Generated/` is never hand-edited — rebuild the contracts project to regenerate.
|
||||||
|
- **Namespaces:** worker MxAccess types live in `ZB.MOM.WW.MxGateway.Worker.MxAccess`; proto C# types in `ZB.MOM.WW.MxGateway.Contracts.Proto`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Contracts
|
||||||
|
|
||||||
|
### Task 1: Worker proto — subtag watch-list, failover config, provider-mode enum
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none (Task 2 imports these types)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (real `SubscribeAlarmsCommand` at ~line 324; `MxCommand` references it at 123-125)
|
||||||
|
|
||||||
|
> **CORRECTION (execution):** The alarm command messages and `MxCommand` live in **`mxaccess_gateway.proto`**, not the worker proto. `mxaccess_worker.proto` *imports* the gateway proto (`WorkerCommand.command` is `mxaccess_gateway.v1.MxCommand`), so the gateway proto is the base and the worker proto needs **no** change. `AlarmProviderMode` and the new types are added to the gateway proto and are visible to worker code as `mxaccess_gateway.v1` types. Tasks 1 and 2 are executed as a single combined edit on this one file.
|
||||||
|
|
||||||
|
**Step 1: Add the enum and messages.** In `mxaccess_gateway.proto`, extend the existing `SubscribeAlarmsCommand` message (line 324) and add the new types after it:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// Provider selection / current provider for the alarm feed. Defined here in
|
||||||
|
// the worker contract because the worker SubscribeAlarmsCommand references it;
|
||||||
|
// mxaccess_gateway.proto imports this file and reuses the same enum.
|
||||||
|
enum AlarmProviderMode {
|
||||||
|
ALARM_PROVIDER_MODE_UNSPECIFIED = 0; // auto: alarmmgr primary, subtag fallback
|
||||||
|
ALARM_PROVIDER_MODE_ALARMMGR = 1;
|
||||||
|
ALARM_PROVIDER_MODE_SUBTAG = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeAlarmsCommand {
|
||||||
|
string subscription_expression = 1; // existing field — keep
|
||||||
|
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
||||||
|
AlarmProviderMode forced_mode = 2;
|
||||||
|
// Subtag watch-list resolved by the gateway (GR SQL + config). Empty in pure
|
||||||
|
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
|
||||||
|
repeated AlarmSubtagTarget watch_list = 3;
|
||||||
|
AlarmFailoverConfig failover = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One alarm attribute the subtag consumer advises. Addresses are full MXAccess
|
||||||
|
// item references the worker passes straight to AddItem.
|
||||||
|
message AlarmSubtagTarget {
|
||||||
|
string alarm_full_reference = 1; // e.g. "Galaxy!Area.Tank01.Level.HiHi"
|
||||||
|
string source_object_reference = 2; // e.g. "Tank01"
|
||||||
|
string active_subtag = 3; // item address of the in-alarm boolean
|
||||||
|
string acked_subtag = 4; // item address of the acknowledged boolean
|
||||||
|
string ack_comment_subtag = 5; // writable ack-comment attribute (ack write target)
|
||||||
|
string priority_subtag = 6; // optional severity source; empty if absent
|
||||||
|
}
|
||||||
|
|
||||||
|
message AlarmFailoverConfig {
|
||||||
|
int32 consecutive_failure_threshold = 1; // wnwrap COM failures before switching (>=1)
|
||||||
|
int32 failback_probe_interval_seconds = 2; // probe cadence while degraded (>=1)
|
||||||
|
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`UnsubscribeAlarmsCommand` and `AcknowledgeAlarmCommand` are unchanged.
|
||||||
|
|
||||||
|
**Step 2: Regenerate & verify it compiles.**
|
||||||
|
Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
||||||
|
Expected: build succeeds; generated `AlarmProviderMode`, `AlarmSubtagTarget`, `AlarmFailoverConfig` types appear.
|
||||||
|
|
||||||
|
**Step 3: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto
|
||||||
|
git commit -m "contracts(worker): subtag watch-list + failover config + AlarmProviderMode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Gateway proto — provider status on the feed, degraded provenance, mode-changed event
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Task 1; Task 3 tests both)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (`OnAlarmTransitionEvent` ~719-771, `ActiveAlarmSnapshot` ~783-803, `AlarmFeedMessage` ~860-870, `MxEvent` family enum + body oneof, `MxEventFamily` enum)
|
||||||
|
|
||||||
|
**Step 1: Add degraded provenance to the two alarm payloads.** Append to `OnAlarmTransitionEvent` (next free field 14):
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// True when this transition came from the subtag-monitoring fallback rather
|
||||||
|
// than the native alarmmgr provider — i.e. it was synthesized from data
|
||||||
|
// changes and carries reduced fidelity (synthetic GUID, no native raise time).
|
||||||
|
bool degraded = 14;
|
||||||
|
// Which provider produced this transition.
|
||||||
|
AlarmProviderMode source_provider = 15;
|
||||||
|
```
|
||||||
|
|
||||||
|
Append the identical two fields to `ActiveAlarmSnapshot` (next free field 14):
|
||||||
|
```protobuf
|
||||||
|
bool degraded = 14;
|
||||||
|
AlarmProviderMode source_provider = 15;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add provider status to the feed oneof.** Add a new oneof case to `AlarmFeedMessage` (next free field 4) and a new message:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message AlarmFeedMessage {
|
||||||
|
oneof payload {
|
||||||
|
ActiveAlarmSnapshot active_alarm = 1;
|
||||||
|
bool snapshot_complete = 2;
|
||||||
|
OnAlarmTransitionEvent transition = 3;
|
||||||
|
// Provider-mode status. Emitted once on stream open and again on every
|
||||||
|
// failover/failback so late joiners learn the current mode immediately.
|
||||||
|
AlarmProviderStatus provider_status = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message AlarmProviderStatus {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
bool degraded = 2; // true whenever mode == SUBTAG
|
||||||
|
string reason = 3; // human-readable switch reason
|
||||||
|
google.protobuf.Timestamp since = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add the worker→gateway mode-changed event to `MxEvent`.** Find the `MxEventFamily` enum and the `MxEvent` body oneof. Add a family member and a body message + oneof case (use the next free family value and the next free `MxEvent` body field number — check the file):
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// in MxEventFamily enum:
|
||||||
|
MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED = <next>;
|
||||||
|
|
||||||
|
// new message near OnAlarmTransitionEvent:
|
||||||
|
message OnAlarmProviderModeChangedEvent {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
string reason = 2;
|
||||||
|
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
|
||||||
|
google.protobuf.Timestamp at = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// in MxEvent body oneof:
|
||||||
|
OnAlarmProviderModeChangedEvent on_alarm_provider_mode_changed = <next>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`AlarmProviderMode` is defined in `mxaccess_worker.proto`; confirm `mxaccess_gateway.proto` already has `import "mxaccess_worker.proto";` (it references `SubscribeAlarmsCommand`, so it does) and reference the enum unqualified or via its package as the existing references do.
|
||||||
|
|
||||||
|
**Step 4: Regenerate & verify.**
|
||||||
|
Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
||||||
|
Expected: build succeeds.
|
||||||
|
|
||||||
|
**Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto
|
||||||
|
git commit -m "contracts(gateway): AlarmProviderStatus feed case, degraded provenance, mode-changed event"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Proto round-trip tests for the new alarm fields
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none (depends on Tasks 1-2)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Add tests** mirroring the existing `Event_RoundTripsOnAlarmTransitionWithFullPayload` style:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Feed_RoundTripsProviderStatus()
|
||||||
|
{
|
||||||
|
var since = Timestamp.FromDateTime(new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
|
||||||
|
var original = new AlarmFeedMessage
|
||||||
|
{
|
||||||
|
ProviderStatus = new AlarmProviderStatus
|
||||||
|
{
|
||||||
|
Mode = AlarmProviderMode.Subtag,
|
||||||
|
Degraded = true,
|
||||||
|
Reason = "wnwrap poll failed 3x (HRESULT 0x80004005)",
|
||||||
|
Since = since,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var parsed = AlarmFeedMessage.Parser.ParseFrom(original.ToByteArray());
|
||||||
|
|
||||||
|
Assert.Equal(original, parsed);
|
||||||
|
Assert.Equal(AlarmFeedMessage.PayloadOneofCase.ProviderStatus, parsed.PayloadCase);
|
||||||
|
Assert.True(parsed.ProviderStatus.Degraded);
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.ProviderStatus.Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transition_RoundTripsDegradedProvenance()
|
||||||
|
{
|
||||||
|
var t = new OnAlarmTransitionEvent
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
|
TransitionKind = AlarmTransitionKind.Raise,
|
||||||
|
Degraded = true,
|
||||||
|
SourceProvider = AlarmProviderMode.Subtag,
|
||||||
|
};
|
||||||
|
|
||||||
|
var parsed = OnAlarmTransitionEvent.Parser.ParseFrom(t.ToByteArray());
|
||||||
|
|
||||||
|
Assert.True(parsed.Degraded);
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.SourceProvider);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run red→green.**
|
||||||
|
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 3: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
|
||||||
|
git commit -m "test(contracts): round-trip provider status + degraded provenance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Worker: subtag consumer + failover
|
||||||
|
|
||||||
|
### Task 4: Subtag value-source abstraction + synthesis state holder
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (Task 5 builds on it)
|
||||||
|
|
||||||
|
A testable seam so synthesis logic is unit-tested without COM. The COM wiring lands in Task 6.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Define the source abstraction.** `ISubtagAlarmSource` advises subtag addresses and raises a normalized value-change callback on the STA:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>A change in one advised subtag value, normalized off the COM boundary.</summary>
|
||||||
|
public sealed class SubtagValueChange
|
||||||
|
{
|
||||||
|
/// <summary>The full item address that changed (matches an AlarmSubtagTarget subtag).</summary>
|
||||||
|
public string ItemAddress { get; init; } = string.Empty;
|
||||||
|
/// <summary>The new value (boolean for .active/.acked, numeric for priority).</summary>
|
||||||
|
public object? Value { get; init; }
|
||||||
|
/// <summary>The change timestamp in UTC.</summary>
|
||||||
|
public DateTime TimestampUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advises a set of MXAccess subtag addresses and surfaces value changes.
|
||||||
|
/// The production implementation (Task 6) owns its own LMXProxyServerClass;
|
||||||
|
/// tests substitute a fake that pushes <see cref="SubtagValueChange"/>s.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISubtagAlarmSource : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Raised on the STA when an advised subtag's value changes.</summary>
|
||||||
|
event EventHandler<SubtagValueChange>? ValueChanged;
|
||||||
|
|
||||||
|
/// <summary>Advises every subtag in the supplied addresses; idempotent per address.</summary>
|
||||||
|
void Advise(IReadOnlyCollection<string> itemAddresses);
|
||||||
|
|
||||||
|
/// <summary>Writes a value to an item address (used for the ack-comment write).</summary>
|
||||||
|
void Write(string itemAddress, object? value);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Write the state-machine tests first.** `SubtagAlarmStateMachine` maps `(active, acked)` changes per target to `MxAlarmTransitionEvent`s. Test the four core transitions:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
public sealed class SubtagAlarmStateMachineTests
|
||||||
|
{
|
||||||
|
private static AlarmSubtagTarget Target() => new()
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
|
SourceObjectReference = "Tank01",
|
||||||
|
ActiveSubtag = "Tank01.Level.HiHi.active",
|
||||||
|
AckedSubtag = "Tank01.Level.HiHi.acked",
|
||||||
|
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveFalseToTrue_EmitsRaise_FlaggedDegraded()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State);
|
||||||
|
Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState);
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", e.Record.TagName); // reference minus provider/area
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AckedTrueWhileActive_EmitsAckTransition()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(5));
|
||||||
|
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckAlm, e.Record.State);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, e.PreviousState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveTrueToFalse_WhileUnacked_EmitsUnackRtn()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
|
||||||
|
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackRtn, e.Record.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_ReflectsActiveAndAckedState()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.acked", true, ts);
|
||||||
|
|
||||||
|
var snap = Assert.Single(sm.SnapshotActive());
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckAlm, snap.State);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~SubtagAlarmStateMachineTests` → FAIL (type missing).
|
||||||
|
|
||||||
|
**Step 3: Implement `SubtagAlarmStateMachine`.** Build an address→target index (active/acked/priority/comment addresses), hold per-reference `(bool active, bool acked, DateTime firstRaiseUtc, int priority)`, and emit on change:
|
||||||
|
- active `false→true` ⇒ `UnackAlm`, set `firstRaiseUtc`, `PreviousState` from prior state.
|
||||||
|
- acked `false→true` while active ⇒ `AckAlm`.
|
||||||
|
- active `true→false` ⇒ `AckRtn` if currently acked else `UnackRtn`; then reset acked.
|
||||||
|
- priority change ⇒ update stored priority, no transition.
|
||||||
|
- `TagName` = `alarm_full_reference` with any `Provider!Area.` prefix stripped (match `WnWrapAlarmConsumer`'s reference shape so `GatewayAlarmMonitor` keys align). Set `ProviderName`, `Group`, `Priority`, `AlarmComment` from the target/last values. Mark a `Degraded`/source flag (carried via a new field — see Task 5 wiring).
|
||||||
|
- `SnapshotActive()` returns `MxAlarmSnapshotRecord` for references whose active is true.
|
||||||
|
|
||||||
|
**Step 4: Run green.** Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs
|
||||||
|
git commit -m "worker(alarms): subtag value-source seam + synthesis state machine"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `SubtagAlarmConsumer` over the source seam (no COM yet)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Task 4)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Test with a fake `ISubtagAlarmSource`.** Drive value changes through the source, assert `AlarmTransitionEmitted` fires with synthesized records and that ack writes the comment to the ack-comment subtag:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class SubtagAlarmConsumerTests
|
||||||
|
{
|
||||||
|
private sealed class FakeSource : ISubtagAlarmSource
|
||||||
|
{
|
||||||
|
public event EventHandler<SubtagValueChange>? ValueChanged;
|
||||||
|
public List<string> Advised { get; } = new();
|
||||||
|
public (string Address, object? Value)? LastWrite { get; private set; }
|
||||||
|
public void Advise(IReadOnlyCollection<string> a) => Advised.AddRange(a);
|
||||||
|
public void Write(string a, object? v) => LastWrite = (a, v);
|
||||||
|
public void Raise(string addr, object? val, DateTime ts) =>
|
||||||
|
ValueChanged?.Invoke(this, new SubtagValueChange { ItemAddress = addr, Value = val, TimestampUtc = ts });
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AlarmSubtagTarget Target() => new()
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
|
ActiveSubtag = "Tank01.Level.HiHi.active",
|
||||||
|
AckedSubtag = "Tank01.Level.HiHi.acked",
|
||||||
|
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Subscribe_AdvisesAllSubtags()
|
||||||
|
{
|
||||||
|
var src = new FakeSource();
|
||||||
|
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
|
||||||
|
c.Subscribe("ignored-in-subtag-mode");
|
||||||
|
Assert.Contains("Tank01.Level.HiHi.active", src.Advised);
|
||||||
|
Assert.Contains("Tank01.Level.HiHi.acked", src.Advised);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValueChange_RaisesSynthesizedTransition()
|
||||||
|
{
|
||||||
|
var src = new FakeSource();
|
||||||
|
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
|
||||||
|
c.Subscribe("x");
|
||||||
|
MxAlarmTransitionEvent? seen = null;
|
||||||
|
c.AlarmTransitionEmitted += (_, e) => seen = e;
|
||||||
|
|
||||||
|
src.Raise("Tank01.Level.HiHi.active", true, new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
Assert.NotNull(seen);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, seen!.Record.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcknowledgeByName_WritesCommentToAckCommentSubtag()
|
||||||
|
{
|
||||||
|
var src = new FakeSource();
|
||||||
|
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
|
||||||
|
c.Subscribe("x");
|
||||||
|
|
||||||
|
int rc = c.AcknowledgeByName("Tank01.Level.HiHi", "Galaxy", "Area",
|
||||||
|
"ack from HMI", "op1", "node", "dom", "Op One");
|
||||||
|
|
||||||
|
Assert.Equal(0, rc);
|
||||||
|
Assert.Equal(("Tank01.Level.HiHi.ackmsg", (object?)"ack from HMI"), src.LastWrite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement `SubtagAlarmConsumer : IMxAccessAlarmConsumer`.**
|
||||||
|
- Constructor `(ISubtagAlarmSource source, IReadOnlyList<AlarmSubtagTarget> watchList)`; build a `SubtagAlarmStateMachine`; index `alarm_full_reference`→target for ack routing.
|
||||||
|
- `Subscribe(_)`: call `source.Advise(<all subtag addresses>)`; subscribe to `source.ValueChanged`, feed each into the state machine, and re-raise each produced `MxAlarmTransitionEvent` via `AlarmTransitionEmitted` (mark degraded).
|
||||||
|
- `AcknowledgeByName(alarmName, …, comment, …)`: resolve the target by reference; if no `AckCommentSubtag`, return a non-zero failure code; else `source.Write(target.AckCommentSubtag, comment)` and return 0.
|
||||||
|
- `AcknowledgeByGuid(guid, …)`: map the synthetic GUID (deterministic hash of reference — see Task 8 helper, or a local copy) back to a reference, then delegate to the name path; unknown GUID ⇒ non-zero.
|
||||||
|
- `SnapshotActiveAlarms()`: from the state machine.
|
||||||
|
- `PollOnce()`: no-op.
|
||||||
|
- `Dispose()`: unsubscribe + dispose source.
|
||||||
|
|
||||||
|
**Step 3: Run green.** `dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~SubtagAlarmConsumerTests`.
|
||||||
|
|
||||||
|
**Step 4: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
|
||||||
|
git commit -m "worker(alarms): SubtagAlarmConsumer synthesizing transitions over the source seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: COM-backed `LmxSubtagAlarmSource` (own LMXProxyServerClass)
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
The only piece that touches live COM. Like `WnWrapAlarmConsumer`, it owns its own MXAccess server object so the subtag source is self-contained and isolated from the session's item pipeline. Logic stays thin (advise/write/marshal); real verification is the live smoke test in Task 17.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs` (constructor/guard tests only; COM path is live-gated)
|
||||||
|
|
||||||
|
**Step 1: Implement `LmxSubtagAlarmSource : ISubtagAlarmSource`.**
|
||||||
|
- Own an `LMXProxyServerClass` (reuse the worker's `IMxAccessServer`/`MxAccessComServer` wrapper + `IMxAccessComObjectFactory` so it is fakeable; constructor takes the factory).
|
||||||
|
- `Advise(addresses)`: `RegisterServer` (topic) once; per address `AddItem`→`itemHandle`, `Advise`, and record `itemHandle→address`. Subscribe to the proxy's `OnDataChange`; in the handler, look up the address by `phItemHandle`, normalize `pvItemValue` (VARIANT→bool/double) and `pftItemTimeStamp`→UTC, and raise `ValueChanged`. All calls run on the STA (the worker STA pumps messages, so `OnDataChange` delivers).
|
||||||
|
- `Write(address, value)`: resolve/create the item handle, `server.Write(serverHandle, itemHandle, value, userId: 0)`.
|
||||||
|
- `Dispose()`: `UnAdvise`/`RemoveItem`/`Unregister`/release COM.
|
||||||
|
|
||||||
|
**Step 2: Tests** — only the non-COM guards (null factory throws; `Write` before `Advise` resolves a handle or throws a clear error). Mark the COM round-trip `[LiveMxAccessFact]` and `Skip` per the `AlarmsLiveSmokeTests` precedent.
|
||||||
|
|
||||||
|
**Step 3: Build x86 + run unit tests.**
|
||||||
|
`dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86`
|
||||||
|
`dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~LmxSubtagAlarmSourceTests`
|
||||||
|
|
||||||
|
**Step 4: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs
|
||||||
|
git commit -m "worker(alarms): COM-backed LmxSubtagAlarmSource advising alarm subtags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: `FailoverAlarmConsumer` state machine
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Task 5)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmProviderModeChange.cs` (small EventArgs)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Test the switch/failback with a fake primary that throws.**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class FailoverAlarmConsumerTests
|
||||||
|
{
|
||||||
|
private sealed class FlakyPrimary : IMxAccessAlarmConsumer
|
||||||
|
{
|
||||||
|
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
||||||
|
public int PollsUntilHeal = int.MaxValue; // becomes healthy after N polls while degraded
|
||||||
|
public bool ThrowOnPoll = true;
|
||||||
|
private int _polls;
|
||||||
|
public void Subscribe(string s) { if (ThrowOnPoll) throw new COMException("boom", unchecked((int)0x80004005)); }
|
||||||
|
public void PollOnce()
|
||||||
|
{
|
||||||
|
_polls++;
|
||||||
|
if (ThrowOnPoll && _polls < PollsUntilHeal) throw new COMException("boom", unchecked((int)0x80004005));
|
||||||
|
}
|
||||||
|
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 0;
|
||||||
|
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 0;
|
||||||
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => Array.Empty<MxAlarmSnapshotRecord>();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubStandby : IMxAccessAlarmConsumer { /* records Subscribe, no-op rest */ }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Primary_FailsThresholdTimes_SwitchesToSubtagAndEmitsModeChange()
|
||||||
|
{
|
||||||
|
var primary = new FlakyPrimary();
|
||||||
|
var standby = new StubStandby();
|
||||||
|
using var c = new FailoverAlarmConsumer(primary, standby,
|
||||||
|
new FailoverSettings(threshold: 3, probeIntervalSeconds: 30, stableProbes: 3));
|
||||||
|
AlarmProviderModeChange? change = null;
|
||||||
|
c.ProviderModeChanged += (_, e) => change = e;
|
||||||
|
|
||||||
|
c.Subscribe("\\\\host\\Galaxy!Area"); // primary.Subscribe throws -> counts as failure 1
|
||||||
|
c.PollOnce(); // failure 2
|
||||||
|
c.PollOnce(); // failure 3 -> switch
|
||||||
|
|
||||||
|
Assert.NotNull(change);
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, change!.Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WhileDegraded_PrimaryHeals_FailsBackAfterStableProbes()
|
||||||
|
{
|
||||||
|
var primary = new FlakyPrimary { PollsUntilHeal = 0 }; // will heal once we stop throwing
|
||||||
|
var standby = new StubStandby();
|
||||||
|
using var c = new FailoverAlarmConsumer(primary, standby,
|
||||||
|
new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 2));
|
||||||
|
var modes = new List<AlarmProviderMode>();
|
||||||
|
c.ProviderModeChanged += (_, e) => modes.Add(e.Mode);
|
||||||
|
|
||||||
|
c.Subscribe("x"); // failure -> switch to subtag
|
||||||
|
primary.ThrowOnPoll = false;
|
||||||
|
c.ProbeOnce(); // clean probe 1
|
||||||
|
c.ProbeOnce(); // clean probe 2 -> failback
|
||||||
|
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, modes[0]);
|
||||||
|
Assert.Equal(AlarmProviderMode.Alarmmgr, modes[^1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement.**
|
||||||
|
- `record FailoverSettings(int threshold, int probeIntervalSeconds, int stableProbes)`; `AlarmProviderModeChange : EventArgs { AlarmProviderMode Mode; string Reason; int HResult; DateTime AtUtc; }`.
|
||||||
|
- Constructor `(IMxAccessAlarmConsumer primary, IMxAccessAlarmConsumer standby, FailoverSettings settings)`; forced-mode variants handled in Task 9 wiring (forced ⇒ skip the other consumer).
|
||||||
|
- Forward `AlarmTransitionEmitted` from the **active** child only (swap the subscription on switch).
|
||||||
|
- Wrap `Subscribe`/`PollOnce` on the primary: on `COMException` (or a failure HRESULT) while `PrimaryActive`, increment a counter; at `threshold`, ensure standby `Subscribe`d, set active=standby, snapshot standby for hand-off, raise `ProviderModeChanged(Subtag, reason, hresult, now)`. Reset counter on any clean primary poll.
|
||||||
|
- `ProbeOnce()` (driven by the poll loop while degraded, gated by `probeIntervalSeconds`): try primary `Subscribe`+`PollOnce`; count consecutive clean probes; at `stableProbes`, set active=primary, return standby to standby, raise `ProviderModeChanged(Alarmmgr, "recovered", 0, now)`.
|
||||||
|
- `Acknowledge*` / `SnapshotActiveAlarms` delegate to the **active** child.
|
||||||
|
- `PollOnce()` drives the active child's poll, and—while degraded—also drives the failback probe cadence.
|
||||||
|
|
||||||
|
**Step 3: Run green** (x86 filter `FailoverAlarmConsumerTests`).
|
||||||
|
|
||||||
|
**Step 4: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmProviderModeChange.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs
|
||||||
|
git commit -m "worker(alarms): FailoverAlarmConsumer auto-failover/failback state machine"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Synthetic-GUID helper + degraded flag on the event sink path
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 9
|
||||||
|
|
||||||
|
Carry `degraded` + `source_provider` from the worker synthesis into the emitted `OnAlarmTransitionEvent`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs` (add `bool Degraded`)
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` (`EnqueueTransition` carries degraded)
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs` (`CreateOnAlarmTransition` sets `Degraded`/`SourceProvider`)
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs`
|
||||||
|
- Test: add cases to `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs` and a new `SyntheticAlarmGuidTests.cs`
|
||||||
|
|
||||||
|
**Step 1: `SyntheticAlarmGuid.ForReference(string reference)`** — deterministic GUID from a stable hash (e.g. MD5 of the UTF-8 reference → `new Guid(bytes)`), so subtag-mode acks resolve by GUID. Test determinism + difference:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact] public void SameReference_SameGuid() =>
|
||||||
|
Assert.Equal(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.C"));
|
||||||
|
[Fact] public void DifferentReference_DifferentGuid() =>
|
||||||
|
Assert.NotEqual(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.D"));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Thread `degraded`** through `MxAlarmSnapshotRecord.Degraded`, `EnqueueTransition(... bool degraded)`, and `CreateOnAlarmTransition(... bool degraded, AlarmProviderMode sourceProvider)`. Default `degraded=false`, `sourceProvider=Alarmmgr` so the wnwrap path is unchanged (regression: existing `AlarmDispatcherTests` still pass with `Degraded=false`).
|
||||||
|
|
||||||
|
**Step 3: Tests** — extend `AlarmDispatcherTests` with a subtag-style transition asserting `body.Degraded == true` and `SourceProvider == Subtag`.
|
||||||
|
|
||||||
|
**Step 4: Build x86 + run** worker tests for `AlarmDispatcherTests`, `SyntheticAlarmGuidTests`.
|
||||||
|
|
||||||
|
**Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs \
|
||||||
|
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/
|
||||||
|
git commit -m "worker(alarms): synthetic GUID + degraded provenance on emitted transitions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Wire watch-list + failover config through `AlarmCommandHandler`; emit mode-changed event
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Tasks 5, 7, 8)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs`
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs`
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs` (`ExecuteSubscribeAlarms`, ~lines 588-616)
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (consumer factory wiring; mode-change → event queue)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs` (extend or create)
|
||||||
|
|
||||||
|
**Step 1: Carry the subscribe payload.** Change the alarm subscribe entry point from `Subscribe(string subscription)` to `Subscribe(SubscribeAlarmsCommand command)` (the command now has `ForcedMode`, `WatchList`, `Failover`). In `AlarmCommandHandler.Subscribe`:
|
||||||
|
- Build the active provider per `ForcedMode`:
|
||||||
|
- `ALARMMGR` ⇒ `WnWrapAlarmConsumer` only.
|
||||||
|
- `SUBTAG` ⇒ `SubtagAlarmConsumer(new LmxSubtagAlarmSource(factory), watchList)` only.
|
||||||
|
- `UNSPECIFIED` ⇒ `FailoverAlarmConsumer(primary: wnwrap, standby: subtag, settings-from-Failover)`.
|
||||||
|
- Use the existing `consumerFactory` seam but widen it to `Func<SubscribeAlarmsCommand, IMxAccessAlarmConsumer>` so tests inject fakes and production builds the failover composite. Subscribe to `FailoverAlarmConsumer.ProviderModeChanged` and enqueue an `OnAlarmProviderModeChangedEvent` MxEvent via the event queue (new mapper method `CreateOnAlarmProviderModeChanged`).
|
||||||
|
|
||||||
|
**Step 2: Executor + STA wiring.** `ExecuteSubscribeAlarms` passes the full `SubscribeAlarmsCommand` (not just the expression). In `MxAccessStaSession`, the `alarmCommandHandlerFactory` must give the handler access to the `IMxAccessComObjectFactory` so the subtag source can create its own proxy server on the STA; keep the `EnsureOnAlarmConsumerThread` affinity guard on every path.
|
||||||
|
|
||||||
|
**Step 3: Test** — fake consumer factory; assert that a `SUBTAG` forced command builds the subtag consumer and advises; that an auto command building a fake failover composite, when it raises `ProviderModeChanged`, enqueues an `OnAlarmProviderModeChangedEvent` on the queue.
|
||||||
|
|
||||||
|
**Step 4: Build x86 + worker tests.**
|
||||||
|
|
||||||
|
**Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/
|
||||||
|
git commit -m "worker(alarms): route watch-list/failover config; emit provider-mode-changed event"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Gateway: discovery, options, monitor, metrics, dashboard
|
||||||
|
|
||||||
|
### Task 10: `AlarmsOptions.Fallback` + validation
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 11, Task 13
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs`
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs` (`ValidateAlarms`, ~lines 234-258)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs` (extend)
|
||||||
|
|
||||||
|
**Step 1:** Add `AlarmFallbackOptions Fallback { get; init; } = new();` to `AlarmsOptions`. `AlarmFallbackOptions`: `string Mode = "Auto"` (`Auto|ForceAlarmManager|ForceSubtag`), `int ConsecutiveFailureThreshold = 3`, `int FailbackProbeIntervalSeconds = 30`, `int FailbackStableProbes = 3`, a `Discovery` sub-object (`bool UseGalaxyRepository = true`, `string Area = ""`, `string[] IncludeAttributes = []`, `string[] ExcludeAttributes = []`), and a `Subtags` sub-object (`Active="active"`, `Acked="acked"`, `AckComment=""`, `Priority="priority"`).
|
||||||
|
|
||||||
|
**Step 2:** In `ValidateAlarms`, when `Enabled` and `Mode == "ForceSubtag"` and `Discovery.UseGalaxyRepository == false` and `IncludeAttributes` empty ⇒ add a validation error ("ForceSubtag requires Galaxy Repository discovery or an explicit IncludeAttributes list"). Floor the three numeric values at 1. Validate `Mode` is one of the three literals.
|
||||||
|
|
||||||
|
**Step 3-5:** Test the new validation cases (red→green), build the server, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Galaxy Repository "alarm attributes" discovery query
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 10, Task 13
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs` (add `GetAlarmAttributesAsync` + SQL constant, following `GetAttributesAsync` ~lines 86-115 and `AttributesSql` ~line 176)
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/` (projection unit test; live SQL gated)
|
||||||
|
|
||||||
|
**Step 1:** `GalaxyAlarmAttributeRow { string FullTagReference; string SourceObjectReference; string AckCommentSubtag; }` (and any priority subtag). `GetAlarmAttributesAsync` reuses the existing `is_alarm` detection (the `AlarmExtension` primitive join already in `AttributesSql`) filtered to `is_alarm = 1`, projecting the alarm reference + its ack-comment attribute. Follow the exact `SqlConnection`/`SqlCommand`/`SqlDataReader` pattern from `GetAttributesAsync`.
|
||||||
|
|
||||||
|
**Step 2:** Unit-test the row→`AlarmSubtagTarget` mapping (a pure mapper function); gate any live-DB test like the existing Galaxy live tests (or `Skip` with a note, matching `AlarmsLiveSmokeTests`).
|
||||||
|
|
||||||
|
**Step 3-5:** red→green, build server, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Watch-list resolver (GR SQL + config override → `AlarmSubtagTarget[]`)
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** none (depends on Tasks 10, 11)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs`
|
||||||
|
- Create: `src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Test the merge** with a fake `IGalaxyRepository`:
|
||||||
|
- discovery rows + `IncludeAttributes` are unioned; `ExcludeAttributes` removed; each becomes an `AlarmSubtagTarget` with `.active`/`.acked`/`.ackmsg` addresses composed from the configured `Subtags` names (`<reference>.<Active>`, etc.); empty config subtag names fall back to defaults; GR unavailable + no includes ⇒ empty list + a logged warning flag.
|
||||||
|
|
||||||
|
**Step 2: Implement** `ResolveAsync(AlarmsOptions, CancellationToken) → IReadOnlyList<AlarmSubtagTarget>`.
|
||||||
|
|
||||||
|
**Step 3-5:** red→green, build, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 13: Gateway metrics — provider-mode gauge + switch counter
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** Task 10, Task 11
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs` (ctor ~lines 55-79; add counter + observable gauge following the existing pattern)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Metrics/GatewayMetricsTests.cs` (if present; else assert via a `MeterListener`)
|
||||||
|
|
||||||
|
**Step 1:** Add `mxgateway.alarms.provider_switches` counter (tagged `from`,`to`,`reason`) and `mxgateway.alarms.provider_mode` observable gauge (1=alarmmgr, 2=subtag), plus `AlarmProviderSwitched(int from, int to, string reason)` and a private `GetAlarmProviderMode()` (lock on `_syncRoot` like the others).
|
||||||
|
|
||||||
|
**Step 2-4:** test, build, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 14: `GatewayAlarmMonitor` — arm watch-list, reflect provider mode, reconcile on switch
|
||||||
|
|
||||||
|
**Classification:** high-risk
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Tasks 9, 12, 13)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (ctor ~41-49; `SubscribeAlarmsAsync` ~210-233; event-drain loop; `StreamAsync` ~386-434)
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs` (new, using `FakeWorkerHarness`)
|
||||||
|
|
||||||
|
**Step 1:** Inject `IAlarmWatchListResolver` and `GatewayMetrics`. In `SubscribeAlarmsAsync`, resolve the watch-list and build the `SubscribeAlarmsCommand` with `ForcedMode` (from `Fallback.Mode`), `WatchList`, and `Failover` populated from options — instead of the bare `{ SubscriptionExpression }`.
|
||||||
|
|
||||||
|
**Step 2:** In the worker-event drain path, handle `OnAlarmProviderModeChangedEvent`: update a `_providerStatus` field (mode/degraded/reason/since), `Broadcast(new AlarmFeedMessage { ProviderStatus = … })` to every subscriber, call `metrics.AlarmProviderSwitched(...)`, and force a `ReconcileAsync` so the cache re-seeds from the now-active provider (avoids raise/clear storms).
|
||||||
|
|
||||||
|
**Step 3:** In `StreamAsync`, emit the current `provider_status` as the **first** message (before the snapshot) so a late joiner immediately knows the mode.
|
||||||
|
|
||||||
|
**Step 4: Test** — stand up the monitor with `FakeWorkerHarness`; emit an `OnAlarmProviderModeChangedEvent(Subtag)`; assert a `StreamAsync` subscriber receives a `ProviderStatus{ Mode=Subtag, Degraded=true }` and that the switch counter incremented. Also assert a transition emitted in subtag mode flows through with `Degraded=true`.
|
||||||
|
|
||||||
|
**Step 5:** build server, run the new test, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 15: Dashboard — push provider status to `/hubs/alarms` + UI indicator
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (depends on Task 14)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs` (forward `ProviderStatus` messages — they already flow through `StreamAsync`, so confirm the existing `SendAsync(AlarmMessage, message)` carries them; add a dedicated `"ProviderModeChanged"` client method if the dashboard needs a distinct channel)
|
||||||
|
- Modify: the alarms dashboard page/component (Bootstrap-only badge: green "alarmmgr" / amber "degraded — subtag") — find under `src/ZB.MOM.WW.MxGateway.Server/Dashboard/`
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/` dashboard model test (e.g. a `DashboardAlarmProviderStatus.FromFeed` mapper, mirroring `DashboardActiveAlarm.FromSnapshot`)
|
||||||
|
|
||||||
|
**Constraint:** Bootstrap CSS/JS only — no MudBlazor/Radzen/FluentUI.
|
||||||
|
|
||||||
|
**Steps:** TDD the model mapper, wire the publisher + badge, build, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Integration, docs, live smoke
|
||||||
|
|
||||||
|
### Task 16: End-to-end fake-worker failover test
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 18
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs`
|
||||||
|
|
||||||
|
Drive the full gateway path with `FakeWorkerHarness`: subscribe (assert the `SubscribeAlarmsCommand` carries a watch-list), emit a wnwrap-style transition (assert `Degraded=false`), emit `OnAlarmProviderModeChangedEvent(Subtag)`, emit a synthesized transition (assert `Degraded=true`, `SourceProvider=Subtag`), then `OnAlarmProviderModeChangedEvent(Alarmmgr)` and assert the feed reports recovery. Build, run, commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 17: Live subtag smoke test (opt-in)
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 18
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `src/ZB.MOM.WW.MxGateway.IntegrationTests/...AlarmSubtagLiveSmokeTests.cs` (or the worker live suite)
|
||||||
|
|
||||||
|
A `[LiveMxAccessFact]`, `Skip`-by-default test (per `AlarmsLiveSmokeTests` precedent) that, against a live Galaxy + alarm flip script: advises the real `.active`/`.acked` subtags via `LmxSubtagAlarmSource`, asserts a synthesized raise/clear, and performs an ack via the ack-comment write. Document the exact subtag names discovered (resolves the design's open item). Commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18: Documentation
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** Task 16, Task 17
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `gateway.md` (alarm provider section: dual provider + auto-failover/failback)
|
||||||
|
- Modify: `docs/DesignDecisions.md` (record the fallback decision + parity rationale)
|
||||||
|
- Modify: `docs/GatewayConfiguration.md` (the `MxGateway:Alarms:Fallback` block)
|
||||||
|
- Modify: `docs/AlarmClientDiscovery.md` (subtag provider, synthesis rules, ack-comment write)
|
||||||
|
- Modify: `docs/Grpc.md` (new `provider_status` feed case + `degraded`/`source_provider` fields)
|
||||||
|
|
||||||
|
Follow `StyleGuide.md` (PascalCase filenames, present tense, explain *why*). No code; commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution order & parallelism summary
|
||||||
|
|
||||||
|
- **Serial spine:** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8/9 → 10/11 → 12 → 13 → 14 → 15 → 16 → 17/18.
|
||||||
|
- **Parallelizable clusters:** {8, 9 partially}, {10, 11, 13}, {16, 17, 18}.
|
||||||
|
- **High-risk tasks** (full review chain): 1, 2, 6, 7, 9, 14. **Standard:** 4, 5, 8, 10, 11, 12, 15, 16. **Small/trivial:** 3, 13, 17, 18.
|
||||||
|
|
||||||
|
## Risk notes for the executor
|
||||||
|
|
||||||
|
- **Field-number collisions:** Task 2 must read the live `MxEvent`/`MxEventFamily` numbers before adding — the agent map gave alarm-payload maxima but not `MxEvent`'s. Verify before editing.
|
||||||
|
- **STA discipline:** every COM call in `LmxSubtagAlarmSource` and every consumer swap runs on the worker STA; keep the `EnsureOnAlarmConsumerThread` guard. The worker STA already pumps Windows messages, which is required for the subtag `OnDataChange` to deliver.
|
||||||
|
- **Parity regression:** alarmmgr-mode output must be byte-for-byte unchanged. Existing `AlarmDispatcherTests` and `ProtobufContractRoundTripTests` are the guardrail — they must stay green with `Degraded=false` defaults.
|
||||||
|
- **Subtag names unverified:** the design leaves exact AVEVA subtag names (`.active`, `.acked`, ack-comment) to confirm against `C:\Users\dohertj2\Desktop\mxaccess` + a live Galaxy (Task 17). The config `Subtags` block exists so names are not hard-coded.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-13-alarm-subtag-fallback.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 54, "subject": "Task 1: Worker proto — watch-list, failover config, AlarmProviderMode", "status": "pending"},
|
||||||
|
{"id": 55, "subject": "Task 2: Gateway proto — provider status, degraded provenance, mode-changed event", "status": "pending", "blockedBy": [54]},
|
||||||
|
{"id": 56, "subject": "Task 3: Proto round-trip tests for new alarm fields", "status": "pending", "blockedBy": [54, 55]},
|
||||||
|
{"id": 57, "subject": "Task 4: Subtag value-source abstraction + synthesis state machine", "status": "pending", "blockedBy": [54]},
|
||||||
|
{"id": 58, "subject": "Task 5: SubtagAlarmConsumer over the source seam", "status": "pending", "blockedBy": [57]},
|
||||||
|
{"id": 59, "subject": "Task 6: COM-backed LmxSubtagAlarmSource", "status": "pending", "blockedBy": [57]},
|
||||||
|
{"id": 60, "subject": "Task 7: FailoverAlarmConsumer state machine", "status": "pending", "blockedBy": [58]},
|
||||||
|
{"id": 61, "subject": "Task 8: Synthetic GUID + degraded flag on event sink path", "status": "pending", "blockedBy": [55]},
|
||||||
|
{"id": 62, "subject": "Task 9: Wire watch-list/failover through AlarmCommandHandler; emit mode-changed", "status": "pending", "blockedBy": [58, 60, 61]},
|
||||||
|
{"id": 63, "subject": "Task 10: AlarmsOptions.Fallback + validation", "status": "pending"},
|
||||||
|
{"id": 64, "subject": "Task 11: Galaxy Repository alarm-attributes discovery query", "status": "pending"},
|
||||||
|
{"id": 65, "subject": "Task 12: Watch-list resolver (GR SQL + config override)", "status": "pending", "blockedBy": [54, 63, 64]},
|
||||||
|
{"id": 66, "subject": "Task 13: Metrics — provider-mode gauge + switch counter", "status": "pending"},
|
||||||
|
{"id": 67, "subject": "Task 14: GatewayAlarmMonitor — arm watch-list, reflect mode, reconcile on switch", "status": "pending", "blockedBy": [55, 62, 65, 66]},
|
||||||
|
{"id": 68, "subject": "Task 15: Dashboard — push provider status + UI badge", "status": "pending", "blockedBy": [67]},
|
||||||
|
{"id": 69, "subject": "Task 16: End-to-end fake-worker failover test", "status": "pending", "blockedBy": [67]},
|
||||||
|
{"id": 70, "subject": "Task 17: Live subtag smoke test (opt-in)", "status": "pending", "blockedBy": [59, 62]},
|
||||||
|
{"id": 71, "subject": "Task 18: Documentation", "status": "pending", "blockedBy": [67]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-13T12:40:00Z"
|
||||||
|
}
|
||||||
@@ -1,27 +1,36 @@
|
|||||||
# GLAuth — LDAP authn reference for mxaccessgw
|
# GLAuth — LDAP authn reference for mxaccessgw
|
||||||
|
|
||||||
GLAuth is a lightweight LDAP server installed on this dev box at
|
> **UPDATED 2026-06-04 — mxaccessgw no longer uses a per-box GLAuth at `C:\publish\glauth`.
|
||||||
`C:\publish\glauth\` and run as a Windows service via NSSM. It already
|
> Dev/test LDAP is now the SHARED GLAuth on `10.100.0.35:3893` (`dc=zb,dc=local`);
|
||||||
backs the LmxOpcUa OPC UA server's UserName-token authn and the LmxOpcUa
|
> the single source of truth is `scadaproj/infra/glauth/` (`config.toml` + `README`).
|
||||||
Admin UI's cookie login; this doc captures everything mxaccessgw needs
|
> The localhost/NSSM/`glauth.cfg` procedures below are RETIRED, kept for reference/rollback.**
|
||||||
to consume the same directory so a single set of dev credentials covers
|
|
||||||
both stacks.
|
|
||||||
|
|
||||||
The authoritative copy of LmxOpcUa's reference lives at
|
GLAuth is a lightweight LDAP server. It already backs all three sister apps (MxAccessGateway,
|
||||||
`C:\publish\glauth\auth.md`. This doc is a redistilled view tailored to
|
OtOpcUa, ScadaBridge) through a **shared container** (`zb-shared-glauth`) running on the Linux
|
||||||
mxaccessgw — what users + groups are already provisioned, how to bind
|
docker host at **`10.100.0.35:3893`**. This doc captures everything mxaccessgw needs to consume
|
||||||
against them, and what's needed to add a gw-specific role.
|
that directory so a single set of dev credentials covers all stacks.
|
||||||
|
|
||||||
|
~~GLAuth is installed on this dev box at `C:\publish\glauth\` and run as a Windows service via
|
||||||
|
NSSM.~~ *(RETIRED — the per-box Windows service has been stopped and set to Manual startup;
|
||||||
|
kept only as a rollback option. Do not edit or restart it for new work.)*
|
||||||
|
|
||||||
|
The single source of truth for the shared GLAuth is
|
||||||
|
**`~/Desktop/scadaproj/infra/glauth/config.toml`** (deploy/verify runbook:
|
||||||
|
`scadaproj/infra/glauth/README.md`). This doc is a redistilled view tailored to mxaccessgw —
|
||||||
|
what users + groups are provisioned, how to bind against them, and what's needed to add a
|
||||||
|
gw-specific role.
|
||||||
|
|
||||||
## Connection details
|
## Connection details
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Protocol | LDAP (unencrypted) |
|
| Protocol | LDAP (unencrypted) |
|
||||||
| Host | `localhost` |
|
| Host | **`10.100.0.35`** (shared docker host — ~~`localhost`~~ retired) |
|
||||||
| Port | `3893` |
|
| Port | `3893` |
|
||||||
| LDAPS | disabled in dev (set `[ldaps]` block to enable) |
|
| LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
|
||||||
| Base DN | `dc=zb,dc=local` |
|
| Base DN | `dc=zb,dc=local` |
|
||||||
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
||||||
|
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
|
||||||
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
||||||
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
||||||
|
|
||||||
@@ -59,13 +68,13 @@ For mxaccessgw dev, `admin` covers every gw-side capability test;
|
|||||||
`readonly` is the right "negative" case for proving Browse-OK /
|
`readonly` is the right "negative" case for proving Browse-OK /
|
||||||
Write-denied.
|
Write-denied.
|
||||||
|
|
||||||
The gateway dashboard adds one role beyond this LmxOpcUa taxonomy:
|
The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
|
||||||
`GwAdmin`. `LdapOptions.RequiredGroup` defaults to `GwAdmin`, so the
|
`GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
|
||||||
dashboard login and `DashboardLdapLiveTests` require `admin` to be a
|
These are already provisioned in the shared `scadaproj/infra/glauth/config.toml`.
|
||||||
member of a `GwAdmin` group. `GwAdmin` is **not** in the baseline
|
The dashboard test users are **`multi-role`/`password`** (Administrator) and
|
||||||
GLAuth config — it must be provisioned before dashboard authn or the
|
**`gw-viewer`/`password`** (Viewer). `LdapOptions.RequiredGroup` defaults to `GwAdmin`.
|
||||||
LDAP live tests work. See [Provisioning the GwAdmin
|
See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for the
|
||||||
group](#provisioning-the-gwadmin-group) below.
|
(now-retired) per-box procedure and for the shared-config equivalent.
|
||||||
|
|
||||||
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
|
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
|
||||||
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
|
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
|
||||||
@@ -118,7 +127,7 @@ record:
|
|||||||
```yaml
|
```yaml
|
||||||
ldap:
|
ldap:
|
||||||
enabled: true
|
enabled: true
|
||||||
server: localhost
|
server: 10.100.0.35 # shared GLAuth on docker host (was localhost)
|
||||||
port: 3893
|
port: 3893
|
||||||
useTls: false
|
useTls: false
|
||||||
allowInsecureLdap: true # dev only
|
allowInsecureLdap: true # dev only
|
||||||
@@ -143,13 +152,29 @@ look that up in `groupToRole`.
|
|||||||
|
|
||||||
## Provisioning the GwAdmin group
|
## Provisioning the GwAdmin group
|
||||||
|
|
||||||
|
> **UPDATED 2026-06-04 — RETIRED per-box procedure.** `GwAdmin` (gid 5610) and `GwReader`
|
||||||
|
> (gid 5611) are already present in the shared GLAuth. To add or modify users/groups,
|
||||||
|
> edit **`~/Desktop/scadaproj/infra/glauth/config.toml`** on host `10.100.0.35` and run:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> cd ~/Desktop/scadaproj/infra/glauth
|
||||||
|
> docker compose up -d --force-recreate
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> The per-box `C:\publish\glauth\glauth.cfg` + NSSM procedure below is kept for
|
||||||
|
> rollback reference only — do not use it for new provisioning.
|
||||||
|
|
||||||
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
||||||
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
||||||
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
||||||
`admin` until a `GwAdmin` group exists and `admin` is a member.
|
logins unless the user is a member of `GwAdmin`.
|
||||||
GLAuth's baseline config ships only the five LmxOpcUa role groups, so
|
The `GwAdmin` (gid 5610) and `GwReader` (gid 5611) groups already exist in the shared
|
||||||
`GwAdmin` must be added to GLAuth rather than run from a separate LDAP
|
config at `scadaproj/infra/glauth/config.toml`. Dashboard test users are
|
||||||
server:
|
`multi-role`/`password` (Administrator) and `gw-viewer`/`password` (Viewer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**RETIRED — per-box provisioning (reference/rollback only):**
|
||||||
|
|
||||||
1. Edit `C:\publish\glauth\glauth.cfg`
|
1. Edit `C:\publish\glauth\glauth.cfg`
|
||||||
2. Append the group:
|
2. Append the group:
|
||||||
@@ -199,15 +224,16 @@ echo -n "yourpassword" | openssl dgst -sha256
|
|||||||
|
|
||||||
## Quick verification
|
## Quick verification
|
||||||
|
|
||||||
From mxaccessgw's dev box, prove the directory is reachable:
|
From mxaccessgw's dev box, prove the shared directory is reachable:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
||||||
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:3893")
|
# (shared GLAuth on 10.100.0.35 — was localhost, now the docker host)
|
||||||
|
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("10.100.0.35:3893")
|
||||||
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
||||||
$ldap.SessionOptions.ProtocolVersion = 3
|
$ldap.SessionOptions.ProtocolVersion = 3
|
||||||
$ldap.SessionOptions.SecureSocketLayer = $false
|
$ldap.SessionOptions.SecureSocketLayer = $false
|
||||||
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=zb,dc=local","admin123")
|
$cred = New-Object System.Net.NetworkCredential("cn=multi-role,dc=zb,dc=local","password")
|
||||||
$ldap.Bind($cred)
|
$ldap.Bind($cred)
|
||||||
"Bind OK"
|
"Bind OK"
|
||||||
```
|
```
|
||||||
@@ -215,17 +241,32 @@ $ldap.Bind($cred)
|
|||||||
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ldapsearch -x -H ldap://localhost:3893 \
|
ldapsearch -x -H ldap://10.100.0.35:3893 \
|
||||||
-D "cn=admin,dc=zb,dc=local" -w admin123 \
|
-D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
|
||||||
-b "dc=zb,dc=local" "(uid=admin)"
|
-b "dc=zb,dc=local" "(uid=multi-role)"
|
||||||
```
|
```
|
||||||
|
|
||||||
The response should list `admin`'s entry with `memberOf` populated for
|
The response should list `multi-role`'s entry with `memberOf` including
|
||||||
all five role groups — plus `GwAdmin` once the gateway-specific group
|
`ou=GwAdmin,ou=groups,dc=zb,dc=local`.
|
||||||
is provisioned.
|
|
||||||
|
|
||||||
## Service management
|
## Service management
|
||||||
|
|
||||||
|
> **RETIRED — per-box NSSM service (reference/rollback only).** The shared GLAuth is
|
||||||
|
> managed via `docker compose` on `10.100.0.35` (`scadaproj/infra/glauth/`). The
|
||||||
|
> Windows NSSM `GLAuth` service on the dev box has been stopped and set to
|
||||||
|
> `StartupType=Manual`; only restart it if you need to roll back to a local directory.
|
||||||
|
>
|
||||||
|
> **Active (shared) management:**
|
||||||
|
> ```bash
|
||||||
|
> ssh 10.100.0.35
|
||||||
|
> cd ~/Desktop/scadaproj/infra/glauth
|
||||||
|
> docker compose ps # check container status
|
||||||
|
> docker compose up -d --force-recreate # apply config.toml changes
|
||||||
|
> docker compose logs -f # tail logs
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**RETIRED — per-box NSSM commands (rollback reference):**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Status / start / stop / restart
|
# Status / start / stop / restart
|
||||||
nssm status GLAuth
|
nssm status GLAuth
|
||||||
@@ -259,7 +300,7 @@ applies to mxaccessgw verbatim. Keys that change:
|
|||||||
|
|
||||||
| Field | GLAuth dev value | AD production value |
|
| Field | GLAuth dev value | AD production value |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Server` | `localhost` | a domain controller FQDN, or the domain itself |
|
| `Server` | `10.100.0.35` (shared docker host) | a domain controller FQDN, or the domain itself |
|
||||||
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
||||||
| `UseTls` | `false` | `true` |
|
| `UseTls` | `false` | `true` |
|
||||||
| `AllowInsecureLdap` | `true` | `false` |
|
| `AllowInsecureLdap` | `true` | `false` |
|
||||||
@@ -275,12 +316,12 @@ add a `tokenGroups` query as an enhancement.
|
|||||||
|
|
||||||
## Security notes for production
|
## Security notes for production
|
||||||
|
|
||||||
- **Plaintext passwords in `glauth.cfg` are dev-only.** The config is
|
- **Plaintext passwords in `config.toml` are dev-only.** The shared config is in
|
||||||
unencrypted on disk; anyone with read access to `C:\publish\glauth\`
|
`scadaproj/infra/glauth/config.toml` (unencrypted); restrict filesystem access on
|
||||||
can SHA256-rainbow-table the entries. Treat the dev creds as
|
`10.100.0.35` accordingly. Treat the dev creds as throwaway. Production LDAP is Active
|
||||||
throwaway. Production LDAP is Active Directory.
|
Directory. *(The retired per-box `C:\publish\glauth\glauth.cfg` has the same caveat.)*
|
||||||
- The 3-fail / 10-minute lockout is per source IP, not per user — a
|
- The 3-fail / 10-minute lockout is per source IP, not per user — a
|
||||||
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
|
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
|
||||||
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
||||||
wire. Fine for `localhost`, never expose port 3893 off-box without
|
wire. The shared GLAuth listens only on the LAN (`10.100.0.35`); never
|
||||||
enabling TLS first.
|
expose port 3893 externally without enabling TLS first.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -315,6 +315,14 @@ message SubscribeBulkCommand {
|
|||||||
repeated string tag_addresses = 2;
|
repeated string tag_addresses = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider selection / current provider for the alarm feed. UNSPECIFIED on a
|
||||||
|
// SubscribeAlarmsCommand means auto: alarmmgr primary with subtag fallback.
|
||||||
|
enum AlarmProviderMode {
|
||||||
|
ALARM_PROVIDER_MODE_UNSPECIFIED = 0;
|
||||||
|
ALARM_PROVIDER_MODE_ALARMMGR = 1;
|
||||||
|
ALARM_PROVIDER_MODE_SUBTAG = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe the worker's alarm consumer to an AVEVA alarm provider.
|
// Subscribe the worker's alarm consumer to an AVEVA alarm provider.
|
||||||
// Subscription expression follows the canonical
|
// Subscription expression follows the canonical
|
||||||
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
|
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
|
||||||
@@ -323,6 +331,12 @@ message SubscribeBulkCommand {
|
|||||||
// SubscribeAlarms to reconfigure).
|
// SubscribeAlarms to reconfigure).
|
||||||
message SubscribeAlarmsCommand {
|
message SubscribeAlarmsCommand {
|
||||||
string subscription_expression = 1;
|
string subscription_expression = 1;
|
||||||
|
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
||||||
|
AlarmProviderMode forced_mode = 2;
|
||||||
|
// Subtag watch-list resolved by the gateway (GR SQL + config). Empty in pure
|
||||||
|
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
|
||||||
|
repeated AlarmSubtagTarget watch_list = 3;
|
||||||
|
AlarmFailoverConfig failover = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tear down the worker's alarm consumer. No-op if no subscription is
|
// Tear down the worker's alarm consumer. No-op if no subscription is
|
||||||
@@ -330,6 +344,23 @@ message SubscribeAlarmsCommand {
|
|||||||
message UnsubscribeAlarmsCommand {
|
message UnsubscribeAlarmsCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One alarm attribute the subtag fallback consumer advises. Addresses are full
|
||||||
|
// MXAccess item references the worker passes straight to AddItem.
|
||||||
|
message AlarmSubtagTarget {
|
||||||
|
string alarm_full_reference = 1; // e.g. "Galaxy!Area.Tank01.Level.HiHi"
|
||||||
|
string source_object_reference = 2; // e.g. "Tank01"
|
||||||
|
string active_subtag = 3; // item address of the in-alarm boolean
|
||||||
|
string acked_subtag = 4; // item address of the acknowledged boolean
|
||||||
|
string ack_comment_subtag = 5; // writable ack-comment attribute (ack write target)
|
||||||
|
string priority_subtag = 6; // optional severity source; empty if absent
|
||||||
|
}
|
||||||
|
|
||||||
|
message AlarmFailoverConfig {
|
||||||
|
int32 consecutive_failure_threshold = 1; // wnwrap COM failures before switching (>=1)
|
||||||
|
int32 failback_probe_interval_seconds = 2; // probe cadence while degraded (>=1)
|
||||||
|
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
||||||
|
}
|
||||||
|
|
||||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||||
// recorded atomically with the ack transition in the alarm-history log.
|
// recorded atomically with the ack transition in the alarm-history log.
|
||||||
// The reply's hresult / native_status surfaces AVEVA's
|
// The reply's hresult / native_status surfaces AVEVA's
|
||||||
@@ -684,6 +715,7 @@ message MxEvent {
|
|||||||
OperationCompleteEvent operation_complete = 22;
|
OperationCompleteEvent operation_complete = 22;
|
||||||
OnBufferedDataChangeEvent on_buffered_data_change = 23;
|
OnBufferedDataChangeEvent on_buffered_data_change = 23;
|
||||||
OnAlarmTransitionEvent on_alarm_transition = 24;
|
OnAlarmTransitionEvent on_alarm_transition = 24;
|
||||||
|
OnAlarmProviderModeChangedEvent on_alarm_provider_mode_changed = 25;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,6 +726,7 @@ enum MxEventFamily {
|
|||||||
MX_EVENT_FAMILY_OPERATION_COMPLETE = 3;
|
MX_EVENT_FAMILY_OPERATION_COMPLETE = 3;
|
||||||
MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4;
|
MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4;
|
||||||
MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5;
|
MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5;
|
||||||
|
MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message OnDataChangeEvent {
|
message OnDataChangeEvent {
|
||||||
@@ -768,6 +801,20 @@ message OnAlarmTransitionEvent {
|
|||||||
// Limit/threshold value that triggered the transition for limit alarms.
|
// Limit/threshold value that triggered the transition for limit alarms.
|
||||||
// Optional; populated for AnalogLimitAlarm-family transitions.
|
// Optional; populated for AnalogLimitAlarm-family transitions.
|
||||||
MxValue limit_value = 13;
|
MxValue limit_value = 13;
|
||||||
|
|
||||||
|
// True when this transition came from the subtag-monitoring fallback rather
|
||||||
|
// than the native alarmmgr provider — synthesized from data changes, reduced
|
||||||
|
// fidelity (synthetic GUID, no native raise time).
|
||||||
|
bool degraded = 14;
|
||||||
|
// Which provider produced this transition.
|
||||||
|
AlarmProviderMode source_provider = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OnAlarmProviderModeChangedEvent {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
string reason = 2;
|
||||||
|
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
|
||||||
|
google.protobuf.Timestamp at = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AlarmTransitionKind {
|
enum AlarmTransitionKind {
|
||||||
@@ -800,6 +847,8 @@ message ActiveAlarmSnapshot {
|
|||||||
string operator_comment = 11;
|
string operator_comment = 11;
|
||||||
MxValue current_value = 12;
|
MxValue current_value = 12;
|
||||||
MxValue limit_value = 13;
|
MxValue limit_value = 13;
|
||||||
|
bool degraded = 14;
|
||||||
|
AlarmProviderMode source_provider = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AlarmConditionState {
|
enum AlarmConditionState {
|
||||||
@@ -866,9 +915,19 @@ message AlarmFeedMessage {
|
|||||||
bool snapshot_complete = 2;
|
bool snapshot_complete = 2;
|
||||||
// A live alarm state change (raise / acknowledge / clear).
|
// A live alarm state change (raise / acknowledge / clear).
|
||||||
OnAlarmTransitionEvent transition = 3;
|
OnAlarmTransitionEvent transition = 3;
|
||||||
|
// Provider-mode status. Emitted once on stream open and again on every
|
||||||
|
// failover/failback so late joiners learn the current mode immediately.
|
||||||
|
AlarmProviderStatus provider_status = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message AlarmProviderStatus {
|
||||||
|
AlarmProviderMode mode = 1;
|
||||||
|
bool degraded = 2; // true whenever mode == SUBTAG
|
||||||
|
string reason = 3; // human-readable switch reason
|
||||||
|
google.protobuf.Timestamp since = 4;
|
||||||
|
}
|
||||||
|
|
||||||
message MxStatusProxy {
|
message MxStatusProxy {
|
||||||
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
||||||
// (a 16-bit signed value in the COM struct, widened to int32 on the
|
// (a 16-bit signed value in the COM struct, widened to int32 on the
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls how the central alarm monitor selects between the MXAccess
|
||||||
|
/// alarm-manager subscription and the subtag-polling fallback, and
|
||||||
|
/// governs the failure-detection thresholds used when switching.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmFallbackOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Selects the operating mode for the alarm-manager ↔ subtag fallback
|
||||||
|
/// mechanism. Accepted values (case-insensitive):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>Auto</c> — use the alarm manager; switch to subtag polling
|
||||||
|
/// automatically when <see cref="ConsecutiveFailureThreshold"/> failures
|
||||||
|
/// are detected, and probe for failback.</item>
|
||||||
|
/// <item><c>ForceAlarmManager</c> — always use the alarm manager;
|
||||||
|
/// never fall back.</item>
|
||||||
|
/// <item><c>ForceSubtag</c> — always use subtag polling;
|
||||||
|
/// never try the alarm manager.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Default is <c>Auto</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Mode { get; init; } = "Auto";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of consecutive alarm-manager failures before the monitor
|
||||||
|
/// switches to subtag-polling fallback. Must be at least 1. Default 3.
|
||||||
|
/// </summary>
|
||||||
|
public int ConsecutiveFailureThreshold { get; init; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How often (in seconds) the monitor sends a probe to the alarm manager
|
||||||
|
/// while operating in subtag-polling fallback mode, to detect recovery.
|
||||||
|
/// Must be at least 1. Default 30.
|
||||||
|
/// </summary>
|
||||||
|
public int FailbackProbeIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of consecutive successful probes required before the monitor
|
||||||
|
/// considers the alarm manager recovered and switches back. Must be at
|
||||||
|
/// least 1. Default 3.
|
||||||
|
/// </summary>
|
||||||
|
public int FailbackStableProbes { get; init; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls how the monitor discovers the set of objects to poll when
|
||||||
|
/// operating in subtag-polling fallback mode.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmDiscoveryOptions Discovery { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the subtag names the monitor reads when polling alarm state
|
||||||
|
/// in subtag-fallback mode.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmSubtagNameOptions Subtags { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Governs how the alarm monitor discovers objects to include in subtag-polling
|
||||||
|
/// fallback mode. Either the Galaxy Repository query (when
|
||||||
|
/// <see cref="UseGalaxyRepository"/> is <c>true</c>) or an explicit
|
||||||
|
/// <see cref="IncludeAttributes"/> list must be supplied when
|
||||||
|
/// <c>MxGateway:Alarms:Fallback:Mode</c> is <c>ForceSubtag</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmDiscoveryOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// When <c>true</c> the monitor queries the Galaxy Repository SQL database
|
||||||
|
/// to enumerate alarm objects for the configured area. Default <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseGalaxyRepository { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Galaxy area to scope the Repository query to. When empty the monitor
|
||||||
|
/// falls back to <see cref="AlarmsOptions.DefaultArea"/>. Ignored when
|
||||||
|
/// <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Area { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Explicit list of MXAccess attribute paths to include in subtag polling,
|
||||||
|
/// supplementing (or replacing, when <see cref="UseGalaxyRepository"/> is
|
||||||
|
/// <c>false</c>) the Repository-derived list. Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string[] IncludeAttributes { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attribute paths to exclude from the Repository-derived poll list.
|
||||||
|
/// Ignored when <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
||||||
|
/// Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string[] ExcludeAttributes { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the subtag names read by the alarm monitor when it is operating
|
||||||
|
/// in subtag-polling fallback mode. Names are matched against MXAccess item
|
||||||
|
/// handles; validation against the live MXAccess attribute list occurs at
|
||||||
|
/// runtime, not at startup.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlarmSubtagNameOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the active-alarm flag. Default <c>active</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Active { get; init; } = "active";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the acknowledged flag. Default <c>acked</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Acked { get; init; } = "acked";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional subtag name for the acknowledgement comment field.
|
||||||
|
/// When empty the feature is disabled. Verified against MXAccess at
|
||||||
|
/// runtime before use. Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string AckComment { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtag name for the alarm priority. Default <c>priority</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Priority { get; init; } = "priority";
|
||||||
|
}
|
||||||
@@ -45,4 +45,12 @@ public sealed class AlarmsOptions
|
|||||||
/// the monitor floors it at 5 seconds.
|
/// the monitor floors it at 5 seconds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReconcileIntervalSeconds { get; init; } = 30;
|
public int ReconcileIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
||||||
|
/// operating mode, failure-detection thresholds, discovery, and subtag
|
||||||
|
/// names. Defaults (Mode = "Auto") preserve behaviour when the section is
|
||||||
|
/// omitted from configuration.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmFallbackOptions Fallback { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
builder);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
|
||||||
|
|
||||||
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
@@ -255,6 +257,46 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
builder.Add(
|
builder.Add(
|
||||||
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ValidateAlarmFallback(options.Fallback, builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateAlarmFallback(AlarmFallbackOptions fallback, ValidationBuilder builder)
|
||||||
|
{
|
||||||
|
// Validate Mode is one of the recognised values (case-insensitive).
|
||||||
|
bool modeValid = Array.Exists(
|
||||||
|
ValidAlarmFallbackModes,
|
||||||
|
m => string.Equals(m, fallback.Mode, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (!modeValid)
|
||||||
|
{
|
||||||
|
builder.Add(
|
||||||
|
$"MxGateway:Alarms:Fallback:Mode must be one of: {string.Join(", ", ValidAlarmFallbackModes)} (was '{fallback.Mode}').");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceSubtag requires either Galaxy Repository discovery or an explicit IncludeAttributes list.
|
||||||
|
if (modeValid
|
||||||
|
&& string.Equals(fallback.Mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !fallback.Discovery.UseGalaxyRepository
|
||||||
|
&& fallback.Discovery.IncludeAttributes.Length == 0)
|
||||||
|
{
|
||||||
|
builder.Add(
|
||||||
|
"MxGateway:Alarms:Fallback ForceSubtag requires Galaxy Repository discovery or a non-empty Discovery:IncludeAttributes list.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor validation: numeric thresholds must be at least 1.
|
||||||
|
AddIfNotPositive(
|
||||||
|
fallback.ConsecutiveFailureThreshold,
|
||||||
|
"MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold must be greater than zero.",
|
||||||
|
builder);
|
||||||
|
AddIfNotPositive(
|
||||||
|
fallback.FailbackProbeIntervalSeconds,
|
||||||
|
"MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds must be greater than zero.",
|
||||||
|
builder);
|
||||||
|
AddIfNotPositive(
|
||||||
|
fallback.FailbackStableProbes,
|
||||||
|
"MxGateway:Alarms:Fallback:FailbackStableProbes must be greater than zero.",
|
||||||
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int MinimumCertValidityYears = 1;
|
private const int MinimumCertValidityYears = 1;
|
||||||
|
|||||||
@@ -6,13 +6,19 @@
|
|||||||
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
||||||
|
|
||||||
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
||||||
<form method="post" action="/login"> (username/password + hidden returnUrl). A native
|
<form method="post" action="/auth/login"> (username/password + hidden returnUrl). A native
|
||||||
form submit is not a Blazor event, so it reaches the minimal-API POST /login endpoint
|
form submit is not a Blazor event, so it reaches the minimal-API POST /auth/login endpoint
|
||||||
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
|
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
|
||||||
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks. *@
|
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks.
|
||||||
|
|
||||||
|
NOTE: the POST target is /auth/login, NOT /login. This @page lives at "/login" and the
|
||||||
|
Razor Components endpoint matches ALL methods, so a POST to /login collided with the
|
||||||
|
minimal-API MapPost("/login") and threw AmbiguousMatchException (HTTP 500). Posting to a
|
||||||
|
distinct /auth/login path (mirroring ScadaBridge) keeps the GET page and POST handler from
|
||||||
|
sharing a route. *@
|
||||||
@attribute [AllowAnonymous]
|
@attribute [AllowAnonymous]
|
||||||
|
|
||||||
<LoginCard Product="MXAccess Gateway" Action="/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
<LoginCard Product="MXAccess Gateway" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||||
<AntiforgeryToken />
|
<AntiforgeryToken />
|
||||||
</LoginCard>
|
</LoginCard>
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,14 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
||||||
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
||||||
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
||||||
|
//
|
||||||
|
// The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
|
||||||
|
// Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
|
||||||
|
// the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
|
||||||
|
// A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
|
||||||
|
// handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
|
||||||
endpoints.MapPost(
|
endpoints.MapPost(
|
||||||
"/login",
|
"/auth/login",
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
PostLoginAsync(httpContext, antiforgery, authenticator))
|
PostLoginAsync(httpContext, antiforgery, authenticator))
|
||||||
.AllowAnonymous()
|
.AllowAnonymous()
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One alarm-bearing attribute discovered by
|
||||||
|
/// <see cref="GalaxyRepository.GetAlarmAttributesAsync"/>: an attribute whose owning
|
||||||
|
/// object configures an <c>AlarmExtension</c> primitive (the same
|
||||||
|
/// <c>is_alarm</c> detection used by <see cref="GalaxyRepository.GetAttributesAsync"/>).
|
||||||
|
/// Used to build the subtag-fallback watch-list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyAlarmAttributeRow
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the alarm-bearing attribute reference (e.g. <c>Tank01.Level.HiHi</c>),
|
||||||
|
/// matching the <c>full_tag_reference</c> projection of
|
||||||
|
/// <see cref="GalaxyRepository.GetAttributesAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string FullTagReference { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the owning object reference (e.g. <c>Tank01</c>). This is the Galaxy
|
||||||
|
/// <c>tag_name</c> — the segment that precedes the first attribute dot in
|
||||||
|
/// <see cref="FullTagReference"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string SourceObjectReference { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the writable ack-comment attribute address.
|
||||||
|
/// <para>
|
||||||
|
/// The Galaxy Repository schema does not expose an ack-comment subtag address
|
||||||
|
/// directly, so this is always <see cref="string.Empty"/> here. The watch-list
|
||||||
|
/// resolver (a later task) composes the concrete address from configuration plus
|
||||||
|
/// <see cref="SourceObjectReference"/> / <see cref="FullTagReference"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public string AckCommentSubtag { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -114,6 +114,56 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves only the alarm-bearing attributes for the subtag-fallback watch-list.
|
||||||
|
/// Alarm detection is identical to <see cref="GetAttributesAsync"/>: a row is
|
||||||
|
/// alarm-bearing when its owning object configures an <c>AlarmExtension</c>
|
||||||
|
/// primitive (the same <c>is_alarm</c> projection, here applied as a SQL filter).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
public async Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
List<GalaxyAlarmAttributeRow> rows = new();
|
||||||
|
|
||||||
|
using SqlConnection conn = new(options.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
using SqlCommand cmd = new(AlarmAttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||||
|
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
rows.Add(MapAlarmRow(
|
||||||
|
fullTagReference: reader.GetString(0),
|
||||||
|
sourceObjectReference: reader.GetString(1)));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a raw alarm-attribute reader row to a <see cref="GalaxyAlarmAttributeRow"/>.
|
||||||
|
/// <para>
|
||||||
|
/// <paramref name="sourceObjectReference"/> is the Galaxy <c>tag_name</c> (the
|
||||||
|
/// owning object), and <paramref name="fullTagReference"/> is
|
||||||
|
/// <c>tag_name + '.' + attribute_name</c> — the same composition the
|
||||||
|
/// <c>full_tag_reference</c> projection of <see cref="AttributesSql"/> produces.
|
||||||
|
/// <see cref="GalaxyAlarmAttributeRow.AckCommentSubtag"/> is left empty here; the
|
||||||
|
/// schema does not expose an ack-comment address and the watch-list resolver
|
||||||
|
/// composes it later.
|
||||||
|
/// </para>
|
||||||
|
/// Exposed internally so the derivation can be unit-tested without a database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fullTagReference">The alarm-bearing attribute reference.</param>
|
||||||
|
/// <param name="sourceObjectReference">The owning object reference (tag name).</param>
|
||||||
|
internal static GalaxyAlarmAttributeRow MapAlarmRow(
|
||||||
|
string fullTagReference,
|
||||||
|
string sourceObjectReference) => new()
|
||||||
|
{
|
||||||
|
FullTagReference = fullTagReference,
|
||||||
|
SourceObjectReference = sourceObjectReference,
|
||||||
|
AckCommentSubtag = string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
private const string HierarchySql = @"
|
private const string HierarchySql = @"
|
||||||
;WITH template_chain AS (
|
;WITH template_chain AS (
|
||||||
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
||||||
@@ -248,5 +298,71 @@ SELECT
|
|||||||
FROM ranked r
|
FROM ranked r
|
||||||
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
|
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
|
||||||
WHERE r.rn = 1
|
WHERE r.rn = 1
|
||||||
|
ORDER BY r.tag_name, r.attribute_name";
|
||||||
|
|
||||||
|
// Alarm-only discovery for the subtag-fallback watch-list. This deliberately reuses the
|
||||||
|
// exact candidate/ranked CTE structure and the same `AlarmExtension`-based is_alarm
|
||||||
|
// detection as AttributesSql so the two queries cannot drift: a row qualifies only when
|
||||||
|
// its user attribute (src_pri 0) anchors an `AlarmExtension` primitive on the owning
|
||||||
|
// object. It projects just what the watch-list needs — full_tag_reference (tag_name +
|
||||||
|
// '.' + attribute_name, matching AttributesSql) and the owning object's tag_name as
|
||||||
|
// source_object_reference. The array `[]` suffix is intentionally omitted: an
|
||||||
|
// alarm-bearing attribute is a scalar anchor, not an array body.
|
||||||
|
private const string AlarmAttributesSql = @"
|
||||||
|
;WITH deployed_package_chain AS (
|
||||||
|
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||||
|
FROM gobject g
|
||||||
|
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
||||||
|
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
||||||
|
UNION ALL
|
||||||
|
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
||||||
|
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
||||||
|
),
|
||||||
|
candidate AS (
|
||||||
|
SELECT
|
||||||
|
dpc.gobject_id, g.tag_name, da.attribute_name, dpc.depth, 0 AS src_pri
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id
|
||||||
|
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||||
|
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||||
|
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||||
|
AND da.attribute_name NOT LIKE '[_]%'
|
||||||
|
AND da.attribute_name NOT LIKE '%.Description'
|
||||||
|
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
dpc.gobject_id, g.tag_name,
|
||||||
|
CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = ''
|
||||||
|
THEN ad.attribute_name
|
||||||
|
ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name,
|
||||||
|
dpc.depth, 1 AS src_pri
|
||||||
|
FROM deployed_package_chain dpc
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id
|
||||||
|
INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||||
|
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||||
|
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||||
|
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||||
|
AND ad.attribute_name NOT LIKE '[_]%'
|
||||||
|
AND ad.attribute_name NOT LIKE '%.Description'
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT c.*, ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn
|
||||||
|
FROM candidate c
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
r.tag_name + '.' + r.attribute_name AS full_tag_reference,
|
||||||
|
r.tag_name AS source_object_reference
|
||||||
|
FROM ranked r
|
||||||
|
WHERE r.rn = 1
|
||||||
|
AND r.src_pri = 0
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM deployed_package_chain dpc2
|
||||||
|
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
|
||||||
|
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
||||||
|
WHERE dpc2.gobject_id = r.gobject_id
|
||||||
|
)
|
||||||
ORDER BY r.tag_name, r.attribute_name";
|
ORDER BY r.tag_name, r.attribute_name";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,12 @@ public interface IGalaxyRepository
|
|||||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves only the alarm-bearing attributes (those whose owning object
|
||||||
|
/// configures an <c>AlarmExtension</c> primitive) for building the
|
||||||
|
/// subtag-fallback watch-list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Metrics;
|
namespace ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
private readonly Counter<long> _heartbeatFailuresCounter;
|
private readonly Counter<long> _heartbeatFailuresCounter;
|
||||||
private readonly Counter<long> _streamDisconnectsCounter;
|
private readonly Counter<long> _streamDisconnectsCounter;
|
||||||
private readonly Counter<long> _retryAttemptsCounter;
|
private readonly Counter<long> _retryAttemptsCounter;
|
||||||
|
private readonly Counter<long> _alarmProviderSwitchesCounter;
|
||||||
private readonly Histogram<double> _workerStartupLatencyHistogram;
|
private readonly Histogram<double> _workerStartupLatencyHistogram;
|
||||||
private readonly Histogram<double> _commandLatencyHistogram;
|
private readonly Histogram<double> _commandLatencyHistogram;
|
||||||
private readonly Histogram<double> _eventStreamSendLatencyHistogram;
|
private readonly Histogram<double> _eventStreamSendLatencyHistogram;
|
||||||
@@ -34,6 +36,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
private int _workersRunning;
|
private int _workersRunning;
|
||||||
private int _workerEventQueueDepth;
|
private int _workerEventQueueDepth;
|
||||||
private int _grpcEventStreamQueueDepth;
|
private int _grpcEventStreamQueueDepth;
|
||||||
|
private int _alarmProviderMode;
|
||||||
private long _sessionsOpened;
|
private long _sessionsOpened;
|
||||||
private long _sessionsClosed;
|
private long _sessionsClosed;
|
||||||
private long _commandsStarted;
|
private long _commandsStarted;
|
||||||
@@ -68,6 +71,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
||||||
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
||||||
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
||||||
|
_alarmProviderSwitchesCounter = _meter.CreateCounter<long>("mxgateway.alarms.provider_switches");
|
||||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||||
@@ -76,6 +80,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
||||||
_meter.CreateObservableGauge("mxgateway.events.worker_queue.depth", GetWorkerEventQueueDepth);
|
_meter.CreateObservableGauge("mxgateway.events.worker_queue.depth", GetWorkerEventQueueDepth);
|
||||||
_meter.CreateObservableGauge("mxgateway.events.grpc_stream_queue.depth", GetGrpcEventStreamQueueDepth);
|
_meter.CreateObservableGauge("mxgateway.events.grpc_stream_queue.depth", GetGrpcEventStreamQueueDepth);
|
||||||
|
_meter.CreateObservableGauge("mxgateway.alarms.provider_mode", GetAlarmProviderMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -377,6 +382,26 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_retryAttemptsCounter.Add(1, new KeyValuePair<string, object?>("area", area));
|
_retryAttemptsCounter.Add(1, new KeyValuePair<string, object?>("area", area));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that the alarm provider switched modes and updates the current provider mode gauge.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fromMode">Provider mode before the switch (1=alarmmgr, 2=subtag, 0=unknown).</param>
|
||||||
|
/// <param name="toMode">Provider mode after the switch (1=alarmmgr, 2=subtag, 0=unknown).</param>
|
||||||
|
/// <param name="reason">Human-readable reason for the switch.</param>
|
||||||
|
public void AlarmProviderSwitched(int fromMode, int toMode, string reason)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_alarmProviderMode = toMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
_alarmProviderSwitchesCounter.Add(
|
||||||
|
1,
|
||||||
|
new KeyValuePair<string, object?>("from", fromMode.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
new KeyValuePair<string, object?>("to", toMode.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
new KeyValuePair<string, object?>("reason", reason));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a snapshot of all current metric values.
|
/// Returns a snapshot of all current metric values.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -455,6 +480,14 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int GetAlarmProviderMode()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _alarmProviderMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void Increment(Dictionary<string, long> values, string key)
|
private static void Increment(Dictionary<string, long> values, string key)
|
||||||
{
|
{
|
||||||
values.TryGetValue(key, out long currentValue);
|
values.TryGetValue(key, out long currentValue);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
|
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
|
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.2.0" />
|
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||||
|
|||||||
@@ -118,4 +118,175 @@ public sealed class GatewayOptionsValidatorTests
|
|||||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
Assert.True(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AlarmFallbackOptions validation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static AlarmsOptions EnabledAlarmsWithFallback(AlarmFallbackOptions fallback) => new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
DefaultArea = "Galaxy",
|
||||||
|
Fallback = fallback,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static GatewayOptions CloneWithAlarms(GatewayOptions source, AlarmsOptions alarms)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Authentication = source.Authentication,
|
||||||
|
Ldap = source.Ldap,
|
||||||
|
Worker = source.Worker,
|
||||||
|
Sessions = source.Sessions,
|
||||||
|
Events = source.Events,
|
||||||
|
Dashboard = source.Dashboard,
|
||||||
|
Protocol = source.Protocol,
|
||||||
|
Alarms = alarms,
|
||||||
|
Tls = source.Tls,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
|
||||||
|
{
|
||||||
|
// Even an invalid Mode is acceptable when Enabled = false.
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
new AlarmsOptions
|
||||||
|
{
|
||||||
|
Enabled = false,
|
||||||
|
Fallback = new AlarmFallbackOptions { Mode = "InvalidMode" },
|
||||||
|
});
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_WhenAlarmsEnabled_DefaultAutoConfig()
|
||||||
|
{
|
||||||
|
// Default AlarmFallbackOptions (Mode="Auto") must pass validation when alarms are enabled.
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
EnabledAlarmsWithFallback(new AlarmFallbackOptions()));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Auto")]
|
||||||
|
[InlineData("ForceAlarmManager")]
|
||||||
|
[InlineData("ForceSubtag")]
|
||||||
|
[InlineData("auto")]
|
||||||
|
[InlineData("FORCESUBTAG")]
|
||||||
|
public void Validate_Succeeds_WhenAlarmsEnabled_RecognisedMode(string mode)
|
||||||
|
{
|
||||||
|
AlarmsOptions alarms = EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = mode });
|
||||||
|
GatewayOptions options = CloneWithAlarms(ValidOptions(), alarms);
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenAlarmsEnabled_InvalidMode()
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = "InvalidMode" }));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Alarms:Fallback") && f.Contains("Mode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenForceSubtag_NoGalaxyRepository_NoIncludes()
|
||||||
|
{
|
||||||
|
// ForceSubtag without galaxy repository and without IncludeAttributes must fail.
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
EnabledAlarmsWithFallback(new AlarmFallbackOptions
|
||||||
|
{
|
||||||
|
Mode = "ForceSubtag",
|
||||||
|
Discovery = new AlarmDiscoveryOptions
|
||||||
|
{
|
||||||
|
UseGalaxyRepository = false,
|
||||||
|
IncludeAttributes = [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(
|
||||||
|
result.Failures!,
|
||||||
|
f => f.Contains("ForceSubtag") && f.Contains("Discovery"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_WhenForceSubtag_NoGalaxyRepository_WithIncludes()
|
||||||
|
{
|
||||||
|
// ForceSubtag without galaxy repository is allowed when IncludeAttributes is non-empty.
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
EnabledAlarmsWithFallback(new AlarmFallbackOptions
|
||||||
|
{
|
||||||
|
Mode = "ForceSubtag",
|
||||||
|
Discovery = new AlarmDiscoveryOptions
|
||||||
|
{
|
||||||
|
UseGalaxyRepository = false,
|
||||||
|
IncludeAttributes = ["attr1"],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_WhenForceSubtag_WithGalaxyRepository()
|
||||||
|
{
|
||||||
|
// ForceSubtag + UseGalaxyRepository=true (default) must pass even without IncludeAttributes.
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
EnabledAlarmsWithFallback(new AlarmFallbackOptions
|
||||||
|
{
|
||||||
|
Mode = "ForceSubtag",
|
||||||
|
Discovery = new AlarmDiscoveryOptions { UseGalaxyRepository = true },
|
||||||
|
}));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
|
||||||
|
[InlineData(-1, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
|
||||||
|
public void Validate_Fails_WhenConsecutiveFailureThresholdBelowOne(int value, string keyPart)
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
EnabledAlarmsWithFallback(new AlarmFallbackOptions { ConsecutiveFailureThreshold = value }));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains(keyPart));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
|
||||||
|
[InlineData(-5, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
|
||||||
|
public void Validate_Fails_WhenFailbackProbeIntervalSecondsBelowOne(int value, string keyPart)
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackProbeIntervalSeconds = value }));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains(keyPart));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, nameof(AlarmFallbackOptions.FailbackStableProbes))]
|
||||||
|
[InlineData(-1, nameof(AlarmFallbackOptions.FailbackStableProbes))]
|
||||||
|
public void Validate_Fails_WhenFailbackStableProbesBelowOne(int value, string keyPart)
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackStableProbes = value }));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(result.Failures!, f => f.Contains(keyPart));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1332,6 +1332,56 @@ public sealed class ProtobufContractRoundTripTests
|
|||||||
Assert.Equal(kind, parsed.Kind);
|
Assert.Equal(kind, parsed.Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that an <see cref="AlarmFeedMessage"/> carrying the
|
||||||
|
/// <c>provider_status</c> payload case round-trips and resolves to
|
||||||
|
/// <see cref="AlarmFeedMessage.PayloadOneofCase.ProviderStatus"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Feed_RoundTripsProviderStatus()
|
||||||
|
{
|
||||||
|
var since = Timestamp.FromDateTime(new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
|
||||||
|
var original = new AlarmFeedMessage
|
||||||
|
{
|
||||||
|
ProviderStatus = new AlarmProviderStatus
|
||||||
|
{
|
||||||
|
Mode = AlarmProviderMode.Subtag,
|
||||||
|
Degraded = true,
|
||||||
|
Reason = "wnwrap poll failed 3x (HRESULT 0x80004005)",
|
||||||
|
Since = since,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var parsed = AlarmFeedMessage.Parser.ParseFrom(original.ToByteArray());
|
||||||
|
|
||||||
|
Assert.Equal(original, parsed);
|
||||||
|
Assert.Equal(AlarmFeedMessage.PayloadOneofCase.ProviderStatus, parsed.PayloadCase);
|
||||||
|
Assert.True(parsed.ProviderStatus.Degraded);
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.ProviderStatus.Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that an <see cref="OnAlarmTransitionEvent"/> carrying the
|
||||||
|
/// new <c>degraded</c> and <c>source_provider</c> provenance fields
|
||||||
|
/// round-trips with their values preserved.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Transition_RoundTripsDegradedProvenance()
|
||||||
|
{
|
||||||
|
var t = new OnAlarmTransitionEvent
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
|
TransitionKind = AlarmTransitionKind.Raise,
|
||||||
|
Degraded = true,
|
||||||
|
SourceProvider = AlarmProviderMode.Subtag,
|
||||||
|
};
|
||||||
|
|
||||||
|
var parsed = OnAlarmTransitionEvent.Parser.ParseFrom(t.ToByteArray());
|
||||||
|
|
||||||
|
Assert.True(parsed.Degraded);
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, parsed.SourceProvider);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that an <see cref="MxCommandReply"/> with kind
|
/// Verifies that an <see cref="MxCommandReply"/> with kind
|
||||||
/// <see cref="MxCommandKind.ReadBulk"/> and a populated
|
/// <see cref="MxCommandKind.ReadBulk"/> and a populated
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure mapper tests for <see cref="GalaxyRepository.MapAlarmRow"/>. These assert the
|
||||||
|
/// FullTagReference / SourceObjectReference derivation produced by
|
||||||
|
/// <c>AlarmAttributesSql</c> without touching a database: the SQL projects
|
||||||
|
/// <c>tag_name</c> as the source object and <c>tag_name + '.' + attribute_name</c> as
|
||||||
|
/// the full reference, exactly as <c>AttributesSql</c> does.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyAlarmAttributeMappingTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies the mapper copies both projected columns onto the row.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void MapAlarmRow_CopiesProjectedColumns()
|
||||||
|
{
|
||||||
|
GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(
|
||||||
|
fullTagReference: "Tank01.Level.HiHi",
|
||||||
|
sourceObjectReference: "Tank01");
|
||||||
|
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", row.FullTagReference);
|
||||||
|
Assert.Equal("Tank01", row.SourceObjectReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="GalaxyAlarmAttributeRow.AckCommentSubtag"/> is always empty:
|
||||||
|
/// the schema does not expose an ack-comment address, so the watch-list resolver
|
||||||
|
/// composes it later from configuration.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void MapAlarmRow_LeavesAckCommentSubtagEmpty()
|
||||||
|
{
|
||||||
|
GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(
|
||||||
|
fullTagReference: "Tank01.Level.HiHi",
|
||||||
|
sourceObjectReference: "Tank01");
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, row.AckCommentSubtag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the SourceObjectReference is the owning object (the SQL <c>tag_name</c>),
|
||||||
|
/// i.e. the segment that precedes the first attribute dot in the full reference, even
|
||||||
|
/// when the attribute itself is a multi-segment extension path.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Tank01", "Level.HiHi", "Tank01.Level.HiHi")]
|
||||||
|
[InlineData("Pump_001", "Speed", "Pump_001.Speed")]
|
||||||
|
[InlineData("TestAlarm001", "Alarm001", "TestAlarm001.Alarm001")]
|
||||||
|
public void MapAlarmRow_SourceObjectIsSegmentBeforeFirstAttributeDot(
|
||||||
|
string tagName,
|
||||||
|
string attributeName,
|
||||||
|
string expectedFullReference)
|
||||||
|
{
|
||||||
|
// Mirror the AlarmAttributesSql projection: full_tag_reference = tag_name + '.' + attribute_name.
|
||||||
|
string fullTagReference = tagName + "." + attributeName;
|
||||||
|
|
||||||
|
GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(fullTagReference, tagName);
|
||||||
|
|
||||||
|
Assert.Equal(expectedFullReference, row.FullTagReference);
|
||||||
|
Assert.Equal(tagName, row.SourceObjectReference);
|
||||||
|
Assert.Equal(row.FullTagReference, row.SourceObjectReference + "." + attributeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -378,6 +378,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
=> throw new InvalidOperationException("GetAttributesAsync should not be reached");
|
=> throw new InvalidOperationException("GetAttributesAsync should not be reached");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||||
|
=> throw new InvalidOperationException("GetAlarmAttributesAsync should not be reached");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary>
|
/// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary>
|
||||||
@@ -465,6 +469,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
|
|||||||
GetAttributesCount++;
|
GetAttributesCount++;
|
||||||
return Task.FromResult(_attributes);
|
return Task.FromResult(_attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new List<GalaxyAlarmAttributeRow>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -518,6 +526,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
|
|||||||
GetAttributesCount++;
|
GetAttributesCount++;
|
||||||
throw toThrow;
|
throw toThrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||||
|
=> throw toThrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,13 +87,18 @@ public sealed class GatewayApplicationTests
|
|||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
||||||
|
|
||||||
// GET /login is now served by the [AllowAnonymous] Blazor <Login> component
|
// GET /login is served by the [AllowAnonymous] Blazor <Login> component
|
||||||
// (Components/Pages/Login.razor → @page "/login"), not a named minimal-API
|
// (Components/Pages/Login.razor → @page "/login"), not a named minimal-API
|
||||||
// endpoint. The form still POSTs to the minimal-API DashboardLoginPost endpoint.
|
// endpoint. The credential POST goes to the DashboardLoginPost endpoint at
|
||||||
|
// /auth/login — a DISTINCT route. The Blazor component endpoint matches all HTTP
|
||||||
|
// methods, so sharing the "/login" route with MapPost previously made POST /login
|
||||||
|
// ambiguous (AmbiguousMatchException → HTTP 500). Pinning the POST to /auth/login
|
||||||
|
// is the regression guard for that fix.
|
||||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login"
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login"
|
||||||
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
||||||
Assert.Contains(endpoints, endpoint =>
|
Assert.Contains(endpoints, endpoint =>
|
||||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost");
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost"
|
||||||
|
&& endpoint.RoutePattern.RawText == "/auth/login");
|
||||||
Assert.Contains(endpoints, endpoint =>
|
Assert.Contains(endpoints, endpoint =>
|
||||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Diagnostics.Metrics;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Tests.Metrics;
|
namespace ZB.MOM.WW.MxGateway.Tests.Metrics;
|
||||||
@@ -63,6 +64,98 @@ public sealed class GatewayMetricsTests
|
|||||||
Assert.Equal("depth", exception.ParamName);
|
Assert.Equal("depth", exception.ParamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that <see cref="GatewayMetrics.AlarmProviderSwitched"/> increments
|
||||||
|
/// <c>mxgateway.alarms.provider_switches</c> by one with the expected from/to/reason tags.
|
||||||
|
/// The listener filters by the specific <see cref="System.Diagnostics.Metrics.Meter"/> instance
|
||||||
|
/// to avoid cross-talk between parallel tests (Tests-027).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AlarmProviderSwitched_IncrementsCounterWithExpectedTags()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
using MeterListener listener = new();
|
||||||
|
|
||||||
|
long capturedValue = 0;
|
||||||
|
string? capturedFrom = null;
|
||||||
|
string? capturedTo = null;
|
||||||
|
string? capturedReason = null;
|
||||||
|
|
||||||
|
listener.InstrumentPublished = (instrument, meterListener) =>
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||||
|
&& instrument.Name == "mxgateway.alarms.provider_switches")
|
||||||
|
{
|
||||||
|
meterListener.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
listener.SetMeasurementEventCallback<long>(
|
||||||
|
(instrument, measurement, tags, _) =>
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||||
|
|| instrument.Name != "mxgateway.alarms.provider_switches")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
capturedValue += measurement;
|
||||||
|
foreach (KeyValuePair<string, object?> tag in tags)
|
||||||
|
{
|
||||||
|
switch (tag.Key)
|
||||||
|
{
|
||||||
|
case "from": capturedFrom = tag.Value as string; break;
|
||||||
|
case "to": capturedTo = tag.Value as string; break;
|
||||||
|
case "reason": capturedReason = tag.Value as string; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
metrics.AlarmProviderSwitched(1, 2, "test");
|
||||||
|
|
||||||
|
Assert.Equal(1, capturedValue);
|
||||||
|
Assert.Equal("1", capturedFrom);
|
||||||
|
Assert.Equal("2", capturedTo);
|
||||||
|
Assert.Equal("test", capturedReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that <see cref="GatewayMetrics.AlarmProviderSwitched"/> updates the
|
||||||
|
/// <c>mxgateway.alarms.provider_mode</c> observable gauge to the <paramref name="toMode"/> value.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AlarmProviderSwitched_UpdatesProviderModeGauge()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
using MeterListener listener = new();
|
||||||
|
|
||||||
|
int? capturedMode = null;
|
||||||
|
|
||||||
|
listener.InstrumentPublished = (instrument, meterListener) =>
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||||
|
&& instrument.Name == "mxgateway.alarms.provider_mode")
|
||||||
|
{
|
||||||
|
meterListener.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
listener.SetMeasurementEventCallback<int>(
|
||||||
|
(instrument, measurement, _, _) =>
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||||
|
&& instrument.Name == "mxgateway.alarms.provider_mode")
|
||||||
|
{
|
||||||
|
capturedMode = measurement;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
metrics.AlarmProviderSwitched(1, 2, "test");
|
||||||
|
listener.RecordObservableInstruments();
|
||||||
|
|
||||||
|
Assert.Equal(2, capturedMode);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that removing session events only affects that session.</summary>
|
/// <summary>Verifies that removing session events only affects that session.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RemoveSessionEvents_RemovesOnlyThatSession()
|
public void RemoveSessionEvents_RemovesOnlyThatSession()
|
||||||
|
|||||||
@@ -62,6 +62,43 @@ public sealed class AlarmDispatcherTests
|
|||||||
Assert.Equal("TestArea", body.Category);
|
Assert.Equal("TestArea", body.Category);
|
||||||
Assert.NotNull(body.TransitionTimestamp);
|
Assert.NotNull(body.TransitionTimestamp);
|
||||||
Assert.Equal(ts, body.TransitionTimestamp.ToDateTime());
|
Assert.Equal(ts, body.TransitionTimestamp.ToDateTime());
|
||||||
|
Assert.False(body.Degraded);
|
||||||
|
Assert.Equal(AlarmProviderMode.Alarmmgr, body.SourceProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a transition enqueued via the subtag fallback
|
||||||
|
/// (<c>degraded: true</c>) is marked <see cref="OnAlarmTransitionEvent.Degraded"/>
|
||||||
|
/// with <see cref="AlarmProviderMode.Subtag"/>, while the default path
|
||||||
|
/// stays on the alarmmgr parity contract.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void EnqueueTransition_WhenDegraded_MarksDegradedAndSubtagProvider()
|
||||||
|
{
|
||||||
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||||
|
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
||||||
|
sink.Attach(new object(), SessionId);
|
||||||
|
|
||||||
|
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
|
||||||
|
sink.EnqueueTransition(
|
||||||
|
alarmFullReference: "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
||||||
|
sourceObjectReference: "TestMachine_001.TestAlarm001",
|
||||||
|
alarmTypeName: "DSC",
|
||||||
|
transitionKind: AlarmTransitionKind.Raise,
|
||||||
|
severity: 500,
|
||||||
|
originalRaiseTimestampUtc: null,
|
||||||
|
transitionTimestampUtc: ts,
|
||||||
|
operatorUser: string.Empty,
|
||||||
|
operatorComment: string.Empty,
|
||||||
|
category: "TestArea",
|
||||||
|
description: string.Empty,
|
||||||
|
degraded: true);
|
||||||
|
|
||||||
|
Assert.Equal(1, queue.Count);
|
||||||
|
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
|
||||||
|
OnAlarmTransitionEvent body = workerEvent!.Event.OnAlarmTransition;
|
||||||
|
Assert.True(body.Degraded);
|
||||||
|
Assert.Equal(AlarmProviderMode.Subtag, body.SourceProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that unchanged alarm states do not emit transitions.</summary>
|
/// <summary>Verifies that unchanged alarm states do not emit transitions.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit-test coverage for <see cref="LmxSubtagAlarmSource"/>'s advise/write
|
||||||
|
/// sequencing and its <c>OnDataChange</c> normalization. The actual
|
||||||
|
/// <c>LMXProxyServerClass</c> COM event subscription cannot be exercised
|
||||||
|
/// without a live MXAccess install, so these tests drive the source through
|
||||||
|
/// its internal <see cref="IMxAccessServer"/> seam and call
|
||||||
|
/// <c>HandleDataChange</c> directly to simulate a COM callback — exactly the
|
||||||
|
/// boundary <c>MxAccessBaseEventSink.OnDataChange</c> uses for the
|
||||||
|
/// per-session pipeline. End-to-end COM delivery is covered by the
|
||||||
|
/// Skip-gated alarm live smoke tests.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LmxSubtagAlarmSourceTests
|
||||||
|
{
|
||||||
|
private const int FakeServerHandle = 7;
|
||||||
|
|
||||||
|
/// <summary>Verifies the production constructor rejects a null factory.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullFactory_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new LmxSubtagAlarmSource(factory: null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="LmxSubtagAlarmSource.Advise"/> calls AddItem then
|
||||||
|
/// Advise once per distinct address, and is idempotent on a repeated
|
||||||
|
/// address.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Advise_AddsAndAdvisesEachAddressOnce()
|
||||||
|
{
|
||||||
|
var server = new RecordingMxAccessServer();
|
||||||
|
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||||
|
|
||||||
|
source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" });
|
||||||
|
// Re-advising an already-advised address is a no-op.
|
||||||
|
source.Advise(new[] { "Tank1.Alarm.Subtag" });
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" },
|
||||||
|
server.AddedItems);
|
||||||
|
Assert.Equal(2, server.AdviseCount);
|
||||||
|
// Every advise targeted the supplied server handle.
|
||||||
|
Assert.All(server.AdvisedServerHandles, h => Assert.Equal(FakeServerHandle, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a simulated <c>OnDataChange</c> for an advised item handle
|
||||||
|
/// raises <see cref="LmxSubtagAlarmSource.ValueChanged"/> with the
|
||||||
|
/// address that was advised and the delivered value.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void HandleDataChange_RaisesValueChangedWithAdvisedAddress()
|
||||||
|
{
|
||||||
|
var server = new RecordingMxAccessServer();
|
||||||
|
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||||
|
|
||||||
|
source.Advise(new[] { "Tank1.Alarm.Subtag" });
|
||||||
|
int itemHandle = server.LastItemHandleFor("Tank1.Alarm.Subtag");
|
||||||
|
|
||||||
|
SubtagValueChange? received = null;
|
||||||
|
source.ValueChanged += (_, change) => received = change;
|
||||||
|
|
||||||
|
source.HandleDataChange(itemHandle, pvItemValue: 42, pftItemTimeStamp: null);
|
||||||
|
|
||||||
|
Assert.NotNull(received);
|
||||||
|
Assert.Equal("Tank1.Alarm.Subtag", received!.ItemAddress);
|
||||||
|
Assert.Equal(42, received.Value);
|
||||||
|
Assert.Equal(DateTimeKind.Utc, received.TimestampUtc.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <c>OnDataChange</c> for an unknown item handle is ignored
|
||||||
|
/// (no <see cref="LmxSubtagAlarmSource.ValueChanged"/> raised).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void HandleDataChange_UnknownHandle_DoesNotRaise()
|
||||||
|
{
|
||||||
|
var server = new RecordingMxAccessServer();
|
||||||
|
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||||
|
|
||||||
|
bool raised = false;
|
||||||
|
source.ValueChanged += (_, _) => raised = true;
|
||||||
|
|
||||||
|
source.HandleDataChange(phItemHandle: 999, pvItemValue: 1, pftItemTimeStamp: null);
|
||||||
|
|
||||||
|
Assert.False(raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> adds the item when
|
||||||
|
/// it was not previously advised and writes with user id 0.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Write_AddsItemWhenUnknownAndWrites()
|
||||||
|
{
|
||||||
|
var server = new RecordingMxAccessServer();
|
||||||
|
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||||
|
|
||||||
|
source.Write("Tank1.Alarm.AckComment", "acknowledged");
|
||||||
|
|
||||||
|
Assert.Contains("Tank1.Alarm.AckComment", server.AddedItems);
|
||||||
|
Assert.Single(server.Writes);
|
||||||
|
RecordingMxAccessServer.WriteRecord write = server.Writes[0];
|
||||||
|
Assert.Equal(FakeServerHandle, write.ServerHandle);
|
||||||
|
Assert.Equal("acknowledged", write.Value);
|
||||||
|
Assert.Equal(0, write.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> reuses an existing
|
||||||
|
/// item handle (no duplicate AddItem) when the address was already
|
||||||
|
/// advised.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Write_ReusesHandleForAdvisedAddress()
|
||||||
|
{
|
||||||
|
var server = new RecordingMxAccessServer();
|
||||||
|
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||||
|
|
||||||
|
source.Advise(new[] { "Tank1.Alarm.Subtag" });
|
||||||
|
int adviseAddCount = server.AddedItems.Count;
|
||||||
|
|
||||||
|
source.Write("Tank1.Alarm.Subtag", true);
|
||||||
|
|
||||||
|
// No second AddItem for the already-advised address.
|
||||||
|
Assert.Equal(adviseAddCount, server.AddedItems.Count);
|
||||||
|
Assert.Single(server.Writes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recording <see cref="IMxAccessServer"/> test double that captures the
|
||||||
|
/// AddItem/Advise/Write/UnAdvise/RemoveItem/Unregister calls
|
||||||
|
/// <see cref="LmxSubtagAlarmSource"/> makes and hands out monotonically
|
||||||
|
/// increasing item handles.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RecordingMxAccessServer : IMxAccessServer
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, int> handlesByAddress = new(StringComparer.Ordinal);
|
||||||
|
private int nextItemHandle = 100;
|
||||||
|
|
||||||
|
public List<string> AddedItems { get; } = new();
|
||||||
|
|
||||||
|
public int AdviseCount { get; private set; }
|
||||||
|
|
||||||
|
public List<int> AdvisedServerHandles { get; } = new();
|
||||||
|
|
||||||
|
public List<WriteRecord> Writes { get; } = new();
|
||||||
|
|
||||||
|
public int LastItemHandleFor(string itemAddress) => handlesByAddress[itemAddress];
|
||||||
|
|
||||||
|
public int Register(string clientName) => FakeServerHandle;
|
||||||
|
|
||||||
|
public void Unregister(int serverHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public int AddItem(int serverHandle, string itemDefinition)
|
||||||
|
{
|
||||||
|
AddedItems.Add(itemDefinition);
|
||||||
|
int handle = nextItemHandle++;
|
||||||
|
handlesByAddress[itemDefinition] = handle;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int AddItem2(int serverHandle, string itemDefinition, string itemContext)
|
||||||
|
=> AddItem(serverHandle, itemDefinition);
|
||||||
|
|
||||||
|
public void RemoveItem(int serverHandle, int itemHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Advise(int serverHandle, int itemHandle)
|
||||||
|
{
|
||||||
|
AdviseCount++;
|
||||||
|
AdvisedServerHandles.Add(serverHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UnAdvise(int serverHandle, int itemHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AdviseSupervisory(int serverHandle, int itemHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Write(int serverHandle, int itemHandle, object? value, int userId)
|
||||||
|
=> Writes.Add(new WriteRecord(serverHandle, itemHandle, value, userId));
|
||||||
|
|
||||||
|
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record WriteRecord(int ServerHandle, int ItemHandle, object? Value, int UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System;
|
||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the subtag-fallback synthesis state machine. The machine
|
||||||
|
/// consumes normalized subtag value changes (active/acked/priority) and
|
||||||
|
/// emits <see cref="MxAlarmTransitionEvent"/> records mirroring the wnwrap
|
||||||
|
/// consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions. No COM
|
||||||
|
/// or AVEVA install is required.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SubtagAlarmStateMachineTests
|
||||||
|
{
|
||||||
|
private static AlarmSubtagTarget Target() => new()
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
|
SourceObjectReference = "Tank01",
|
||||||
|
ActiveSubtag = "Tank01.Level.HiHi.active",
|
||||||
|
AckedSubtag = "Tank01.Level.HiHi.acked",
|
||||||
|
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveFalseToTrue_EmitsRaise()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State);
|
||||||
|
Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState);
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", e.Record.TagName);
|
||||||
|
Assert.Equal("Galaxy", e.Record.ProviderName);
|
||||||
|
Assert.Equal("Area", e.Record.Group);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveFalseToTrue_NoProviderBang_UsesWholeReferenceAsTagName()
|
||||||
|
{
|
||||||
|
var target = new AlarmSubtagTarget
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Tank01.Level.HiHi",
|
||||||
|
SourceObjectReference = string.Empty,
|
||||||
|
ActiveSubtag = "Tank01.Level.HiHi.active",
|
||||||
|
};
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { target });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal("Tank01.Level.HiHi", e.Record.TagName);
|
||||||
|
Assert.Equal(string.Empty, e.Record.ProviderName);
|
||||||
|
Assert.Equal(string.Empty, e.Record.Group);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OutOfOrderAckThenClear_StillEmitsAckRtn()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(2));
|
||||||
|
// Out-of-order un-ack arrives before the active=false clear.
|
||||||
|
sm.Apply("Tank01.Level.HiHi.acked", false, ts.AddSeconds(3));
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckRtn, e.Record.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateActiveSubtag_Throws()
|
||||||
|
{
|
||||||
|
var first = new AlarmSubtagTarget
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
||||||
|
SourceObjectReference = "Tank01",
|
||||||
|
ActiveSubtag = "Shared.active",
|
||||||
|
};
|
||||||
|
var second = new AlarmSubtagTarget
|
||||||
|
{
|
||||||
|
AlarmFullReference = "Galaxy!Area.Tank02.Level.HiHi",
|
||||||
|
SourceObjectReference = "Tank02",
|
||||||
|
ActiveSubtag = "Shared.active",
|
||||||
|
};
|
||||||
|
Assert.Throws<ArgumentException>(() => new SubtagAlarmStateMachine(new[] { first, second }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AckedTrueWhileActive_EmitsAck()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(5));
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckAlm, e.Record.State);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, e.PreviousState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveTrueToFalse_WhileUnacked_EmitsUnackRtn()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackRtn, e.Record.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveTrueToFalse_WhileAcked_EmitsAckRtn()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(2));
|
||||||
|
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
|
||||||
|
var e = Assert.Single(events);
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckRtn, e.Record.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_ReflectsActiveAndAckedState()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
||||||
|
sm.Apply("Tank01.Level.HiHi.acked", true, ts);
|
||||||
|
var snap = Assert.Single(sm.SnapshotActive());
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckAlm, snap.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnknownAddress_NoEvents()
|
||||||
|
{
|
||||||
|
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
||||||
|
var events = sm.Apply("Some.Other.Tag.active", true, DateTime.UtcNow);
|
||||||
|
Assert.Empty(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="SyntheticAlarmGuid"/>: the subtag-fallback
|
||||||
|
/// path derives a deterministic GUID from the alarm reference, so identical
|
||||||
|
/// references must collide and distinct references must not.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SyntheticAlarmGuidTests
|
||||||
|
{
|
||||||
|
/// <summary>Verifies the same reference yields the same GUID.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void SameReference_SameGuid() =>
|
||||||
|
Assert.Equal(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.C"));
|
||||||
|
|
||||||
|
/// <summary>Verifies distinct references yield distinct GUIDs.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DifferentReference_DifferentGuid() =>
|
||||||
|
Assert.NotEqual(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.D"));
|
||||||
|
|
||||||
|
/// <summary>Verifies a reference produces a non-empty GUID.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Reference_ProducesNonEmptyGuid() =>
|
||||||
|
Assert.NotEqual(Guid.Empty, SyntheticAlarmGuid.ForReference("A.B.C"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advises a set of MXAccess subtag addresses and surfaces value changes.
|
||||||
|
/// The production implementation owns its own LMXProxyServerClass (later task);
|
||||||
|
/// tests substitute a fake that pushes SubtagValueChange values.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISubtagAlarmSource : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Raised when an advised subtag reports a new value.</summary>
|
||||||
|
event EventHandler<SubtagValueChange>? ValueChanged;
|
||||||
|
|
||||||
|
/// <summary>Begins advising the supplied MXAccess subtag item addresses.</summary>
|
||||||
|
/// <param name="itemAddresses">The subtag references to advise.</param>
|
||||||
|
void Advise(IReadOnlyCollection<string> itemAddresses);
|
||||||
|
|
||||||
|
/// <summary>Writes a value to the named MXAccess subtag (e.g. an ack request).</summary>
|
||||||
|
/// <param name="itemAddress">The subtag reference to write.</param>
|
||||||
|
/// <param name="value">The value to write.</param>
|
||||||
|
void Write(string itemAddress, object? value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using ArchestrA.MxAccess;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="ISubtagAlarmSource"/> backed by its own MXAccess
|
||||||
|
/// <c>LMXProxyServerClass</c> COM object. It advises a set of alarm subtag
|
||||||
|
/// item addresses, normalizes each <c>OnDataChange</c> COM callback into a
|
||||||
|
/// <see cref="SubtagValueChange"/>, and supports writing a value (e.g. an
|
||||||
|
/// ack-comment) to a subtag.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Like <see cref="WnWrapAlarmConsumer"/>, this type owns its
|
||||||
|
/// <em>own</em> MXAccess proxy-server COM object rather than sharing the
|
||||||
|
/// per-session item pipeline's <see cref="MxAccessSession"/>. That keeps
|
||||||
|
/// the subtag-fallback subscription isolated from the session's own
|
||||||
|
/// <c>Register</c>/<c>AddItem</c>/<c>Advise</c> bookkeeping: advising or
|
||||||
|
/// tearing down alarm subtags never perturbs the client's subscriptions.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <strong>Threading.</strong> The <c>LMXProxyServerClass</c> CLSID is
|
||||||
|
/// apartment-threaded and delivers <c>OnDataChange</c> via the STA
|
||||||
|
/// message pump. Every method on this type — the lazy <see cref="Advise"/>
|
||||||
|
/// creation, <see cref="Write"/>, and <see cref="Dispose"/> — must be
|
||||||
|
/// invoked on the worker's STA that created the source, exactly as
|
||||||
|
/// <see cref="MxAccessSession"/> and <see cref="WnWrapAlarmConsumer"/>
|
||||||
|
/// require. The <c>OnDataChange</c> COM event also arrives on that STA.
|
||||||
|
/// Because every call is confined to the single owning STA, no lock is
|
||||||
|
/// taken around the COM object or the handle maps: a lock here would risk
|
||||||
|
/// deadlocking the STA against a re-entrant pump callback, and the STA
|
||||||
|
/// affinity already serializes access.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
|
||||||
|
{
|
||||||
|
private const string DefaultClientName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker.SubtagAlarms";
|
||||||
|
|
||||||
|
private readonly IMxAccessComObjectFactory? factory;
|
||||||
|
private readonly string clientName;
|
||||||
|
private readonly Dictionary<string, int> itemHandlesByAddress =
|
||||||
|
new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<int, string> addressesByItemHandle =
|
||||||
|
new Dictionary<int, string>();
|
||||||
|
|
||||||
|
private object? mxAccessComObject;
|
||||||
|
private IMxAccessServer? server;
|
||||||
|
private LMXProxyServerClass? comEventSource;
|
||||||
|
private int serverHandle;
|
||||||
|
private bool registered;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production constructor. The MXAccess COM object is created lazily on
|
||||||
|
/// the first <see cref="Advise"/> call (which must run on the worker's
|
||||||
|
/// STA) via the supplied <paramref name="factory"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="factory">Factory that creates the MXAccess proxy-server COM object.</param>
|
||||||
|
/// <param name="clientName">
|
||||||
|
/// Optional MXAccess client/registration name used for
|
||||||
|
/// <see cref="IMxAccessServer.Register"/>; a worker-specific default is
|
||||||
|
/// used when null or whitespace. Mirrors the registration name
|
||||||
|
/// <see cref="MxAccessSession.Register"/> accepts.
|
||||||
|
/// </param>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="factory"/> is null.</exception>
|
||||||
|
public LmxSubtagAlarmSource(
|
||||||
|
IMxAccessComObjectFactory factory,
|
||||||
|
string? clientName = null)
|
||||||
|
{
|
||||||
|
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||||
|
this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only seam: constructs a source over a caller-supplied
|
||||||
|
/// <see cref="IMxAccessServer"/> with a pre-registered server handle,
|
||||||
|
/// bypassing the live COM factory. Exposed via
|
||||||
|
/// <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c> so tests can
|
||||||
|
/// exercise <see cref="Advise"/>/<see cref="Write"/> and
|
||||||
|
/// <see cref="HandleDataChange"/> without a live <c>LMXProxyServerClass</c>.
|
||||||
|
/// No COM event source is wired in this mode — tests drive
|
||||||
|
/// <see cref="HandleDataChange"/> directly to simulate an
|
||||||
|
/// <c>OnDataChange</c> callback.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="server">The server abstraction to drive.</param>
|
||||||
|
/// <param name="serverHandle">The already-registered server handle to use.</param>
|
||||||
|
internal LmxSubtagAlarmSource(
|
||||||
|
IMxAccessServer server,
|
||||||
|
int serverHandle)
|
||||||
|
{
|
||||||
|
this.factory = null;
|
||||||
|
this.clientName = DefaultClientName;
|
||||||
|
this.server = server ?? throw new ArgumentNullException(nameof(server));
|
||||||
|
this.serverHandle = serverHandle;
|
||||||
|
this.registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler<SubtagValueChange>? ValueChanged;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Idempotent per address: an address already advised is skipped. The
|
||||||
|
/// MXAccess COM object is created and the client registered on the first
|
||||||
|
/// call. Must be invoked on the worker's STA.
|
||||||
|
/// </remarks>
|
||||||
|
public void Advise(IReadOnlyCollection<string> itemAddresses)
|
||||||
|
{
|
||||||
|
if (itemAddresses is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(itemAddresses));
|
||||||
|
}
|
||||||
|
|
||||||
|
ThrowIfDisposed();
|
||||||
|
EnsureRegistered();
|
||||||
|
|
||||||
|
IMxAccessServer mxServer = server!;
|
||||||
|
foreach (string? itemAddress in itemAddresses)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(itemAddress))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemHandlesByAddress.ContainsKey(itemAddress!))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int itemHandle = mxServer.AddItem(serverHandle, itemAddress!);
|
||||||
|
mxServer.Advise(serverHandle, itemHandle);
|
||||||
|
itemHandlesByAddress[itemAddress!] = itemHandle;
|
||||||
|
addressesByItemHandle[itemHandle] = itemAddress!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Adds the item if it has not been added yet — the write may target a
|
||||||
|
/// subtag (e.g. the ack-comment) that was never advised — then writes
|
||||||
|
/// with MXAccess user id 0 (unsecured). Must be invoked on the worker's
|
||||||
|
/// STA.
|
||||||
|
/// </remarks>
|
||||||
|
public void Write(string itemAddress, object? value)
|
||||||
|
{
|
||||||
|
if (itemAddress is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(itemAddress));
|
||||||
|
}
|
||||||
|
|
||||||
|
ThrowIfDisposed();
|
||||||
|
EnsureRegistered();
|
||||||
|
|
||||||
|
IMxAccessServer mxServer = server!;
|
||||||
|
if (!itemHandlesByAddress.TryGetValue(itemAddress, out int itemHandle))
|
||||||
|
{
|
||||||
|
itemHandle = mxServer.AddItem(serverHandle, itemAddress);
|
||||||
|
itemHandlesByAddress[itemAddress] = itemHandle;
|
||||||
|
addressesByItemHandle[itemHandle] = itemAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
mxServer.Write(serverHandle, itemHandle, value, userId: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes an MXAccess <c>OnDataChange</c> callback into a
|
||||||
|
/// <see cref="SubtagValueChange"/> and raises <see cref="ValueChanged"/>
|
||||||
|
/// when the item handle maps to an advised address. Unknown handles are
|
||||||
|
/// ignored. Exposed <c>internal</c> as a unit-test seam so the
|
||||||
|
/// normalization can be exercised without a live <c>LMXProxyServerClass</c>,
|
||||||
|
/// mirroring <c>MxAccessBaseEventSink.OnDataChange</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="phItemHandle">The MXAccess item handle that changed.</param>
|
||||||
|
/// <param name="pvItemValue">The new item value (left as a boxed COM variant; the state machine coerces it).</param>
|
||||||
|
/// <param name="pftItemTimeStamp">The item timestamp as delivered by MXAccess (a culture-formatted string).</param>
|
||||||
|
internal void HandleDataChange(
|
||||||
|
int phItemHandle,
|
||||||
|
object? pvItemValue,
|
||||||
|
object? pftItemTimeStamp)
|
||||||
|
{
|
||||||
|
if (!addressesByItemHandle.TryGetValue(phItemHandle, out string? itemAddress))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MXAccess delivers OnDataChange timestamps as a culture-formatted
|
||||||
|
// string (VT_BSTR), not a FILETIME or VT_DATE — see
|
||||||
|
// MxAccessEventMapper.TryParseSourceTimestamp, which is the single
|
||||||
|
// conversion helper the per-session pipeline already uses. Reuse it so
|
||||||
|
// the subtag path interprets the host-local string the same way; fall
|
||||||
|
// back to capture time when the string is absent or unparsable.
|
||||||
|
DateTime timestampUtc = ConvertTimestampUtc(pftItemTimeStamp);
|
||||||
|
|
||||||
|
EventHandler<SubtagValueChange>? handler = ValueChanged;
|
||||||
|
handler?.Invoke(this, new SubtagValueChange
|
||||||
|
{
|
||||||
|
ItemAddress = itemAddress,
|
||||||
|
Value = pvItemValue,
|
||||||
|
TimestampUtc = timestampUtc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
|
||||||
|
// Unsubscribe the COM event before tearing handles down so a late pump
|
||||||
|
// callback cannot re-enter HandleDataChange mid-teardown.
|
||||||
|
if (comEventSource is not null)
|
||||||
|
{
|
||||||
|
try { comEventSource.OnDataChange -= OnDataChange; } catch { /* swallow — best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
IMxAccessServer? mxServer = server;
|
||||||
|
if (mxServer is not null && registered)
|
||||||
|
{
|
||||||
|
foreach (KeyValuePair<int, string> entry in addressesByItemHandle)
|
||||||
|
{
|
||||||
|
try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
|
||||||
|
try { mxServer.RemoveItem(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
try { mxServer.Unregister(serverHandle); } catch { /* swallow — best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
itemHandlesByAddress.Clear();
|
||||||
|
addressesByItemHandle.Clear();
|
||||||
|
|
||||||
|
object? comToRelease = mxAccessComObject;
|
||||||
|
mxAccessComObject = null;
|
||||||
|
comEventSource = null;
|
||||||
|
server = null;
|
||||||
|
registered = false;
|
||||||
|
|
||||||
|
if (comToRelease is not null && Marshal.IsComObject(comToRelease))
|
||||||
|
{
|
||||||
|
try { Marshal.FinalReleaseComObject(comToRelease); } catch { /* swallow — best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureRegistered()
|
||||||
|
{
|
||||||
|
if (registered)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// factory is only null in the test-only constructor, which sets
|
||||||
|
// registered = true, so this path is never hit there.
|
||||||
|
object created = factory!.Create()
|
||||||
|
?? throw new InvalidOperationException("MXAccess COM factory returned null.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mxAccessComObject = created;
|
||||||
|
|
||||||
|
// Wire OnDataChange the same way MxAccessBaseEventSink.Attach does:
|
||||||
|
// cast to the concrete LMXProxyServerClass RCW and subscribe. Test
|
||||||
|
// doubles cannot be cast to LMXProxyServerClass, so the COM event
|
||||||
|
// subscription is exercised only on the live path; unit tests drive
|
||||||
|
// HandleDataChange directly via the internal constructor seam.
|
||||||
|
if (created is LMXProxyServerClass comObject)
|
||||||
|
{
|
||||||
|
comEventSource = comObject;
|
||||||
|
comObject.OnDataChange += OnDataChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
server = new MxAccessComServer(created);
|
||||||
|
serverHandle = server.Register(clientName);
|
||||||
|
registered = true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (comEventSource is not null)
|
||||||
|
{
|
||||||
|
try { comEventSource.OnDataChange -= OnDataChange; } catch { /* swallow */ }
|
||||||
|
comEventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Marshal.IsComObject(created))
|
||||||
|
{
|
||||||
|
try { Marshal.FinalReleaseComObject(created); } catch { /* swallow */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
mxAccessComObject = null;
|
||||||
|
server = null;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// COM <c>OnDataChange</c> delegate target. Forwards to
|
||||||
|
/// <see cref="HandleDataChange"/>; the quality, status array, and server
|
||||||
|
/// handle carry no information the subtag fallback needs.
|
||||||
|
/// </summary>
|
||||||
|
private void OnDataChange(
|
||||||
|
int hLMXServerHandle,
|
||||||
|
int phItemHandle,
|
||||||
|
object pvItemValue,
|
||||||
|
int pwItemQuality,
|
||||||
|
object pftItemTimeStamp,
|
||||||
|
ref MXSTATUS_PROXY[] pVars)
|
||||||
|
{
|
||||||
|
HandleDataChange(phItemHandle, pvItemValue, pftItemTimeStamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime ConvertTimestampUtc(object? pftItemTimeStamp)
|
||||||
|
{
|
||||||
|
if (pftItemTimeStamp is not null
|
||||||
|
&& MxAccessEventMapper.TryParseSourceTimestamp(pftItemTimeStamp.ToString(), out DateTime parsedUtc))
|
||||||
|
{
|
||||||
|
return parsedUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(LmxSubtagAlarmSource));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,12 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
|||||||
/// <param name="operatorComment">The operator's comment, if any.</param>
|
/// <param name="operatorComment">The operator's comment, if any.</param>
|
||||||
/// <param name="category">The alarm category.</param>
|
/// <param name="category">The alarm category.</param>
|
||||||
/// <param name="description">The alarm description.</param>
|
/// <param name="description">The alarm description.</param>
|
||||||
|
/// <param name="degraded">
|
||||||
|
/// <see langword="true"/> when the transition was synthesized by the
|
||||||
|
/// subtag-provider fallback rather than the native alarmmgr path.
|
||||||
|
/// Defaults to <see langword="false"/> so existing alarmmgr callers
|
||||||
|
/// compile unchanged and stay on the parity (alarmmgr) path.
|
||||||
|
/// </param>
|
||||||
internal void EnqueueTransition(
|
internal void EnqueueTransition(
|
||||||
string alarmFullReference,
|
string alarmFullReference,
|
||||||
string sourceObjectReference,
|
string sourceObjectReference,
|
||||||
@@ -97,10 +103,16 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
|||||||
string operatorUser,
|
string operatorUser,
|
||||||
string operatorComment,
|
string operatorComment,
|
||||||
string category,
|
string category,
|
||||||
string description)
|
string description,
|
||||||
|
bool degraded = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Degraded transitions come from the subtag fallback; the native
|
||||||
|
// alarmmgr (wnwrap) path stays degraded=false / ALARMMGR for parity.
|
||||||
|
AlarmProviderMode sourceProvider = degraded
|
||||||
|
? AlarmProviderMode.Subtag
|
||||||
|
: AlarmProviderMode.Alarmmgr;
|
||||||
MxEvent mxEvent = eventMapper.CreateOnAlarmTransition(
|
MxEvent mxEvent = eventMapper.CreateOnAlarmTransition(
|
||||||
sessionId,
|
sessionId,
|
||||||
alarmFullReference,
|
alarmFullReference,
|
||||||
@@ -114,7 +126,9 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
|||||||
operatorComment,
|
operatorComment,
|
||||||
category,
|
category,
|
||||||
description,
|
description,
|
||||||
statuses: null);
|
statuses: null,
|
||||||
|
degraded: degraded,
|
||||||
|
sourceProvider: sourceProvider);
|
||||||
eventQueue.Enqueue(mxEvent);
|
eventQueue.Enqueue(mxEvent);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ public sealed class MxAccessEventMapper
|
|||||||
/// <param name="category">Alarm taxonomy bucket from the Galaxy template.</param>
|
/// <param name="category">Alarm taxonomy bucket from the Galaxy template.</param>
|
||||||
/// <param name="description">Human-readable alarm description.</param>
|
/// <param name="description">Human-readable alarm description.</param>
|
||||||
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
|
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
|
||||||
|
/// <param name="degraded">
|
||||||
|
/// <see langword="true"/> when this transition was synthesized by the
|
||||||
|
/// subtag-provider fallback rather than the native alarmmgr path.
|
||||||
|
/// Defaults to <see langword="false"/> to preserve alarmmgr parity.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="sourceProvider">
|
||||||
|
/// The alarm provider that sourced this transition. Defaults to
|
||||||
|
/// <see cref="AlarmProviderMode.Alarmmgr"/> for the native path.
|
||||||
|
/// </param>
|
||||||
public MxEvent CreateOnAlarmTransition(
|
public MxEvent CreateOnAlarmTransition(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
string alarmFullReference,
|
string alarmFullReference,
|
||||||
@@ -137,7 +146,9 @@ public sealed class MxAccessEventMapper
|
|||||||
string operatorComment,
|
string operatorComment,
|
||||||
string category,
|
string category,
|
||||||
string description,
|
string description,
|
||||||
Array? statuses)
|
Array? statuses,
|
||||||
|
bool degraded = false,
|
||||||
|
AlarmProviderMode sourceProvider = AlarmProviderMode.Alarmmgr)
|
||||||
{
|
{
|
||||||
MxEvent mxEvent = CreateBaseEvent(
|
MxEvent mxEvent = CreateBaseEvent(
|
||||||
MxEventFamily.OnAlarmTransition,
|
MxEventFamily.OnAlarmTransition,
|
||||||
@@ -159,6 +170,8 @@ public sealed class MxAccessEventMapper
|
|||||||
OperatorComment = operatorComment ?? string.Empty,
|
OperatorComment = operatorComment ?? string.Empty,
|
||||||
Category = category ?? string.Empty,
|
Category = category ?? string.Empty,
|
||||||
Description = description ?? string.Empty,
|
Description = description ?? string.Empty,
|
||||||
|
Degraded = degraded,
|
||||||
|
SourceProvider = sourceProvider,
|
||||||
};
|
};
|
||||||
if (originalRaiseTimestampUtc is { } orts)
|
if (originalRaiseTimestampUtc is { } orts)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|||||||
/// Single alarm record as emitted by the wnwrapConsumer XML stream.
|
/// Single alarm record as emitted by the wnwrapConsumer XML stream.
|
||||||
/// Field names match the captured XML schema (see
|
/// Field names match the captured XML schema (see
|
||||||
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" section).
|
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" section).
|
||||||
|
/// Records are also produced by the subtag synthesis path when
|
||||||
|
/// <see cref="Degraded"/> is set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MxAlarmSnapshotRecord
|
public sealed class MxAlarmSnapshotRecord
|
||||||
{
|
{
|
||||||
@@ -37,4 +39,12 @@ public sealed class MxAlarmSnapshotRecord
|
|||||||
public string OperatorName { get; set; } = string.Empty;
|
public string OperatorName { get; set; } = string.Empty;
|
||||||
/// <summary>Gets or sets the alarm comment.</summary>
|
/// <summary>Gets or sets the alarm comment.</summary>
|
||||||
public string AlarmComment { get; set; } = string.Empty;
|
public string AlarmComment { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether this record was synthesized
|
||||||
|
/// by the subtag-provider fallback rather than emitted by the native
|
||||||
|
/// alarmmgr (wnwrap) path. Default <see langword="false"/> preserves
|
||||||
|
/// parity for the alarmmgr path; the subtag fallback sets it to
|
||||||
|
/// <see langword="true"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool Degraded { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synthesizes alarm transitions from raw subtag value changes when the
|
||||||
|
/// native alarmmgr (wnwrap) consumer is unavailable. Each configured
|
||||||
|
/// <see cref="AlarmSubtagTarget"/> contributes an <c>.active</c>,
|
||||||
|
/// <c>.acked</c>, optional <c>.priority</c>, and optional ack-comment
|
||||||
|
/// subtag; the machine tracks per-alarm state and emits
|
||||||
|
/// <see cref="MxAlarmTransitionEvent"/> records mirroring the wnwrap
|
||||||
|
/// consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions.
|
||||||
|
/// This type performs no COM work and is fully unit-testable.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This type has STA affinity: it is mutated and read on the worker STA
|
||||||
|
/// thread only and is not thread-safe. The per-target
|
||||||
|
/// <see cref="AlarmSubtagTarget.AckCommentSubtag"/> is a write-only ack
|
||||||
|
/// target and is deliberately not bound for observation. The synthesized
|
||||||
|
/// <see cref="MxAlarmSnapshotRecord.AlarmGuid"/> is left at its default
|
||||||
|
/// value: in subtag mode acks are correlated by name/comment rather than
|
||||||
|
/// by GUID, and a synthetic GUID is assigned by a later component.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SubtagAlarmStateMachine
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<MxAlarmTransitionEvent> NoEvents = Array.Empty<MxAlarmTransitionEvent>();
|
||||||
|
|
||||||
|
private readonly Dictionary<string, AlarmState> _statesByReference = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, SubtagBinding> _bindingsByAddress = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Initializes the machine from the configured alarm subtag targets.</summary>
|
||||||
|
/// <param name="targets">The alarm targets whose subtags will be observed.</param>
|
||||||
|
public SubtagAlarmStateMachine(IEnumerable<AlarmSubtagTarget> targets)
|
||||||
|
{
|
||||||
|
if (targets is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(targets));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (AlarmSubtagTarget target in targets)
|
||||||
|
{
|
||||||
|
var state = new AlarmState(target);
|
||||||
|
_statesByReference[target.AlarmFullReference] = state;
|
||||||
|
|
||||||
|
Bind(target.ActiveSubtag, state, SubtagRole.Active);
|
||||||
|
Bind(target.AckedSubtag, state, SubtagRole.Acked);
|
||||||
|
Bind(target.PrioritySubtag, state, SubtagRole.Priority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a single subtag value change and returns any synthesized
|
||||||
|
/// transitions. Unknown addresses and no-op changes return an empty list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemAddress">The subtag address whose value changed.</param>
|
||||||
|
/// <param name="value">The new value (bool, numeric, or string).</param>
|
||||||
|
/// <param name="timestampUtc">The UTC timestamp of the change.</param>
|
||||||
|
/// <returns>The synthesized alarm transitions, if any.</returns>
|
||||||
|
public IReadOnlyList<MxAlarmTransitionEvent> Apply(string itemAddress, object? value, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
if (itemAddress is null || !_bindingsByAddress.TryGetValue(itemAddress, out SubtagBinding binding))
|
||||||
|
{
|
||||||
|
return NoEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
AlarmState state = binding.State;
|
||||||
|
switch (binding.Role)
|
||||||
|
{
|
||||||
|
case SubtagRole.Active:
|
||||||
|
return ApplyActive(state, CoerceBool(value), timestampUtc);
|
||||||
|
case SubtagRole.Acked:
|
||||||
|
return ApplyAcked(state, CoerceBool(value), timestampUtc);
|
||||||
|
case SubtagRole.Priority:
|
||||||
|
state.Priority = CoerceInt(value, state.Priority);
|
||||||
|
return NoEvents;
|
||||||
|
default:
|
||||||
|
return NoEvents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns one snapshot record per currently-active alarm, with state
|
||||||
|
/// reflecting whether the alarm has been acknowledged.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The active alarm snapshot records.</returns>
|
||||||
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActive()
|
||||||
|
{
|
||||||
|
var records = new List<MxAlarmSnapshotRecord>();
|
||||||
|
foreach (AlarmState state in _statesByReference.Values)
|
||||||
|
{
|
||||||
|
if (!state.Active)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
MxAlarmStateKind kind = state.Acked ? MxAlarmStateKind.AckAlm : MxAlarmStateKind.UnackAlm;
|
||||||
|
records.Add(state.BuildRecord(kind, state.FirstRaiseUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<MxAlarmTransitionEvent> ApplyActive(AlarmState state, bool newActive, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
if (newActive == state.Active)
|
||||||
|
{
|
||||||
|
return NoEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newActive)
|
||||||
|
{
|
||||||
|
// false -> true: alarm raised, initially unacknowledged.
|
||||||
|
MxAlarmStateKind previous = state.DerivedState();
|
||||||
|
state.Active = true;
|
||||||
|
state.Acked = false;
|
||||||
|
state.AckedDuringEpisode = false;
|
||||||
|
state.FirstRaiseUtc = timestampUtc;
|
||||||
|
return Single(state, MxAlarmStateKind.UnackAlm, previous, timestampUtc);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// true -> false: alarm returned to normal. The return kind reflects
|
||||||
|
// whether the alarm was acknowledged at any point during the active
|
||||||
|
// episode, not just its instantaneous Acked flag (see remarks).
|
||||||
|
MxAlarmStateKind previous = state.DerivedState();
|
||||||
|
MxAlarmStateKind kind = state.AckedDuringEpisode ? MxAlarmStateKind.AckRtn : MxAlarmStateKind.UnackRtn;
|
||||||
|
state.Active = false;
|
||||||
|
state.Acked = false;
|
||||||
|
state.AckedDuringEpisode = false;
|
||||||
|
return Single(state, kind, previous, timestampUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="AlarmState.AckedDuringEpisode"/> latches that the alarm was
|
||||||
|
/// acknowledged at some point while active. MXAccess does not guarantee
|
||||||
|
/// that the acked subtag update arrives before the active=false update on
|
||||||
|
/// clear, so an out-of-order un-ack (acked true->false) must not lose the
|
||||||
|
/// fact that the episode was acknowledged; the latch ensures the clear
|
||||||
|
/// still emits ACK_RTN rather than UNACK_RTN.
|
||||||
|
/// </remarks>
|
||||||
|
private IReadOnlyList<MxAlarmTransitionEvent> ApplyAcked(AlarmState state, bool newAcked, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
if (newAcked == state.Acked)
|
||||||
|
{
|
||||||
|
return NoEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Acked = newAcked;
|
||||||
|
|
||||||
|
// Only a true ack of a currently-active alarm produces an ACK_ALM
|
||||||
|
// transition; the latch records that the episode was acked at some point.
|
||||||
|
if (newAcked && state.Active)
|
||||||
|
{
|
||||||
|
state.AckedDuringEpisode = true;
|
||||||
|
return Single(state, MxAlarmStateKind.AckAlm, MxAlarmStateKind.UnackAlm, timestampUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// acked true->false while active: do NOT clear the latch (the episode was
|
||||||
|
// already acked); emit nothing.
|
||||||
|
return NoEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<MxAlarmTransitionEvent> Single(
|
||||||
|
AlarmState state,
|
||||||
|
MxAlarmStateKind kind,
|
||||||
|
MxAlarmStateKind previous,
|
||||||
|
DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new MxAlarmTransitionEvent
|
||||||
|
{
|
||||||
|
Record = state.BuildRecord(kind, timestampUtc),
|
||||||
|
PreviousState = previous,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Bind(string? address, AlarmState state, SubtagRole role)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(address))
|
||||||
|
{
|
||||||
|
if (_bindingsByAddress.ContainsKey(address!))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Duplicate subtag item address '{address}' is bound to more than one alarm target.",
|
||||||
|
nameof(address));
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindingsByAddress[address!] = new SubtagBinding(state, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CoerceBool(object? value)
|
||||||
|
{
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case null:
|
||||||
|
return false;
|
||||||
|
case bool b:
|
||||||
|
return b;
|
||||||
|
case string s:
|
||||||
|
if (bool.TryParse(s, out bool parsedBool))
|
||||||
|
{
|
||||||
|
return parsedBool;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out double parsedNum))
|
||||||
|
{
|
||||||
|
return parsedNum != 0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.ToDouble(value, CultureInfo.InvariantCulture) != 0d;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CoerceInt(object? value, int fallback)
|
||||||
|
{
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case null:
|
||||||
|
return fallback;
|
||||||
|
case int i:
|
||||||
|
return i;
|
||||||
|
case string s when int.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out int parsed):
|
||||||
|
return parsed;
|
||||||
|
case string:
|
||||||
|
return fallback;
|
||||||
|
default:
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.ToInt32(value, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SubtagRole
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
Acked,
|
||||||
|
Priority,
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct SubtagBinding
|
||||||
|
{
|
||||||
|
public SubtagBinding(AlarmState state, SubtagRole role)
|
||||||
|
{
|
||||||
|
State = state;
|
||||||
|
Role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AlarmState State { get; }
|
||||||
|
|
||||||
|
public SubtagRole Role { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AlarmState
|
||||||
|
{
|
||||||
|
private readonly string _providerName;
|
||||||
|
private readonly string _group;
|
||||||
|
private readonly string _tagName;
|
||||||
|
|
||||||
|
public AlarmState(AlarmSubtagTarget target)
|
||||||
|
{
|
||||||
|
(_providerName, _group, _tagName) = DeriveNameParts(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Active { get; set; }
|
||||||
|
|
||||||
|
public bool Acked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Latches that the alarm was acknowledged at some point during the
|
||||||
|
/// current active episode, so a clear emits ACK_RTN even when the
|
||||||
|
/// un-ack subtag arrives before the active=false update.
|
||||||
|
/// </summary>
|
||||||
|
public bool AckedDuringEpisode { get; set; }
|
||||||
|
|
||||||
|
public int Priority { get; set; }
|
||||||
|
|
||||||
|
public DateTime FirstRaiseUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Derives the current alarm state from the tracked flags, before a transition is applied.</summary>
|
||||||
|
public MxAlarmStateKind DerivedState()
|
||||||
|
{
|
||||||
|
if (!Active)
|
||||||
|
{
|
||||||
|
return MxAlarmStateKind.Unspecified;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Acked ? MxAlarmStateKind.AckAlm : MxAlarmStateKind.UnackAlm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxAlarmSnapshotRecord BuildRecord(MxAlarmStateKind kind, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
return new MxAlarmSnapshotRecord
|
||||||
|
{
|
||||||
|
ProviderName = _providerName,
|
||||||
|
Group = _group,
|
||||||
|
TagName = _tagName,
|
||||||
|
Priority = Priority,
|
||||||
|
State = kind,
|
||||||
|
TransitionTimestampUtc = timestampUtc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits an alarm full reference such as <c>Galaxy!Area.Tank01.Level.HiHi</c>
|
||||||
|
/// into provider (<c>Galaxy</c>), group/area (<c>Area</c>), and the object-rooted
|
||||||
|
/// tag name (<c>Tank01.Level.HiHi</c>). The tag name is anchored on the target's
|
||||||
|
/// <see cref="AlarmSubtagTarget.SourceObjectReference"/> when present, so any
|
||||||
|
/// leading provider and area segments are stripped.
|
||||||
|
/// </summary>
|
||||||
|
private static (string ProviderName, string Group, string TagName) DeriveNameParts(AlarmSubtagTarget target)
|
||||||
|
{
|
||||||
|
string reference = target.AlarmFullReference ?? string.Empty;
|
||||||
|
|
||||||
|
string providerName = string.Empty;
|
||||||
|
string afterBang = reference;
|
||||||
|
int bang = reference.IndexOf('!');
|
||||||
|
if (bang >= 0)
|
||||||
|
{
|
||||||
|
providerName = reference.Substring(0, bang);
|
||||||
|
afterBang = reference.Substring(bang + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
string tagName = afterBang;
|
||||||
|
string source = target.SourceObjectReference ?? string.Empty;
|
||||||
|
string group = string.Empty;
|
||||||
|
if (source.Length > 0)
|
||||||
|
{
|
||||||
|
// Match the source on dot-delimited segment boundaries so a source
|
||||||
|
// such as "Tank" does not match mid-token inside "Tank01" and a
|
||||||
|
// source "Area" does not match inside "TankArea".
|
||||||
|
string[] segments = afterBang.Split('.');
|
||||||
|
int segmentIndex = -1;
|
||||||
|
for (int i = 0; i < segments.Length; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(segments[i], source, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
segmentIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segmentIndex > 0)
|
||||||
|
{
|
||||||
|
// Everything before the object is the area/group; the object
|
||||||
|
// and everything after it is the object-rooted tag name.
|
||||||
|
group = string.Join(".", segments, 0, segmentIndex);
|
||||||
|
tagName = string.Join(".", segments, segmentIndex, segments.Length - segmentIndex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// segmentIndex == 0 (source is the first segment) or not found:
|
||||||
|
// no leading area to strip, so the tag name is the whole afterBang.
|
||||||
|
tagName = afterBang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (providerName, group, tagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>A change in one advised subtag value, normalized off the COM boundary.</summary>
|
||||||
|
public sealed class SubtagValueChange
|
||||||
|
{
|
||||||
|
/// <summary>Gets or sets the MXAccess item address (subtag reference) whose value changed.</summary>
|
||||||
|
public string ItemAddress { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the new value, as delivered by MXAccess (boxed COM variant or test value).</summary>
|
||||||
|
public object? Value { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the UTC timestamp the change was observed.</summary>
|
||||||
|
public DateTime TimestampUtc { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a deterministic synthetic <see cref="Guid"/> from an alarm
|
||||||
|
/// reference for the subtag-provider fallback path, which has no native
|
||||||
|
/// MxAccess alarm GUID. Hashing the reference yields a stable identity so
|
||||||
|
/// repeated transitions for the same alarm reference correlate downstream
|
||||||
|
/// (acknowledge, snapshot, OPC UA mapping) without an alarmmgr-supplied GUID.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SyntheticAlarmGuid
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Produces a stable <see cref="Guid"/> for the given alarm reference.
|
||||||
|
/// The same reference always maps to the same GUID; distinct references
|
||||||
|
/// map to distinct GUIDs with overwhelming probability.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reference">
|
||||||
|
/// The fully-qualified alarm reference (for example
|
||||||
|
/// <c>"Galaxy!Area.Tag.HiHi"</c>). Treated as UTF-8 bytes.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>A deterministic, non-empty GUID derived from the reference.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">
|
||||||
|
/// Thrown when <paramref name="reference"/> is <see langword="null"/>.
|
||||||
|
/// </exception>
|
||||||
|
public static Guid ForReference(string reference)
|
||||||
|
{
|
||||||
|
if (reference is null) throw new ArgumentNullException(nameof(reference));
|
||||||
|
|
||||||
|
byte[] bytes = Encoding.UTF8.GetBytes(reference);
|
||||||
|
|
||||||
|
// MD5 is used purely for a stable, non-cryptographic identity mapping
|
||||||
|
// (reference -> 16-byte GUID), never for security. Its 128-bit output
|
||||||
|
// fits a GUID exactly, which is why it is preferred here.
|
||||||
|
using MD5 md5 = MD5.Create();
|
||||||
|
byte[] hash = md5.ComputeHash(bytes);
|
||||||
|
return new Guid(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user