diff --git a/docs/v2/f14b-part9-sdk-notes.md b/docs/v2/f14b-part9-sdk-notes.md new file mode 100644 index 00000000..1f843b35 --- /dev/null +++ b/docs/v2/f14b-part9-sdk-notes.md @@ -0,0 +1,609 @@ +# F14b — OPC UA Part 9 Alarm & Condition SDK Notes (T13) + +Reference notes for materialising **real** OPC UA Part 9 Alarm & Condition +nodes in our `CustomNodeManager2`-based `OtOpcUaNodeManager`, firing events +on state transitions, and accepting inbound Acknowledge / Confirm / Shelve / +AddComment method calls. This replaces the current placeholder in +`OtOpcUaNodeManager.WriteAlarmState`, which only writes a two-element +`[active, acknowledged]` `BaseDataVariableState` and fires no events. + +Consumed by implementation tasks **T14–T17**. + +## Provenance (read this first) + +- **DeepWiki MCP: NOT AVAILABLE** in this session. `mcp__deepwiki__*` tools + were not surfaced by the tool registry (only Gmail/Calendar/Drive/Chrome + MCP servers were). All findings below come from sources 2 and 3 instead. +- **Local NuGet cache (authoritative, version-exact).** Every API name, + ctor, delegate, and method signature below was confirmed by **decompiling + the exact assembly we reference** — `OPCFoundation.NetStandard.Opc.Ua.*` + **v1.5.378.106** (pinned in `Directory.Packages.props`), `net10.0` TFM — + with `ilspycmd`. The Part 9 condition types live in `Opc.Ua.Core.dll`; + `NodeState` / `ISystemContext` / `IUserIdentity` live in `Opc.Ua.Types.dll`; + `CustomNodeManager2` lives in `Opc.Ua.Server.dll`. +- **GitHub reference sample (pattern-level, master branch).** The end-to-end + node-creation / event-firing / handler-wiring patterns were cross-checked + against the OPC Foundation reference server at + `Applications/Quickstarts.Servers/Alarms/` (the `AlarmHolders/*` classes + + `AlarmNodeManager.cs`). **Caveat:** these samples are on `master`, which is + slightly ahead of 1.5.378.106 — they use a few newer optional members + (e.g. `SilenceState`, `OutOfServiceState`, `LatchedState`, + `PropertyState.With`, `ByteString`). The *core* alarm + pattern (Create → set state → ReportEvent; OnAcknowledge/OnConfirm + delegates) is identical in both. Anything sample-only that I could not + also confirm in the 1.5.378.106 assembly is flagged **[SAMPLE-ONLY]** below. + +### What's verified vs. uncertain + +| Claim | Status | +|---|---| +| Condition/Alarm type names, class hierarchy, ctors | **Verified** in 1.5.378.106 assembly | +| `SetActiveState` / `SetAcknowledgedState` / `SetConfirmedState` / `SetEnableState` / `SetSeverity` / `SetShelvingState` / `SetSuppressedState` signatures | **Verified** in assembly | +| `OnAcknowledge` / `OnConfirm` / `OnAddComment` / `OnEnableDisable` / `OnShelve` / `OnTimedUnshelve` delegate field types + signatures | **Verified** in assembly | +| `NodeState.Create(...)` / `ReportEvent(...)` / `AddNotifier(...)` signatures | **Verified** in assembly | +| `CustomNodeManager2.AddRootNotifier` / `SubscribeToEvents` / `ConditionRefresh` / `Call` behaviour | **Verified** by decompiling `CustomNodeManager2` | +| Principal surfacing via `ISessionOperationContext.UserIdentity` (`GetCurrentUserId`) | **Verified** in assembly | +| End-to-end wiring sequence (folder EventNotifier → AddRootNotifier → alarm.Create → AddChild → ReportEvent) | **Verified** by decompile + cross-checked against reference sample | +| No packaged base alarm server class — must hand-roll on `CustomNodeManager2` | **Verified** (no `AlarmConditionServer`/`SampleNodeManager` type in the `Opc.Ua.Server` NuGet) | +| `InitializeAlarmConditionEvent` helper | **DOES NOT EXIST** in this SDK — see Q3 | + +--- + +## Q1 — Node creation under a `CustomNodeManager2` + +### Type hierarchy (all in namespace `Opc.Ua`, assembly `Opc.Ua.Core`) + +``` +BaseObjectState + └─ BaseEventState (EventId, EventType, SourceNode, SourceName, Time, ReceiveTime, Message, Severity) + └─ ConditionState (EnabledState, Retain, ConditionClassId, ConditionName, BranchId, Quality, Comment, AddComment method) + └─ AcknowledgeableConditionState (AckedState, ConfirmedState, Acknowledge + Confirm methods) + └─ AlarmConditionState (ActiveState, SuppressedState, ShelvingState, Shelve/Unshelve methods, ...) + ├─ LimitAlarmState (HighLimit / HighHighLimit / LowLimit / LowLowLimit : PropertyState) + │ ├─ ExclusiveLimitAlarmState (LimitState : ExclusiveLimitStateMachineState; SetLimitState) + │ │ ├─ ExclusiveLevelAlarmState + │ │ ├─ ExclusiveDeviationAlarmState + │ │ └─ ExclusiveRateOfChangeAlarmState + │ └─ NonExclusiveLimitAlarmState (HighState/HighHighState/LowState/LowLowState : TwoStateVariableState; SetLimitState) + │ ├─ NonExclusiveLevelAlarmState + │ ├─ NonExclusiveDeviationAlarmState + │ └─ NonExclusiveRateOfChangeAlarmState + ├─ DiscreteAlarmState + │ ├─ OffNormalAlarmState + │ │ ├─ SystemOffNormalAlarmState + │ │ └─ TripAlarmState + │ └─ (CertificateExpirationAlarmState, etc.) + └─ DiscrepancyAlarmState, InstrumentDiagnosticAlarmState, ... +``` + +These are all **ModelCompiler-generated** (`[GeneratedCode("Opc.Ua.ModelCompiler")]`). +Each carries an embedded base64 `InitializationString`: calling `Create(...)` +builds the **entire mandatory child set** (EnabledState, AckedState, +ConfirmedState, ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable +methods, etc.) automatically. You do **not** hand-build child variables/methods. + +### Constructors (verified) + +`AlarmConditionState` (and only it, among these) has **two** ctors: + +```csharp +public AlarmConditionState(NodeState parent) // classic +public AlarmConditionState(ITelemetryContext telemetry, NodeState parent) // 1.5.378 telemetry-aware +``` + +`ConditionState`, `AcknowledgeableConditionState`, `LimitAlarmState`, +`ExclusiveLimitAlarmState`, `NonExclusiveLimitAlarmState`, `OffNormalAlarmState`, +etc. expose only the **`(NodeState parent)`** ctor in 1.5.378.106. + +> **Recommendation:** prefer the telemetry ctor for `AlarmConditionState` +> when a telemetry context is reachable (`Server.Telemetry`), matching the +> reference sample (`new AlarmConditionState(Server.Telemetry, parent)`). +> The `(NodeState parent)` ctor is fine and is the only option for the +> non-alarm condition types. + +### The create-and-parent pattern (verified shape, from `AlarmHolder.InitializeInternal`) + +```csharp +// 'parent' is the owning ObjectState/FolderState already in the address space. +// 'trigger' is the source variable the alarm reports against (optional). +var alarm = new AlarmConditionState(Server.Telemetry, parent); + +var nodeId = new NodeId($"{parentId}.{alarmName}", NamespaceIndex); +var browseName = new QualifiedName(alarmName, NamespaceIndex); +var displayName = new LocalizedText(alarmName); + +alarm.SymbolicName = alarmName; +alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent; // how the parent links to it + +// Builds the full mandatory child tree from the embedded type definition. +alarm.Create( + SystemContext, // ISystemContext + nodeId, // NodeId (or null/default to auto-assign) + browseName, // QualifiedName + displayName, // LocalizedText + assignNodeIds: true); + +// (optional) point the source variable at the condition +trigger.AddReference(ReferenceTypeIds.HasCondition, isInverse: false, alarm.NodeId); + +parent.AddChild(alarm); // wires the parent→alarm reference +AddPredefinedNode(SystemContext, alarm); // registers node + all children with the manager +``` + +`NodeState.Create` signature (verified): +```csharp +public virtual void Create(ISystemContext context, NodeId nodeId, + QualifiedName browseName, LocalizedText displayName, + bool assignNodeIds) +``` + +> **Important:** `AddPredefinedNode(SystemContext, alarm)` registers the alarm +> **and all the children `Create` minted** into `PredefinedNodes`, so browse / +> read / method-call routing all work. Our existing `EnsureFolder` / +> `EnsureVariable` already use `AddPredefinedNode`; the alarm node is the same +> call. When tearing down, mirror `RebuildAddressSpace`: `parent.RemoveChild` +> + remove from `PredefinedNodes`. + +### What makes a node an event-notifier (so clients can subscribe to its events) + +Two things, both required: + +1. The **notifier object** (typically the folder/area the alarms live under, + or our `_root`) must set + `EventNotifier = EventNotifiers.SubscribeToEvents` and be registered as a + **root notifier**: + + ```csharp + alarmsFolder.EventNotifier = EventNotifiers.SubscribeToEvents; + AddRootNotifier(alarmsFolder); // protected, on CustomNodeManager2 + ``` + + `AddRootNotifier` (verified) does three things: sets + `notifier.OnReportEvent = OnReportEvent` (which forwards to + `Server.ReportEvent`), adds an **inverse `HasNotifier` reference to the + Server object** (so the Server object becomes the well-known event entry + point clients subscribe to), and back-fills any existing + "monitor all events" subscriptions. + +2. The condition node must have a **notifier path up to that root notifier**. + `alarm.Create(...)` under a parent that is itself a root notifier is the + simplest path; `ReportEvent` walks **inverse** notifier references upward + (see Q3). If a condition is several folders deep, each intermediate folder + needs `EventNotifier = SubscribeToEvents` and an `AddNotifier` / + `HasNotifier` link, OR just make each alarm folder its own root notifier. + +`NodeState.AddNotifier` (verified): +```csharp +public virtual void AddNotifier(ISystemContext context, NodeId referenceTypeId, + bool isInverse, NodeState target) +// referenceTypeId null ⇒ HasEventSource. Use HasNotifier for object→object notifier chains. +``` + +> **Simplest robust design for us:** make the single OtOpcUa alarm-root folder +> a root notifier (`SubscribeToEvents` + `AddRootNotifier`) and `Create` every +> alarm condition directly (or via plain folders that are themselves root +> notifiers) under it. Don't over-engineer the notifier hierarchy until a +> deep-tree requirement appears. + +--- + +## Q2 — State setters (which take an `ISystemContext`) + +All the `Set*` mutators below are on the condition/alarm types and **take an +`ISystemContext` first arg** — pass the manager's `SystemContext` (a +`ServerSystemContext`) or a copy of it. They internally update the relevant +`TwoStateVariableState` (`.Id.Value`, `.Value` true/false text, +`TransitionTime`) and flag change masks. + +| Goal | API (verified signature) | Defined on | +|---|---|---| +| Active / inactive | `void SetActiveState(ISystemContext context, bool active)` | `AlarmConditionState` | +| Acked / unacked | `void SetAcknowledgedState(ISystemContext context, bool acknowledged)` | `AcknowledgeableConditionState` | +| Confirmed / unconfirmed | `void SetConfirmedState(ISystemContext context, bool confirmed)` | `AcknowledgeableConditionState` | +| Enabled / disabled | `void SetEnableState(ISystemContext context, bool enabled)` | `ConditionState` | +| Suppressed | `void SetSuppressedState(ISystemContext context, bool suppressed)` | `AlarmConditionState` | +| Shelving (all 3 transitions) | `void SetShelvingState(ISystemContext context, bool shelved, bool oneShot, double shelvingTime)` | `AlarmConditionState` | +| Severity | `void SetSeverity(ISystemContext context, EventSeverity severity)` | `ConditionState` | +| Comment (+ user) | `void SetComment(ISystemContext context, LocalizedText comment, string clientUserId)` | `ConditionState` | +| Exclusive limit zone | `void SetLimitState(ISystemContext context, LimitAlarmStates limit)` | `ExclusiveLimitAlarmState` | +| Non-exclusive limit zone | `void SetLimitState(ISystemContext context, LimitAlarmStates limit)` | `NonExclusiveLimitAlarmState` | + +**`SupportsConfirm()`** (verified, on `AcknowledgeableConditionState`) gates +whether the Confirm sub-state machine is active. The SDK auto-decides this from +whether `ConfirmedState` was materialised. To enable Confirm you must have a +`ConfirmedState` child + call `SetConfirmedState` during init. + +### Properties you set directly (not via a `Set*` helper) + +These are inherited `PropertyState` nodes; assign `.Value` and (for event +fields) let `ReportEvent` snapshot them: + +| Property | Type (verified) | Notes | +|---|---|---| +| `Message` | `PropertyState` | event message text | +| `Severity` | `PropertyState` | prefer `SetSeverity` (also stamps `LastSeverity`) | +| `Time` | `PropertyState` | event source time (UTC); set per event in `ReportEvent` | +| `ReceiveTime` | `PropertyState` | usually `= Time.Value` | +| `EventId` | `PropertyState` | **new GUID-bytes per event** (see Q3) | +| `EventType` | `PropertyState` | set by `Create` from the type; can override | +| `SourceNode` / `SourceName` | `PropertyState` / `PropertyState` | the originating tag | +| `Retain` | `PropertyState` | **drives ConditionRefresh replay** (see Q5) | +| `ConditionClassId` | `PropertyState` | e.g. `ObjectTypeIds.ProcessConditionClassType` | +| `ConditionName` | `PropertyState` | human name of the condition | +| `BranchId` | `PropertyState` | `new NodeId()` (null) for the main branch | +| `Quality` | `PropertyState` | usually `StatusCodes.Good` | +| `EnabledState` | `TwoStateVariableState` | set via `SetEnableState`, not directly | + +`EventSeverity` is the `Opc.Ua.EventSeverity` enum (`Low`/`Medium`/`High`/...); +`SetSeverity` maps it to the `ushort Severity` value and stamps `LastSeverity`. + +--- + +## Q3 — Firing an event on a state transition + +### There is NO `InitializeAlarmConditionEvent` in this SDK + +The method named in the task brief **does not exist** in 1.5.378.106. The real +firing path is: set the per-event fields, then call `ReportEvent` passing a +**filter target** that is a snapshot of the node. + +`NodeState.ReportEvent` (verified): +```csharp +public virtual void ReportEvent(ISystemContext context, IFilterTarget e) +``` + +The reference sample's verbatim pattern (from `ConditionTypeHolder.ReportEvent`): + +```csharp +if (alarm.EnabledState.Id.Value) // only fire if enabled +{ + alarm.EventId.Value = Uuid.NewUuid().ToByteString(); // unique per event + alarm.Time.Value = DateTime.UtcNow; + alarm.ReceiveTime.Value = alarm.Time.Value; + // (Message / Severity / ActiveState / AckedState already set by the Set* calls) + + alarm.ClearChangeMasks(SystemContext, includeChildren: true); + + var snapshot = new InstanceStateSnapshot(); + snapshot.Initialize(SystemContext, alarm); + alarm.ReportEvent(SystemContext, snapshot); // IFilterTarget = InstanceStateSnapshot +} +``` + +Key points: + +- **`InstanceStateSnapshot`** is the `IFilterTarget` — a frozen copy of the + condition's fields at fire time, so the event a client receives reflects the + values *at that instant* even if the live node mutates afterwards. `Initialize` + reads the node's children into the snapshot. +- **A fresh `EventId` per event** is mandatory — Acknowledge/Confirm/AddComment + inbound calls are correlated back to a specific event by this `EventId` + (`GetEventByEventId` / `GetBranch`). Reusing one breaks ack routing. +- `ReportEvent` then walks **inverse notifier references upward**: each notifier + in the chain re-reports, and the root notifier's `OnReportEvent` hands off to + `Server.ReportEvent`, which queues the event to subscribed monitored items. +- **Typical transition flow:** `SetActiveState`/`SetAcknowledgedState`/... → + set `Message`/`Severity` → set `Retain` (active or unacked ⇒ `true`) → + `ReportEvent`. + +### Threading / locking + +- **Every node-manager mutation must hold the manager's `Lock`** (we already do + this in `OtOpcUaNodeManager` — see `WriteValue`/`EnsureFolder`). Creating the + alarm, the `Set*` calls, and `ReportEvent` must all run **under `lock (Lock)`**. + `CustomNodeManager2.AddRootNotifier`, `ConditionRefresh`, etc. all take `Lock` + internally; if you call them while *already* holding `Lock` that's fine (it's + the same monitor — `Lock` is a plain `object` used with C# `lock`, which is + re-entrant on the same thread). +- `ReportEvent` itself does **not** require any extra lock beyond the manager + `Lock` you already hold for the mutation; the Server's event queue is + internally synchronised. + +--- + +## Q4 — Inbound method calls (Acknowledge / Confirm / AddComment / Shelve) + +### You do NOT route methods by hand + +`alarm.Create(...)` materialises the standard Part 9 methods (`Acknowledge`, +`Confirm`, `AddComment`, `Enable`, `Disable`, `OneShotShelve`, `TimedShelve`, +`Unshelve`) as `MethodState` children, and the condition types **wire their own +`OnCall` handlers in `OnAfterCreate`** (verified): + +```csharp +// AcknowledgeableConditionState.OnAfterCreate: +Acknowledge.OnCall = OnAcknowledgeCalled; +Confirm.OnCall = OnConfirmCalled; +// ConditionState.OnAfterCreate: +AddComment.OnCall = OnAddCommentCalled; +``` + +When a client calls `Acknowledge`, the path is: +`MasterNodeManager.Call` → `CustomNodeManager2.Call(...)` (verified: routes to +`MethodState.OnCall`) → `AcknowledgeableConditionState.OnAcknowledgeCalled(...)`. +The built-in `OnAcknowledgeCalled` (verified body) already does **all of this** +for you: + +1. `ProcessBeforeAcknowledge` (validates `eventId`, checks `EnabledState`, + **invokes your `OnAcknowledge` delegate** for veto/permission — see below); +2. resolves the branch via `GetAcknowledgeableBranch(eventId)`; +3. `SetAcknowledgedState(context, true)` (+ `SetConfirmedState(false)` if it + supports confirm); +4. `SetComment(context, comment, GetCurrentUserId(context))`; +5. `UpdateRetainState()`; +6. if events are monitored, `ReportStateChange(...)` **(fires the condition + event for you)** plus an `AuditConditionAcknowledgeEventState` audit event. + +So for a basic implementation you **don't even need to fire an event after an +ack** — the SDK does it. You only supply the **veto/permission/business +delegates**. + +### Delegate hooks (the wiring point) — verified field types + signatures + +| Hook field | Delegate type | Signature | +|---|---|---| +| `AcknowledgeableConditionState.OnAcknowledge` | `ConditionAddCommentEventHandler` | `(ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment) → ServiceResult` | +| `AcknowledgeableConditionState.OnConfirm` | `ConditionAddCommentEventHandler` | same as above | +| `ConditionState.OnAddComment` | `ConditionAddCommentEventHandler` | same as above | +| `ConditionState.OnEnableDisable` | `ConditionEnableEventHandler` | `(ISystemContext, ConditionState, bool enabling) → ServiceResult` | +| `AlarmConditionState.OnShelve` | `AlarmConditionShelveEventHandler` | `(ISystemContext, AlarmConditionState alarm, bool shelving, bool oneShot, double shelvingTime) → ServiceResult` | +| `AlarmConditionState.OnTimedUnshelve` | `AlarmConditionTimedUnshelveEventHandler` | `(ISystemContext, AlarmConditionState alarm) → ServiceResult` | + +> Note: `OnAcknowledge`, `OnConfirm`, and `OnAddComment` **all use the same +> delegate type** `ConditionAddCommentEventHandler` +> `(context, condition, eventId, comment)`, matching the task brief exactly. +> There is **no** separate `AcknowledgeMethodHandler` / `ConfirmMethodHandler` +> type — those are not in this SDK. + +Wire them right after `Create` (and after setting initial state): + +```csharp +alarm.OnAcknowledge = OnAcknowledgeHandler; // ServiceResult OnAcknowledgeHandler(ISystemContext ctx, ConditionState c, byte[] eventId, LocalizedText comment) +alarm.OnConfirm = OnConfirmHandler; +alarm.OnAddComment = OnAddCommentHandler; +alarm.OnEnableDisable = OnEnableDisableHandler; +alarm.OnShelve = OnShelveHandler; // only if shelving is supported +alarm.OnTimedUnshelve = OnTimedUnshelveHandler; +``` + +Returning a **bad `ServiceResult`** (e.g. +`StatusCodes.BadUserAccessDenied`, `StatusCodes.BadConditionBranchAlreadyAcked`, +`StatusCodes.BadEventIdUnknown`) **vetoes** the operation — the SDK aborts the +state change and returns that status to the client. This is the natural seam for +our **AlarmAck permission gate** (T17): check the caller's role in +`OnAcknowledge`, return `BadUserAccessDenied` if they lack `AlarmAck`. + +### How the calling principal reaches the handler + +The `ISystemContext context` passed into the handler is a `ServerSystemContext` +(`: SessionSystemContext`) carrying the session's identity. The SDK's own +`ConditionState.GetCurrentUserId` (verified) reads it as: + +```csharp +if (context is ISessionOperationContext { UserIdentity: not null } s) + return s.UserIdentity.DisplayName; // IUserIdentity +``` + +So in our handlers: + +```csharp +var identity = (context as ISessionOperationContext)?.UserIdentity; // Opc.Ua.IUserIdentity +// identity.DisplayName : string +// identity.GrantedRoleIds : NodeIdCollection ← use for the AlarmAck gate +// identity.TokenType : UserTokenType +``` + +`IUserIdentity` (verified) exposes `DisplayName`, `TokenType`, `IssuedTokenType`, +and **`GrantedRoleIds` (`NodeIdCollection`)** — the latter is what the AlarmAck +gate should check (or carry our own claims via the identity we set during +authentication). `ServerSystemContext` is constructed from the `OperationContext` +/ `ISession` (`UserIdentity = session.Identity`), so the principal is whoever +authenticated the calling session via our `IOpcUaUserAuthenticator` / +`LdapOpcUaUserAuthenticator`. + +> The reference sample also uses `context.UserId` (a `string`) inside handlers — +> that resolves on the concrete `SystemContext`/`IOperationContext`. Prefer the +> `ISessionOperationContext.UserIdentity` cast above: it's what the SDK itself +> uses and gives the full `IUserIdentity` (roles), not just a display string. + +### Shelve specifics + +- Client `OneShotShelve` / `TimedShelve` / `Unshelve` methods call into + `AlarmConditionState.OnOneShotShelve` / `OnTimedShelve` / `OnUnshelve`, which + invoke your `OnShelve` (with `shelving`/`oneShot`/`shelvingTime` flags) and + `OnTimedUnshelve` delegates, then call `SetShelvingState`. +- The `ShelvingState` is a `ShelvedStateMachineState` child; for timed shelve + the SDK runs an internal timer (`UnshelveTimeUpdateRate`, + `OnTimerExpired`/`OnUnshelveTimeUpdate`) that auto-unshelves. `UnshelveTime` + is a calculated property (read handler `OnReadUnshelveTime`). +- **[SAMPLE-ONLY]** `SilenceState`, `OutOfServiceState`, `LatchedState`, + `MaxTimeShelved` are created as **optional** children in the master sample. + In 1.5.378.106 the `ShelvingState`/`SuppressedState`/`OutOfServiceState`/ + `SilenceState`/`LatchedState` properties **exist** on `AlarmConditionState`, + but you must materialise the optional ones yourself before `Create` + (e.g. `alarm.ShelvingState = new ShelvedStateMachineState(alarm)` then + `ShelvingState.Create(...)`). For our first pass we likely only need + ActiveState + AckedState + (optional) ConfirmedState + ShelvingState. + +--- + +## Q5 — Gotchas / threading / ConditionRefresh / base server + +### Dispatcher / threading +- All node-manager mutations under **`lock (Lock)`** (already our convention). + Build + `Create` + `Set*` + `ReportEvent` for one transition in a single + critical section so a client never observes a half-applied state. +- `CustomNodeManager2.Lock` is a plain `object` used with `lock` — re-entrant + on the same thread, so calling `AddRootNotifier`/`ConditionRefresh` while + holding it is safe. + +### EventId / branches +- **Fresh `EventId` per `ReportEvent`** (GUID bytes). Inbound + Acknowledge/Confirm carry the `eventId` of the event being acked; the SDK + matches it via `GetEventByEventId` / `GetAcknowledgeableBranch` / + `GetBranch(eventId)`. +- **Branches** (`CreateBranch(context, branchId)` → + `ConditionState`) model "a previous unacked occurrence while a new one is + already active". They are **optional** — only needed if we want the + ack-the-old-while-new-is-active semantics. **For T14–T17 we can skip + branching initially** (main branch `BranchId = new NodeId()` / null) and add + it later; our `Part9StateMachine` doesn't model branches today. + +### ConditionRefresh (must work, or clients can't recover retained state) +- When a client (re)subscribes to events and calls the `ConditionRefresh` + method, `CustomNodeManager2.ConditionRefresh` (verified) iterates the + monitored items, calls `node.ConditionRefresh(context, events, true)` on each + root notifier (or the specific source node), collects the retained condition + events, and queues a refresh batch to that monitored item. +- For this to replay our active/unacked alarms, **each live condition must have + `Retain.Value == true`** while it should appear in a refresh (Part 9 rule: + retain while active OR unacknowledged OR unconfirmed). The built-in + `UpdateRetainState`/`GetRetainState` handles this if you keep ack/active state + in sync; if you compute retain yourself, set `alarm.Retain.Value` on every + transition. **`CustomNodeManager2.ConditionRefresh` is `public virtual` and + already implemented** — we get refresh "for free" as long as the alarms are + registered (`AddPredefinedNode`) and reachable from a root notifier with + correct `Retain`. + +### Is there a base alarm server to lean on? — **No (in NuGet).** +- The `OPCFoundation.NetStandard.Opc.Ua.Server` **NuGet package** contains + **no** `AlarmConditionServer`, `SampleNodeManager`, or alarm-holder helper. + Confirmed: the only alarm/condition type in `Opc.Ua.Server` is the interface + `IConditionRefreshAsyncNodeManager`. The `AlarmConditionServer` / + `Quickstarts.Servers` code lives **only in the GitHub repo's + `Applications/` tree**, which is *not* packaged. +- **Therefore we hand-roll on `CustomNodeManager2`** (which we already + subclass). The base class gives us everything load-bearing: + - `Call` → routes client method calls to `MethodState.OnCall` (so + Acknowledge/Confirm/AddComment/Shelve "just work" once `Create` runs); + - `SubscribeToEvents` / `SubscribeToAllEvents` → flips + `node.SetAreEventsMonitored` so `EventsMonitored()` gates audit-event + emission; + - `AddRootNotifier` / `RemoveRootNotifier` → notifier wiring to the Server + object; + - `ConditionRefresh` → retained-event replay; + - `AddPredefinedNode` → node registration (we use it already). +- The reference `AlarmHolders/*` classes are a **good pattern reference to + copy selectively** (Create → set state → ReportEvent; delegate wiring), but + they're tangled with the sample's controllers/branching/optional-children + scaffolding we don't need. Lift the *shape*, not the whole hierarchy. + +--- + +## Mapping to our existing domain model (for T14–T17) + +We already have a **pure** Part 9 state machine and domain records in +`src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/`: +`Part9StateMachine` (`ApplyAcknowledge`/`ApplyConfirm`/`ApplyOneShotShelve`/ +`ApplyTimedShelve`/`ApplyUnshelve`/`ApplyEnable`/`ApplyDisable`/`ApplyAddComment`/ +`ApplyShelvingCheck`) and the `AlarmConditionState` / `ShelvingState` / +`AlarmComment` records. + +**The SDK node is a *projection* of that domain state, not a second source of +truth.** Recommended split: + +- **Inbound (client → us):** keep the SDK delegates *thin*. In + `OnAcknowledge`/`OnConfirm`/`OnShelve`/etc., (a) run the **AlarmAck permission + gate** off `ISessionOperationContext.UserIdentity`, returning a bad + `ServiceResult` to veto if denied; (b) translate to the matching + `Part9StateMachine.Apply*` call against our domain state; (c) let the + resulting domain transition drive the authoritative store. You can either let + the SDK's built-in `OnAcknowledgeCalled` post-processing apply the SDK-side + state change + event, or set state yourself — but don't double-fire. +- **Outbound (engine → clients):** when our `ScriptedAlarmEngine` produces a new + `AlarmConditionState` (active/severity/ack changes), project it onto the SDK + node: `SetActiveState` / `SetAcknowledgedState` / `SetConfirmedState` / + `SetSeverity` / set `Message` + `Retain`, then `ReportEvent` — all under + `lock (Lock)`. +- This replaces `OtOpcUaNodeManager.WriteAlarmState`'s `[active, ack]` boolean + pair with a real `AlarmConditionState` node keyed by the same alarm node id. + +--- + +## Minimal end-to-end sketch (what T14–T16 build toward) + +```csharp +// --- one-time, in CreateAddressSpace or first alarm registration (under Lock) --- +_alarmsFolder.EventNotifier = EventNotifiers.SubscribeToEvents; +AddRootNotifier(_alarmsFolder); + +// --- register one alarm condition (under Lock) --- +var alarm = new AlarmConditionState(Server.Telemetry, _alarmsFolder); +alarm.SymbolicName = name; +alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent; +alarm.Create(SystemContext, new NodeId($"{parentId}.{name}", NamespaceIndex), + new QualifiedName(name, NamespaceIndex), new LocalizedText(name), true); + +alarm.ConditionName.Value = name; +alarm.ConditionClassId.Value = ObjectTypeIds.BaseConditionClassType; // or a specific class +alarm.SetEnableState(SystemContext, true); +alarm.SetActiveState(SystemContext, false); +alarm.SetAcknowledgedState(SystemContext, true); +alarm.Retain.Value = false; + +alarm.OnAcknowledge = OnAck; // permission gate + domain Apply +alarm.OnConfirm = OnConfirm; +alarm.OnAddComment = OnAddComment; +alarm.OnEnableDisable = OnEnableDisable; + +_alarmsFolder.AddChild(alarm); +AddPredefinedNode(SystemContext, alarm); +_alarms[alarmNodeId] = alarm; + +// --- on an engine-driven transition (under Lock) --- +alarm.SetActiveState(SystemContext, active: true); +alarm.SetAcknowledgedState(SystemContext, acknowledged: false); +alarm.SetSeverity(SystemContext, EventSeverity.High); +alarm.Message.Value = new LocalizedText("en", "High temperature"); +alarm.Retain.Value = true; // active|unacked ⇒ retain +alarm.EventId.Value = Uuid.NewUuid().ToByteString(); +alarm.Time.Value = alarm.ReceiveTime.Value = DateTime.UtcNow; +alarm.ClearChangeMasks(SystemContext, true); +var snap = new InstanceStateSnapshot(); +snap.Initialize(SystemContext, alarm); +alarm.ReportEvent(SystemContext, snap); + +// --- inbound ack handler (called by SDK Call routing; on a server thread) --- +ServiceResult OnAck(ISystemContext ctx, ConditionState c, byte[] eventId, LocalizedText comment) +{ + var id = (ctx as ISessionOperationContext)?.UserIdentity; + if (!HasAlarmAck(id)) return StatusCodes.BadUserAccessDenied; // T17 gate + // translate to Part9StateMachine.ApplyAcknowledge(...) against domain state + return ServiceResult.Good; // returning Good lets the SDK set acked state + fire the event +} +``` + +--- + +## Open uncertainties to validate during T14–T17 + +1. **Telemetry ctor vs classic ctor at runtime.** We verified both ctors exist; + confirm `Server.Telemetry` (`ITelemetryContext`) is non-null in our host + before relying on the telemetry ctor — fall back to `new AlarmConditionState(parent)` + if not. (Not load-bearing; pick whichever the host supports.) +2. **Optional children before `Create`.** Whether `ShelvingState` / + `ConfirmedState` are auto-created by `Create` or must be instantiated first + (the sample instantiates them) — **[SAMPLE-ONLY]** behaviour; verify by + inspecting the live node after `Create` (browse the children). If Confirm / + Shelve children are missing, materialise them like the sample before `Create`. +3. **`InstanceStateSnapshot` vs reporting the node directly.** The sample uses + an `InstanceStateSnapshot` as the `IFilterTarget`. Confirm whether reporting + the alarm node itself (which is also an `IFilterTarget`) is acceptable — the + snapshot is safer (frozen values) and is the documented pattern; default to it. +4. **Double-event on inbound ack.** The built-in `OnAcknowledgeCalled` already + fires `ReportStateChange` after a successful ack. Make sure our handler does + **not** also call `ReportEvent` for the same transition (would double-emit). + Decide whether to drive SDK state from the handler return (let SDK fire) or + suppress the SDK's auto-state-change and project from the engine. +5. **`GrantedRoleIds` vs our own claim model.** Confirm whether our + `LdapOpcUaUserAuthenticator` populates `IUserIdentity.GrantedRoleIds`, or + whether the AlarmAck permission lives on a custom identity we set. The gate + in `OnAcknowledge` must read whichever surface our auth actually fills. + +--- + +*All API names/signatures verified against `OPCFoundation.NetStandard.Opc.Ua.*` +v1.5.378.106 (`net10.0`) via `ilspycmd` decompilation, cross-checked against the +OPC Foundation reference server samples at +`UA-.NETStandard/Applications/Quickstarts.Servers/Alarms/` (master). DeepWiki +MCP was unavailable; no claim here rests on it.*