Files
lmxopcua/docs/v2/f14b-part9-sdk-notes.md
T

619 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 **T14T17**.
## 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<T>.With<VariantBuilder>`, `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<double>)
│ ├─ 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<T>` nodes; assign `.Value` and (for event
fields) let `ReportEvent` snapshot them:
| Property | Type (verified) | Notes |
|---|---|---|
| `Message` | `PropertyState<LocalizedText>` | event message text |
| `Severity` | `PropertyState<ushort>` | prefer `SetSeverity` (also stamps `LastSeverity`) |
| `Time` | `PropertyState<DateTime>` | event source time (UTC); set per event in `ReportEvent` |
| `ReceiveTime` | `PropertyState<DateTime>` | usually `= Time.Value` |
| `EventId` | `PropertyState<byte[]>` | **new GUID-bytes per event** (see Q3) |
| `EventType` | `PropertyState<NodeId>` | set by `Create` from the type; can override |
| `SourceNode` / `SourceName` | `PropertyState<NodeId>` / `PropertyState<string>` | the originating tag |
| `Retain` | `PropertyState<bool>` | **drives ConditionRefresh replay** (see Q5) |
| `ConditionClassId` | `PropertyState<NodeId>` | e.g. `ObjectTypeIds.ProcessConditionClassType` |
| `ConditionName` | `PropertyState<string>` | human name of the condition |
| `BranchId` | `PropertyState<NodeId>` | `new NodeId()` (null) for the main branch |
| `Quality` | `PropertyState<StatusCode>` | 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 T14T17 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 T14T17)
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 T14T16 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 T14T17
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.~~
**RESOLVED in T14 (real-server integration test, 1.5.378.106):** `Create`
auto-builds the **full** optional Part 9 child set from the embedded type
definition with **no** pre-setting — for `OffNormalAlarmState`, both
`ConfirmedState` AND `ShelvingState` come back **non-null** after `Create`
(richer than the `[SAMPLE-ONLY]` caveat predicted). So T15/T16 can call
`SetConfirmedState` / `SetShelvingState` directly; no manual child
materialisation is needed. **Gotcha also found:** `BranchId.Value` is left a
**null reference** by `Create`, and the very first `Set*` call
(`SetEnableState` → `UpdateRetainState` → `GetRetainState` → `IsBranch()`)
**NREs** on it. Fix: set `alarm.BranchId.Value = NodeId.Null` (the main
branch) **before** any `Set*` call. T14's `MaterialiseAlarmCondition` does
this. (Covered by `SdkAddressSpaceSinkTests.MaterialiseAlarmCondition_*`.)
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.*