33 KiB
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 inDirectory.Packages.props),net10.0TFM — withilspycmd. The Part 9 condition types live inOpc.Ua.Core.dll;NodeState/ISystemContext/IUserIdentitylive inOpc.Ua.Types.dll;CustomNodeManager2lives inOpc.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/(theAlarmHolders/*classes +AlarmNodeManager.cs). Caveat: these samples are onmaster, 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:
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
AlarmConditionStatewhen 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)
// '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):
public virtual void Create(ISystemContext context, NodeId nodeId,
QualifiedName browseName, LocalizedText displayName,
bool assignNodeIds)
Important:
AddPredefinedNode(SystemContext, alarm)registers the alarm and all the childrenCreateminted intoPredefinedNodes, so browse / read / method-call routing all work. Our existingEnsureFolder/EnsureVariablealready useAddPredefinedNode; the alarm node is the same call. When tearing down, mirrorRebuildAddressSpace:parent.RemoveChild
- remove from
PredefinedNodes.
What makes a node an event-notifier (so clients can subscribe to its events)
Two things, both required:
-
The notifier object (typically the folder/area the alarms live under, or our
_root) must setEventNotifier = EventNotifiers.SubscribeToEventsand be registered as a root notifier:alarmsFolder.EventNotifier = EventNotifiers.SubscribeToEvents; AddRootNotifier(alarmsFolder); // protected, on CustomNodeManager2AddRootNotifier(verified) does three things: setsnotifier.OnReportEvent = OnReportEvent(which forwards toServer.ReportEvent), adds an inverseHasNotifierreference 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. -
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;ReportEventwalks inverse notifier references upward (see Q3). If a condition is several folders deep, each intermediate folder needsEventNotifier = SubscribeToEventsand anAddNotifier/HasNotifierlink, OR just make each alarm folder its own root notifier.
NodeState.AddNotifier (verified):
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) andCreateevery 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):
public virtual void ReportEvent(ISystemContext context, IFilterTarget e)
The reference sample's verbatim pattern (from ConditionTypeHolder.ReportEvent):
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:
InstanceStateSnapshotis theIFilterTarget— 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.Initializereads the node's children into the snapshot.- A fresh
EventIdper event is mandatory — Acknowledge/Confirm/AddComment inbound calls are correlated back to a specific event by thisEventId(GetEventByEventId/GetBranch). Reusing one breaks ack routing. ReportEventthen walks inverse notifier references upward: each notifier in the chain re-reports, and the root notifier'sOnReportEventhands off toServer.ReportEvent, which queues the event to subscribed monitored items.- Typical transition flow:
SetActiveState/SetAcknowledgedState/... → setMessage/Severity→ setRetain(active or unacked ⇒true) →ReportEvent.
Threading / locking
- Every node-manager mutation must hold the manager's
Lock(we already do this inOtOpcUaNodeManager— seeWriteValue/EnsureFolder). Creating the alarm, theSet*calls, andReportEventmust all run underlock (Lock).CustomNodeManager2.AddRootNotifier,ConditionRefresh, etc. all takeLockinternally; if you call them while already holdingLockthat's fine (it's the same monitor —Lockis a plainobjectused with C#lock, which is re-entrant on the same thread). ReportEventitself does not require any extra lock beyond the managerLockyou 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):
// 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:
ProcessBeforeAcknowledge(validateseventId, checksEnabledState, invokes yourOnAcknowledgedelegate for veto/permission — see below);- resolves the branch via
GetAcknowledgeableBranch(eventId); SetAcknowledgedState(context, true)(+SetConfirmedState(false)if it supports confirm);SetComment(context, comment, GetCurrentUserId(context));UpdateRetainState();- if events are monitored,
ReportStateChange(...)(fires the condition event for you) plus anAuditConditionAcknowledgeEventStateaudit 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, andOnAddCommentall use the same delegate typeConditionAddCommentEventHandler(context, condition, eventId, comment), matching the task brief exactly. There is no separateAcknowledgeMethodHandler/ConfirmMethodHandlertype — those are not in this SDK.
Wire them right after Create (and after setting initial state):
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:
if (context is ISessionOperationContext { UserIdentity: not null } s)
return s.UserIdentity.DisplayName; // IUserIdentity
So in our handlers:
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(astring) inside handlers — that resolves on the concreteSystemContext/IOperationContext. Prefer theISessionOperationContext.UserIdentitycast above: it's what the SDK itself uses and gives the fullIUserIdentity(roles), not just a display string.
Shelve specifics
- Client
OneShotShelve/TimedShelve/Unshelvemethods call intoAlarmConditionState.OnOneShotShelve/OnTimedShelve/OnUnshelve, which invoke yourOnShelve(withshelving/oneShot/shelvingTimeflags) andOnTimedUnshelvedelegates, then callSetShelvingState. - The
ShelvingStateis aShelvedStateMachineStatechild; for timed shelve the SDK runs an internal timer (UnshelveTimeUpdateRate,OnTimerExpired/OnUnshelveTimeUpdate) that auto-unshelves.UnshelveTimeis a calculated property (read handlerOnReadUnshelveTime). - [SAMPLE-ONLY]
SilenceState,OutOfServiceState,LatchedState,MaxTimeShelvedare created as optional children in the master sample. In 1.5.378.106 theShelvingState/SuppressedState/OutOfServiceState/SilenceState/LatchedStateproperties exist onAlarmConditionState, but you must materialise the optional ones yourself beforeCreate(e.g.alarm.ShelvingState = new ShelvedStateMachineState(alarm)thenShelvingState.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*+ReportEventfor one transition in a single critical section so a client never observes a half-applied state. CustomNodeManager2.Lockis a plainobjectused withlock— re-entrant on the same thread, so callingAddRootNotifier/ConditionRefreshwhile holding it is safe.
EventId / branches
- Fresh
EventIdperReportEvent(GUID bytes). Inbound Acknowledge/Confirm carry theeventIdof the event being acked; the SDK matches it viaGetEventByEventId/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 branchBranchId = new NodeId()/ null) and add it later; ourPart9StateMachinedoesn't model branches today.
ConditionRefresh (must work, or clients can't recover retained state)
- When a client (re)subscribes to events and calls the
ConditionRefreshmethod,CustomNodeManager2.ConditionRefresh(verified) iterates the monitored items, callsnode.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 == truewhile it should appear in a refresh (Part 9 rule: retain while active OR unacknowledged OR unconfirmed). The built-inUpdateRetainState/GetRetainStatehandles this if you keep ack/active state in sync; if you compute retain yourself, setalarm.Retain.Valueon every transition.CustomNodeManager2.ConditionRefreshispublic virtualand already implemented — we get refresh "for free" as long as the alarms are registered (AddPredefinedNode) and reachable from a root notifier with correctRetain.
Is there a base alarm server to lean on? — No (in NuGet).
- The
OPCFoundation.NetStandard.Opc.Ua.ServerNuGet package contains noAlarmConditionServer,SampleNodeManager, or alarm-holder helper. Confirmed: the only alarm/condition type inOpc.Ua.Serveris the interfaceIConditionRefreshAsyncNodeManager. TheAlarmConditionServer/Quickstarts.Serverscode lives only in the GitHub repo'sApplications/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 toMethodState.OnCall(so Acknowledge/Confirm/AddComment/Shelve "just work" onceCreateruns);SubscribeToEvents/SubscribeToAllEvents→ flipsnode.SetAreEventsMonitoredsoEventsMonitored()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 offISessionOperationContext.UserIdentity, returning a badServiceResultto veto if denied; (b) translate to the matchingPart9StateMachine.Apply*call against our domain state; (c) let the resulting domain transition drive the authoritative store. You can either let the SDK's built-inOnAcknowledgeCalledpost-processing apply the SDK-side state change + event, or set state yourself — but don't double-fire. - Outbound (engine → clients): when our
ScriptedAlarmEngineproduces a newAlarmConditionState(active/severity/ack changes), project it onto the SDK node:SetActiveState/SetAcknowledgedState/SetConfirmedState/SetSeverity/ setMessage+Retain, thenReportEvent— all underlock (Lock). - This replaces
OtOpcUaNodeManager.WriteAlarmState's[active, ack]boolean pair with a realAlarmConditionStatenode keyed by the same alarm node id.
Minimal end-to-end sketch (what T14–T16 build toward)
// --- 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
- 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 tonew AlarmConditionState(parent)if not. (Not load-bearing; pick whichever the host supports.) - Optional children before
Create.WhetherRESOLVED in T14 (real-server integration test, 1.5.378.106):ShelvingState/ConfirmedStateare auto-created byCreateor must be instantiated first.Createauto-builds the full optional Part 9 child set from the embedded type definition with no pre-setting — forOffNormalAlarmState, bothConfirmedStateANDShelvingStatecome back non-null afterCreate(richer than the[SAMPLE-ONLY]caveat predicted). So T15/T16 can callSetConfirmedState/SetShelvingStatedirectly; no manual child materialisation is needed. Gotcha also found:BranchId.Valueis left a null reference byCreate, and the very firstSet*call (SetEnableState→UpdateRetainState→GetRetainState→IsBranch()) NREs on it. Fix: setalarm.BranchId.Value = NodeId.Null(the main branch) before anySet*call. T14'sMaterialiseAlarmConditiondoes this. (Covered bySdkAddressSpaceSinkTests.MaterialiseAlarmCondition_*.) InstanceStateSnapshotvs reporting the node directly. The sample uses anInstanceStateSnapshotas theIFilterTarget. Confirm whether reporting the alarm node itself (which is also anIFilterTarget) is acceptable — the snapshot is safer (frozen values) and is the documented pattern; default to it.- Double-event on inbound ack. The built-in
OnAcknowledgeCalledalready firesReportStateChangeafter a successful ack. Make sure our handler does not also callReportEventfor 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. GrantedRoleIdsvs our own claim model. Confirm whether ourLdapOpcUaUserAuthenticatorpopulatesIUserIdentity.GrantedRoleIds, or whether the AlarmAck permission lives on a custom identity we set. The gate inOnAcknowledgemust 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.