docs(scripted-alarms): F14b OPC UA Part 9 SDK research notes (T13)
This commit is contained in:
@@ -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<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 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.*
|
||||||
Reference in New Issue
Block a user