32 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. WhetherShelvingState/ConfirmedStateare auto-created byCreateor must be instantiated first (the sample instantiates them) — [SAMPLE-ONLY] behaviour; verify by inspecting the live node afterCreate(browse the children). If Confirm / Shelve children are missing, materialise them like the sample beforeCreate. 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.