feat(scripted-alarms): richer AlarmConditionState bridge to the OPC UA node (T15)

This commit is contained in:
Joseph Doherty
2026-06-10 19:41:16 -04:00
parent b31d7cb03f
commit 4eb1d65e2b
16 changed files with 349 additions and 108 deletions
@@ -90,42 +90,69 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
/// <summary>
/// Apply an alarm-state write. When a real Part 9 <see cref="AlarmConditionState"/> has been
/// materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
/// this projects <paramref name="active"/>/<paramref name="acknowledged"/> onto the live
/// condition node's ActiveState/AckedState/Retain (T14 — basic active/ack only; <b>no event
/// firing yet</b>, that lands in T16). Otherwise it falls back to the legacy two-element
/// <c>[active, acknowledged]</c> <see cref="BaseDataVariableState"/> placeholder so callers
/// whose alarm node hasn't been materialised (and the existing unit tests) keep working.
/// Apply a full Part 9 alarm-condition write. When a real <see cref="AlarmConditionState"/> has
/// been materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
/// this projects the whole <paramref name="state"/> snapshot
/// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
/// node and recomputes Retain (T15 — richer state; <b>still no event firing</b>, that lands in T16).
/// Otherwise it falls back to the legacy two-element <c>[Active, Acknowledged]</c>
/// <see cref="BaseDataVariableState"/> placeholder so callers whose alarm node hasn't been
/// materialised (and the existing unit tests) keep working.
/// </summary>
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
/// <param name="active">Whether the alarm is currently active.</param>
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
/// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(state);
if (_alarmConditions.TryGetValue(alarmNodeId, out var condition))
{
lock (Lock)
{
condition.SetActiveState(SystemContext, active);
condition.SetAcknowledgedState(SystemContext, acknowledged);
// EnabledState / AckedState / ActiveState are mandatory children — always present after
// Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is
// that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a
// future SDK that builds a leaner child set) may leave them null. Null-guard each optional
// child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a
// no-op rather than an NRE.
condition.SetEnableState(SystemContext, state.Enabled);
condition.SetActiveState(SystemContext, state.Active);
condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (condition.ConfirmedState is not null)
{
condition.SetConfirmedState(SystemContext, state.Confirmed);
}
if (condition.ShelvingState is not null)
{
// SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the
// SDK timer, so we pass shelvingTime=0 (no SDK-managed auto-unshelve).
condition.SetShelvingState(
SystemContext,
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
oneShot: state.Shelving == AlarmShelvingKind.OneShot,
shelvingTime: 0);
}
condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
condition.Message.Value = new LocalizedText(state.Message);
// Part 9: retain the condition while it is active OR unacknowledged so a client's
// ConditionRefresh replays it. T16's event firing will also drive Retain; here we keep
// it correct for the basic projection.
condition.Retain.Value = active || !acknowledged;
// it correct for the projection.
condition.Retain.Value = state.Active || !state.Acknowledged;
condition.Time.Value = sourceTimestampUtc;
condition.ReceiveTime.Value = sourceTimestampUtc;
// NO ReportEvent here — T16 owns event firing. ClearChangeMasks just notifies any
// attribute (not event) subscribers watching ActiveState/AckedState/Retain directly.
// attribute (not event) subscribers watching the condition's children directly.
condition.ClearChangeMasks(SystemContext, includeChildren: true);
}
return;
}
// Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable.
// Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so
// un-materialised callers (and the existing unit tests) keep working.
lock (Lock)
{
// CreateVariable mutates the SDK address space, so it MUST run under Lock (see WriteValue).
@@ -135,7 +162,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_variables[alarmNodeId] = variable;
}
variable.Value = new[] { active, acknowledged };
variable.Value = new[] { state.Active, state.Acknowledged };
variable.StatusCode = StatusCodes.Good;
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
@@ -146,7 +173,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> node under its equipment
/// folder so clients can browse it as a proper condition (and, once T16 lands, subscribe to its
/// events). The node id is the alarm node id (the ScriptedAlarmId) so subsequent
/// <see cref="WriteAlarmState"/> calls — which target that same id — update this node.
/// <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
/// <para>
/// This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
/// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires