e6ec0ad8be
- HistoryReadEvents miss path + catch path now both set results[handle.Index] explicitly
(new SdkHistoryReadResult { StatusCode = BadHistoryOperationUnsupported }) — don't rely on
base pre-seeding results[i] so every path sets BOTH errors and results coherently (#1)
- ProjectEventField: SourceName null now emits Variant.Null instead of a String-typed null
variant (evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName)) (#3)
- Comment near the HistoryRead dispatcher block updated: all four arms (Raw/Processed/AtTime
+ Events/Task 4) are now overridden — "left to the base" wording was stale (#5)
- Happy-path test adds ReceiveTime to select clauses and asserts it projects ReceivedTimeUtc
as a DateTime Variant at the correct select-order position (#4)
- Backend-throw test hardened: asserts errors[0] via ServiceResult.IsBad + explicit code,
asserts results[0] is non-null with the Bad code (no longer relies on base seeding),
and asserts EventsEntered to prove the override reached the bridge before the throw (#1)
- RecordingHistorianDataSource gains EventsEntered flag (set before ThrowOnRead check) (#1)
- Events_non_source_node test gains clarifying doc comment explaining the SDK base rejects
variable nodes (EventNotifier=None) for event reads before our override runs; the
override's source-guard is exercised by the promoted-without-source test instead (#2)
1483 lines
90 KiB
C#
1483 lines
90 KiB
C#
using System.Collections.Concurrent;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
// The SDK's HistoryRead service result (the value the override fills + hands back) and the historian
|
|
// data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous:
|
|
// the SDK result stays unqualified as the dominant name in the override; the source DTO is HistorianRead.
|
|
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
|
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
/// <summary>
|
|
/// Custom OPC UA <see cref="CustomNodeManager2"/> that owns the writable address space for
|
|
/// the OtOpcUa server. Variable nodes are created lazily on first <see cref="WriteValue"/>
|
|
/// under the manager's namespace; subsequent writes update the existing node's Value +
|
|
/// StatusCode + SourceTimestamp and notify subscribed clients via the standard
|
|
/// <c>ClearChangeMasks</c> path.
|
|
///
|
|
/// This is the F10b production wiring behind the v2 <see cref="IOpcUaAddressSpaceSink"/>
|
|
/// seam — once a <see cref="SdkAddressSpaceSink"/> is bound, OpcUaPublishActor's writes
|
|
/// materialise as real OPC UA Variable updates that clients can browse + subscribe to.
|
|
///
|
|
/// Node-id encoding uses the manager's default namespace + the caller-supplied string id
|
|
/// as the identifier portion (e.g. <c>"ns=2;s=eq-1/temp"</c>). Equipment-folder hierarchy
|
|
/// and OPC UA type metadata still come from the Phase7Applier / EquipmentNodeWalker
|
|
/// integration (F14b, tracked under #85) — this manager treats every id as a flat
|
|
/// <see cref="BaseDataVariableState"/> under the namespace root.
|
|
/// </summary>
|
|
public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|
{
|
|
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
|
|
|
|
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, AlarmConditionState> _alarmConditions = new(StringComparer.Ordinal);
|
|
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
|
|
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
|
|
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
|
|
/// <see cref="RebuildAddressSpace"/>.</summary>
|
|
private readonly ConcurrentDictionary<string, string> _historizedTagnames = new(StringComparer.Ordinal);
|
|
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
|
|
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
|
|
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
|
|
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
|
|
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
|
|
/// <summary>Phase C (Task 4): event-notifier folder NodeId-identifier → the event-history source
|
|
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
|
|
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
|
|
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
|
|
/// <see cref="EnsureFolderIsEventNotifier"/> only when a real historian is wired at promotion time,
|
|
/// and the <see cref="HistoryReadEvents"/> override resolves an inbound request's notifier NodeId
|
|
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
|
|
/// <see cref="RebuildAddressSpace"/>.</summary>
|
|
private readonly ConcurrentDictionary<string, string> _eventNotifierSources = new(StringComparer.Ordinal);
|
|
private FolderState? _root;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
|
|
/// <param name="server">The OPC UA server instance.</param>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
|
: base(server, configuration, DefaultNamespaceUri)
|
|
{
|
|
// SystemContext is initialised by the base ctor.
|
|
}
|
|
|
|
/// <summary>Gets the count of variable nodes currently managed.</summary>
|
|
public int VariableCount => _variables.Count;
|
|
/// <summary>Gets the count of folder nodes currently managed.</summary>
|
|
public int FolderCount => _folders.Count;
|
|
/// <summary>Gets the count of real Part 9 <see cref="AlarmConditionState"/> nodes currently managed.</summary>
|
|
public int AlarmConditionCount => _alarmConditions.Count;
|
|
|
|
/// <summary>
|
|
/// Reverse-path sink for inbound OPC UA Part 9 alarm method calls. When a client invokes a
|
|
/// materialised condition's Acknowledge / Confirm / Shelve / AddComment method, the condition's
|
|
/// handler (wired in <see cref="MaterialiseAlarmCondition"/>) gates on the caller's
|
|
/// <c>AlarmAck</c> role and, when allowed, builds an <see cref="AlarmCommand"/> and invokes this
|
|
/// delegate. The host sets it at boot to a non-blocking <c>mediator.Tell</c> onto the
|
|
/// <c>alarm-commands</c> DistributedPubSub topic; T19's engine-side subscriber consumes it.
|
|
/// <para>
|
|
/// This is the ONLY reverse coupling out of the node manager — by design it is a plain
|
|
/// <see cref="Action{AlarmCommand}"/> (no Akka / <c>IActorRef</c> / DI handle). The handler
|
|
/// delegates run under the manager's <c>Lock</c>; the invoked action MUST be non-blocking
|
|
/// (a fire-and-forget <c>Tell</c>) so there is no deadlock. Null (the default) makes every
|
|
/// handler a safe no-op — it still gates + returns, just routes nowhere.
|
|
/// </para>
|
|
/// </summary>
|
|
public Action<AlarmCommand>? AlarmCommandRouter { get; set; }
|
|
|
|
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
|
|
|
|
/// <summary>
|
|
/// Reverse-path gateway for inbound OPC UA operator writes to a writable equipment-tag variable node.
|
|
/// When a client writes such a node, the node's <see cref="BaseDataVariableState.OnWriteValue"/>
|
|
/// handler (<see cref="OnEquipmentTagWrite"/>, attached by <see cref="EnsureVariable"/> when the
|
|
/// variable is writable) first gates on the caller's <see cref="OpcUaDataPlaneRoles.WriteOperate"/>
|
|
/// role and, when allowed, calls <see cref="IOpcUaNodeWriteGateway.WriteAsync"/> with the node's
|
|
/// string id + the written value to route the write to the backing driver.
|
|
/// <para>
|
|
/// This is the write-side twin of <see cref="AlarmCommandRouter"/>; the gateway abstraction keeps
|
|
/// this assembly Akka-free (the host wires an <c>ActorNodeWriteGateway</c> that Asks the local
|
|
/// <c>DriverHostActor</c>). The handler delegates run under the node-manager <c>Lock</c> (the OPC
|
|
/// UA SDK's <c>CustomNodeManager2.Write</c> holds <c>Lock</c> while invoking <c>OnWriteValue</c>),
|
|
/// so the dispatch is FIRE-AND-FORGET — the handler kicks off <c>WriteAsync</c> and returns
|
|
/// <c>Good</c> immediately so the SDK applies the client value optimistically; it MUST NOT block
|
|
/// on the device round-trip. When the asynchronous <see cref="NodeWriteOutcome"/> comes back
|
|
/// FAILED, an off-Lock continuation self-corrects: it re-takes <c>Lock</c> and reverts the node to
|
|
/// its real pre-write value — but only while the node still holds the optimistic value, so a fresh
|
|
/// driver poll that has already moved the node on is not clobbered (see
|
|
/// <see cref="ShouldRevert"/> / <see cref="RevertOptimisticWriteIfNeeded"/>).
|
|
/// </para>
|
|
/// <para>
|
|
/// Set by the host at <c>StartAsync</c>; the <see cref="NullOpcUaNodeWriteGateway"/> default
|
|
/// (assigning <c>null</c> restores it) makes every write resolve to a "writes unavailable"
|
|
/// failure. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make the
|
|
/// startup-write / SDK-thread-read explicit: the host assigns it once at boot on the start thread
|
|
/// and the SDK reads it on Write request threads.
|
|
/// </para>
|
|
/// </summary>
|
|
public IOpcUaNodeWriteGateway NodeWriteGateway
|
|
{
|
|
get => _nodeWriteGateway;
|
|
set => _nodeWriteGateway = value ?? NullOpcUaNodeWriteGateway.Instance;
|
|
}
|
|
|
|
private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;
|
|
|
|
/// <summary>
|
|
/// Server-side read backend for the OPC UA HistoryRead service over historized variable nodes.
|
|
/// When a client issues a HistoryRead (Raw / Processed / AtTime) against a node materialised
|
|
/// <c>Historizing</c> (a tag with <see cref="TryGetHistorizedTagname"/> registered), the
|
|
/// HistoryRead override resolves the node's NodeId to its historian tagname and dispatches to
|
|
/// this source — so a single registered historian (e.g. Wonderware) serves many drivers' nodes,
|
|
/// independent of any driver's lifecycle.
|
|
/// <para>
|
|
/// Set by the Host at <c>StartAsync</c> (Task 5). The <see cref="NullHistorianDataSource"/>
|
|
/// default (assigning <c>null</c> restores it) means "no historian wired" → every read
|
|
/// returns empty, so a historized node's HistoryRead surfaces <c>GoodNoData</c> rather than
|
|
/// faulting. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make
|
|
/// the startup-write / SDK-read-thread handoff explicit: the Host assigns it once at boot on
|
|
/// the start thread and the SDK reads it on HistoryRead request threads. Unlike
|
|
/// <see cref="NodeWriteGateway"/>, the HistoryRead override does NOT run under the
|
|
/// node-manager <c>Lock</c>, so the override may block-bridge to this (async) source.
|
|
/// </para>
|
|
/// </summary>
|
|
public IHistorianDataSource HistorianDataSource
|
|
{
|
|
get => _historianDataSource;
|
|
set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
|
|
}
|
|
|
|
/// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
|
|
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
|
|
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
|
|
/// <returns>The cached <see cref="AlarmConditionState"/>, or null when none is registered.</returns>
|
|
public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId) =>
|
|
_alarmConditions.TryGetValue(alarmNodeId, out var condition) ? condition : null;
|
|
|
|
/// <summary>Phase C: look up the resolved historian tagname registered for a historized variable
|
|
/// node, or null when the node is not historized. The (later) HistoryRead override resolves an
|
|
/// inbound HistoryRead request's NodeId against this map. Exposed for tests + the override.</summary>
|
|
/// <param name="nodeId">The variable node identifier.</param>
|
|
/// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
|
|
/// <returns>True when the node is registered as historized; otherwise false.</returns>
|
|
public bool TryGetHistorizedTagname(string nodeId, out string? tagname)
|
|
{
|
|
if (_historizedTagnames.TryGetValue(nodeId, out var t)) { tagname = t; return true; }
|
|
tagname = null;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Look up a materialised variable node by its NodeId string, or null if not present.
|
|
/// Exposed for tests so they can assert the SDK node's Historizing / AccessLevel attributes.</summary>
|
|
/// <param name="nodeId">The variable node identifier.</param>
|
|
/// <returns>The cached <see cref="BaseDataVariableState"/>, or null when none is registered.</returns>
|
|
internal BaseDataVariableState? TryGetVariable(string nodeId) =>
|
|
_variables.TryGetValue(nodeId, out var variable) ? variable : null;
|
|
|
|
/// <summary>Look up a materialised folder node by its NodeId string, or null if not present.
|
|
/// Exposed for tests so they can resolve an equipment folder's NodeId (e.g. the event-notifier
|
|
/// node a HistoryReadEvents request targets).</summary>
|
|
/// <param name="nodeId">The folder node identifier.</param>
|
|
/// <returns>The cached <see cref="FolderState"/>, or null when none is registered.</returns>
|
|
internal FolderState? TryGetFolder(string nodeId) =>
|
|
_folders.TryGetValue(nodeId, out var folder) ? folder : null;
|
|
|
|
/// <summary>
|
|
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
|
/// variable node on first call; subsequent calls update Value + StatusCode +
|
|
/// SourceTimestamp and call <c>ClearChangeMasks</c> so subscribed clients see the change.
|
|
/// </summary>
|
|
/// <param name="nodeId">The node identifier of the variable.</param>
|
|
/// <param name="value">The new value to write.</param>
|
|
/// <param name="quality">The OPC UA quality status code.</param>
|
|
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
|
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(nodeId);
|
|
|
|
lock (Lock)
|
|
{
|
|
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
|
|
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
|
|
if (!_variables.TryGetValue(nodeId, out var variable))
|
|
{
|
|
variable = CreateVariable(nodeId);
|
|
_variables[nodeId] = variable;
|
|
}
|
|
|
|
variable.Value = value;
|
|
variable.StatusCode = StatusFromQuality(quality);
|
|
variable.Timestamp = sourceTimestampUtc;
|
|
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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="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 WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
|
ArgumentNullException.ThrowIfNull(state);
|
|
|
|
// Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
|
|
// _alarmConditions / detach the condition node between the lookup and the Set* calls.
|
|
lock (Lock)
|
|
{
|
|
if (_alarmConditions.TryGetValue(alarmNodeId, out var condition))
|
|
{
|
|
// T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting
|
|
// the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming
|
|
// snapshot to that current state. We gate against the NODE's state, NOT a "last written"
|
|
// cache, because an inbound client ack the SDK applied (OnAcknowledge returned Good →
|
|
// SDK mutated AckedState + auto-fired its own event) NEVER passed through this method, so a
|
|
// last-written cache would be stale and wrongly report a delta. By the time the engine
|
|
// re-projects that ack here, the node already holds the acked state → no delta → suppress.
|
|
var current = ReadConditionDelta(condition);
|
|
var incoming = ToConditionDelta(state, condition);
|
|
bool fire = ShouldFireConditionEvent(current, incoming);
|
|
|
|
// 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. The event firing below also depends on this Retain being
|
|
// correct (a non-retained inactive+acked condition still fires its transition event, but
|
|
// won't be replayed on a later ConditionRefresh).
|
|
condition.Retain.Value = state.Active || !state.Acknowledged;
|
|
condition.Time.Value = sourceTimestampUtc;
|
|
condition.ReceiveTime.Value = sourceTimestampUtc;
|
|
|
|
// T20 — fire a real Part 9 condition event ONLY when this projection is a genuine state
|
|
// change (the delta-gate decided above, against the node's pre-projection state). A
|
|
// genuine engine-driven transition (alarm goes active/clear, severity bucket shifts, an
|
|
// engine-side ack, etc.) differs from the node's current state → fire. The re-projection
|
|
// of a client ack the SDK already applied equals the node's current state → no delta →
|
|
// suppress, so we don't double-emit (E2 from the SDK + E3 from here). ReportConditionEvent
|
|
// stamps a fresh EventId, ClearChangeMasks, and ReportEvent — all still under this lock.
|
|
if (fire)
|
|
{
|
|
ReportConditionEvent(condition, sourceTimestampUtc);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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. CreateVariable mutates
|
|
// the SDK address space, so it MUST run under Lock (see WriteValue).
|
|
if (!_variables.TryGetValue(alarmNodeId, out var variable))
|
|
{
|
|
variable = CreateVariable(alarmNodeId);
|
|
_variables[alarmNodeId] = variable;
|
|
}
|
|
|
|
variable.Value = new[] { state.Active, state.Acknowledged };
|
|
variable.StatusCode = StatusCodes.Good;
|
|
variable.Timestamp = sourceTimestampUtc;
|
|
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
|
|
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
|
|
/// have applied the new state via the <c>Set*</c> projection — this stamps a fresh per-event
|
|
/// <c>EventId</c>, <c>ClearChangeMasks</c>, then <c>ReportEvent</c> with an
|
|
/// <see cref="InstanceStateSnapshot"/> (a frozen copy of the condition's children at fire time,
|
|
/// so a subscribing client sees the values at this instant even if the live node mutates after).
|
|
/// <para>
|
|
/// A fresh <c>EventId</c> per event is a Part 9 requirement: inbound Acknowledge / Confirm /
|
|
/// AddComment calls are correlated back to a specific event by this id (the SDK matches it via
|
|
/// <c>GetEventByEventId</c> / <c>GetBranch</c>), so T17's ack routing relies on it being unique
|
|
/// per emission. We use the main branch only (<c>BranchId == NodeId.Null</c>, set at
|
|
/// materialise) — no branch creation here.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Double-emit note (resolved by delta-gate).</b> An inbound client Acknowledge/Confirm
|
|
/// goes through the SDK's own handler, which (after T18's gate returns Good) applies the acked
|
|
/// state to the node and auto-fires its own condition event (E2) — directly on the node,
|
|
/// BYPASSING <see cref="WriteAlarmCondition"/>. The engine then re-projects that same logical
|
|
/// transition through <see cref="WriteAlarmCondition"/>, which would otherwise fire a second
|
|
/// event (E3). <see cref="WriteAlarmCondition"/>'s delta-gate suppresses E3: it compares the
|
|
/// incoming snapshot against the NODE's CURRENT state, and because the SDK has ALREADY
|
|
/// pre-applied the inbound-ack state, the re-projection is a no-delta no-op (no fire). Genuine
|
|
/// engine-driven transitions still differ from the node's current state, so they fire here as
|
|
/// before.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
|
|
/// <param name="ts">The source/receive timestamp (UTC) for this event.</param>
|
|
private void ReportConditionEvent(AlarmConditionState alarm, DateTime ts)
|
|
{
|
|
// Fresh GUID-bytes EventId per event — mandatory for Part 9 ack correlation (T17 relies on it).
|
|
alarm.EventId.Value = Guid.NewGuid().ToByteArray();
|
|
// Time/ReceiveTime were already set to sourceTimestampUtc by the WriteAlarmCondition projection
|
|
// immediately above; the assignment here is a locality repeat (same value, no behavioral change)
|
|
// so the restamp is co-located with the EventId and ClearChangeMasks in the same method.
|
|
alarm.Time.Value = ts;
|
|
alarm.ReceiveTime.Value = ts;
|
|
|
|
// Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so
|
|
// the InstanceStateSnapshot captures the just-projected values.
|
|
alarm.ClearChangeMasks(SystemContext, includeChildren: true);
|
|
|
|
try
|
|
{
|
|
// InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire
|
|
// time. ReportEvent walks inverse notifier references up to the root-notifier folder (promoted
|
|
// in MaterialiseAlarmCondition), whose OnReportEvent hands off to Server.ReportEvent → the
|
|
// event reaches subscribed monitored items.
|
|
var snapshot = new InstanceStateSnapshot();
|
|
snapshot.Initialize(SystemContext, alarm);
|
|
alarm.ReportEvent(SystemContext, snapshot);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// A failed event report must NOT break the state projection or the calling actor: the node's
|
|
// state has already been applied + ClearChangeMasks'd, so attribute subscribers still see the
|
|
// change; only the event delivery is lost. This CustomNodeManager2 carries no ILogger, so log
|
|
// through the SDK's static trace (Utils.LogError) instead of swallowing silently — a recurring
|
|
// failure here is then visible in the server log rather than invisible. T19's live Client.CLI
|
|
// run is the integration proof that the happy path delivers.
|
|
// Utils.LogError routes to the SDK's trace sink. It's [Obsolete] in 1.5.378 in favour of an
|
|
// ITelemetryContext/ILogger this CustomNodeManager2 doesn't have wired — suppress the
|
|
// deprecation here (wiring the telemetry logger through is a separate follow-up); the point is
|
|
// that a recurring failure is visible in the server trace rather than silently swallowed.
|
|
#pragma warning disable CS0618 // Type or member is obsolete
|
|
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {0}", alarm.NodeId);
|
|
#pragma warning restore CS0618
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
|
|
/// condition event AND that an <see cref="AlarmConditionSnapshot"/> can change. As a record, two
|
|
/// instances compare by value, so <see cref="ShouldFireConditionEvent"/> is a plain inequality.
|
|
/// <para>
|
|
/// <b>Severity</b> is stored as the MAPPED <see cref="EventSeverity"/> bucket (a
|
|
/// <see cref="ushort"/>) — the same value the node holds after <c>SetSeverity</c> — so two
|
|
/// raw severities that fall in the same bucket are correctly treated as "no change". The
|
|
/// <b>Shelving</b> kind is read back from / mapped to the SDK's shelving state machine so the
|
|
/// live node and the snapshot compare on the same 3-way (Unshelved/OneShot/Timed) axis.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Why <c>CommentAdded</c> is not a field here (intentional).</b>
|
|
/// <c>EmissionKind.CommentAdded</c> is produced only by
|
|
/// <c>Part9StateMachine.ApplyAddComment</c>, which is reached only via
|
|
/// <c>ScriptedAlarmEngine.AddCommentAsync</c>, which is called only from
|
|
/// <c>ScriptedAlarmHostActor</c>'s inbound <c>AlarmCommand</c> handler — meaning
|
|
/// <c>CommentAdded</c> ALWAYS originates from a client calling the condition's
|
|
/// <c>AddComment</c> method. On that path T18's <c>OnAddComment</c> delegate returns
|
|
/// <c>ServiceResult.Good</c>, so the OPC UA SDK itself applies the comment to the node
|
|
/// and auto-fires the Part 9 comment event (E2) directly to subscribers — BEFORE the
|
|
/// engine re-projects via <see cref="WriteAlarmCondition"/>. When that re-projection
|
|
/// arrives here, the delta-gate sees no change in any compared field (the snapshot carries
|
|
/// no comments list) and correctly suppresses a second event (E3). Force-firing for
|
|
/// <c>CommentAdded</c> would double-emit. There is no engine-internal or script-driven
|
|
/// comment path, so suppression never drops a needed event.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Why <c>Retain</c> is absent (intentional — safe today).</b>
|
|
/// <c>Retain</c> is projected as <c>state.Active || !state.Acknowledged</c> in
|
|
/// <see cref="WriteAlarmCondition"/>. Every path that flips <c>Retain</c> necessarily
|
|
/// changes <c>Active</c> or <c>Acknowledged</c> (both ARE compared fields), so a
|
|
/// <c>Retain</c> flip always rides along with a real delta and fires correctly. If a
|
|
/// future engine were to set <c>Retain</c> independently — without touching
|
|
/// <c>Active</c>/<c>Acknowledged</c> — it would need to be added here.
|
|
/// </para>
|
|
/// </summary>
|
|
internal readonly record struct AlarmConditionDelta(
|
|
bool Active,
|
|
bool Acknowledged,
|
|
bool Confirmed,
|
|
bool Enabled,
|
|
AlarmShelvingKind Shelving,
|
|
ushort MappedSeverity,
|
|
string Message);
|
|
|
|
/// <summary>Decide whether a <see cref="WriteAlarmCondition"/> projection is a genuine state change
|
|
/// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
|
|
/// incoming snapshot. Pure + value-based so it's unit-testable in isolation: returns <c>true</c> iff
|
|
/// any gate-relevant field differs. An inbound client ack the SDK already applied makes
|
|
/// <paramref name="current"/> == <paramref name="incoming"/> ⇒ <c>false</c> (suppress the re-projected
|
|
/// double-emit); a genuine engine-driven transition differs ⇒ <c>true</c> (fire).</summary>
|
|
/// <param name="current">The node's current (pre-projection) gate-relevant state.</param>
|
|
/// <param name="incoming">The incoming snapshot's gate-relevant state.</param>
|
|
/// <returns><c>true</c> to fire a condition event; <c>false</c> to suppress (no delta).</returns>
|
|
internal static bool ShouldFireConditionEvent(AlarmConditionDelta current, AlarmConditionDelta incoming) =>
|
|
current != incoming;
|
|
|
|
/// <summary>Read the gate-relevant slice off the LIVE condition node. Mandatory children
|
|
/// (Active/Acked/Enabled) are always present; Confirmed/Shelving are optional and null-guarded
|
|
/// (a leaner child set ⇒ treat as the unset default). Severity is read as the already-mapped
|
|
/// <see cref="EventSeverity"/> bucket the node stores, and shelving is mapped from the shelving
|
|
/// state machine's CurrentState so it lines up with <see cref="ToConditionDelta"/>.</summary>
|
|
private static AlarmConditionDelta ReadConditionDelta(AlarmConditionState condition) => new(
|
|
Active: condition.ActiveState?.Id?.Value ?? false,
|
|
Acknowledged: condition.AckedState?.Id?.Value ?? true,
|
|
Confirmed: condition.ConfirmedState?.Id?.Value ?? true,
|
|
Enabled: condition.EnabledState?.Id?.Value ?? true,
|
|
Shelving: ReadShelvingKind(condition),
|
|
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
|
|
Message: condition.Message?.Value?.Text ?? string.Empty);
|
|
|
|
/// <summary>Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
|
|
/// the node stores in a derived form: Severity is run through <see cref="MapSeverity"/> so it matches
|
|
/// the bucket the node holds (the projection calls <c>SetSeverity(MapSeverity(...))</c>), and an
|
|
/// optional Confirmed/Shelving that the node can't actually hold (missing child) is folded to the
|
|
/// node's read-back default so it never spuriously registers as a delta.</summary>
|
|
private static AlarmConditionDelta ToConditionDelta(AlarmConditionSnapshot state, AlarmConditionState condition) => new(
|
|
Active: state.Active,
|
|
Acknowledged: state.Acknowledged,
|
|
// If the node has no ConfirmedState child, the projection is a no-op there; mirror the node's
|
|
// read-back default (true) so a snapshot Confirmed value can't create a phantom delta.
|
|
Confirmed: condition.ConfirmedState is not null ? state.Confirmed : true,
|
|
Enabled: state.Enabled,
|
|
// Likewise for shelving: without a ShelvingState child the projection can't apply, so fold to the
|
|
// node's read-back default (Unshelved).
|
|
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
|
|
MappedSeverity: (ushort)MapSeverity(state.Severity),
|
|
Message: state.Message ?? string.Empty);
|
|
|
|
/// <summary>Map the live shelving state machine's CurrentState back to our 3-way
|
|
/// <see cref="AlarmShelvingKind"/> by matching its well-known Part 9 state object id. Any node without
|
|
/// a shelving sub-state machine (or an unrecognised/unset state) reads as
|
|
/// <see cref="AlarmShelvingKind.Unshelved"/> — the same value <see cref="ToConditionDelta"/> folds an
|
|
/// unsupported shelving snapshot to, so the two stay comparable.</summary>
|
|
private static AlarmShelvingKind ReadShelvingKind(AlarmConditionState condition)
|
|
{
|
|
var stateId = condition.ShelvingState?.CurrentState?.Id?.Value as NodeId;
|
|
if (stateId == ObjectIds.ShelvedStateMachineType_OneShotShelved) return AlarmShelvingKind.OneShot;
|
|
if (stateId == ObjectIds.ShelvedStateMachineType_TimedShelved) return AlarmShelvingKind.Timed;
|
|
return AlarmShelvingKind.Unshelved;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 subscribe to its events). The node
|
|
/// id is the alarm node id (the ScriptedAlarmId) so subsequent
|
|
/// <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
|
|
/// <b>no</b> events itself.
|
|
/// </para>
|
|
/// Idempotent: a second call with the same <paramref name="alarmNodeId"/> tears down the prior
|
|
/// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
|
|
/// </summary>
|
|
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.</param>
|
|
/// <param name="equipmentNodeId">The equipment folder node id the condition parents under (null/unknown ⇒ root).</param>
|
|
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
|
|
/// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
|
|
/// <param name="severity">Domain severity (treated as an OPC UA 1..1000 severity); mapped to <see cref="EventSeverity"/>.</param>
|
|
/// <remarks>
|
|
/// <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
|
|
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
|
|
/// map: <c>OffNormalAlarm</c> → <see cref="OffNormalAlarmState"/>, <c>DiscreteAlarm</c> →
|
|
/// <see cref="DiscreteAlarmState"/>, and everything else (including <c>AlarmCondition</c> and
|
|
/// <c>LimitAlarm</c>, which has no script-supplied limits) → the base
|
|
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
|
|
/// notes — a script alarm carries no High/Low limits to populate.</para>
|
|
/// </remarks>
|
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
|
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
|
|
|
lock (Lock)
|
|
{
|
|
// Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
|
|
// type/severity on redeploy) reflects cleanly instead of leaking the old node.
|
|
if (_alarmConditions.TryRemove(alarmNodeId, out var existing))
|
|
{
|
|
existing.Parent?.RemoveChild(existing);
|
|
PredefinedNodes?.Remove(existing.NodeId);
|
|
}
|
|
|
|
var parent = ResolveParentFolder(equipmentNodeId);
|
|
|
|
AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
|
|
alarm.SymbolicName = displayName;
|
|
// HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
|
|
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
|
|
|
|
// Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
|
|
// ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
|
|
// type's embedded definition; we do not hand-build them.
|
|
alarm.Create(
|
|
SystemContext,
|
|
new NodeId(alarmNodeId, NamespaceIndex),
|
|
new QualifiedName(displayName, NamespaceIndex),
|
|
new LocalizedText(displayName),
|
|
assignNodeIds: true);
|
|
|
|
// Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
|
|
// UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
|
|
// Create leaves as a null reference and would NRE. NodeId.Null marks "the main branch".
|
|
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
|
|
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
|
|
|
|
// Initial state via the SDK setters (T14: basic state only, NO event firing).
|
|
alarm.SetEnableState(SystemContext, true);
|
|
alarm.SetActiveState(SystemContext, false);
|
|
alarm.SetAcknowledgedState(SystemContext, true);
|
|
alarm.SetSeverity(SystemContext, MapSeverity(severity));
|
|
alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
|
|
alarm.Message.Value = new LocalizedText(displayName);
|
|
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
|
|
|
|
// T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/
|
|
// AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall
|
|
// routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
|
|
// state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
|
|
// routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
|
|
// SDK applies its node state + auto-fires its own event (E2).
|
|
// T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
|
|
// delta-gate (compares against the node's current state, which the SDK already pre-applied)
|
|
// sees no change and suppresses the would-be second event (E3) — so no double-emit.
|
|
alarm.OnAcknowledge = (context, condition, _, comment) =>
|
|
HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
|
|
alarm.OnConfirm = (context, condition, _, comment) =>
|
|
HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
|
|
alarm.OnAddComment = (context, condition, _, comment) =>
|
|
HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
|
|
alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
|
|
{
|
|
// SDK invocation shapes (verified against the decompiled AlarmConditionState):
|
|
// OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
|
|
// TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
|
|
// Unshelve → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry
|
|
// shelvingTime is an OPC UA Duration (milliseconds).
|
|
var (operation, unshelveAt) =
|
|
!shelving ? ("Unshelve", (DateTime?)null)
|
|
: oneShot ? ("OneShotShelve", null)
|
|
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
|
|
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
|
|
};
|
|
// The auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK
|
|
// fires it with the node manager's system context — there is NO session and NO user identity.
|
|
// Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied,
|
|
// leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the
|
|
// same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve
|
|
// state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated.
|
|
alarm.OnTimedUnshelve = (context, condition) =>
|
|
{
|
|
var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty;
|
|
AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", string.Empty, null, null));
|
|
return ServiceResult.Good;
|
|
};
|
|
|
|
parent.AddChild(alarm);
|
|
|
|
// Promote the equipment folder to an event notifier + register it as a root notifier so
|
|
// T16's ReportEvent has a notifier path up to the Server object. Guard so repeated
|
|
// materialise under the same folder doesn't double-add the root notifier.
|
|
EnsureFolderIsEventNotifier(parent);
|
|
|
|
AddPredefinedNode(SystemContext, alarm);
|
|
_alarmConditions[alarmNodeId] = alarm;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
|
|
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
|
|
/// (<b>fails closed</b>: a missing identity or a missing role is denied), and on success builds a
|
|
/// mapped <see cref="AlarmCommand"/> and routes it through <see cref="AlarmCommandRouter"/>.
|
|
/// </summary>
|
|
/// <param name="context">The SDK context the handler delegate was invoked with — a
|
|
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session
|
|
/// identity. T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>.</param>
|
|
/// <param name="condition">The condition the method targets; its <c>NodeId</c> identifier is the
|
|
/// ScriptedAlarmId (T14 aligned them), which becomes <see cref="AlarmCommand.AlarmId"/>.</param>
|
|
/// <param name="operation">The Part 9 operation name (e.g. <c>Acknowledge</c>, <c>TimedShelve</c>).</param>
|
|
/// <param name="comment">The call's comment text, or <c>null</c> when none was supplied.</param>
|
|
/// <param name="unshelveAt">For <c>TimedShelve</c>, the computed UTC expiry; otherwise <c>null</c>.</param>
|
|
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its
|
|
/// event); <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
|
private ServiceResult HandleAlarmCommand(
|
|
ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
|
|
{
|
|
// Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
|
|
// role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
|
|
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
|
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
|
|
// state change and surfaces the status to the client; we never route or mutate.
|
|
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
|
}
|
|
|
|
var cmd = new AlarmCommand(
|
|
AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
|
|
Operation: operation,
|
|
User: identity.DisplayName ?? string.Empty,
|
|
Comment: comment?.Text,
|
|
UnshelveAtUtc: unshelveAt);
|
|
|
|
// Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock.
|
|
AlarmCommandRouter?.Invoke(cmd);
|
|
|
|
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
|
|
/// <see cref="EnsureVariable"/> (Task 11). The OPC UA SDK invokes it when a client writes the
|
|
/// node's Value. It resolves the calling principal off the SDK <paramref name="context"/> the
|
|
/// SAME way <see cref="HandleAlarmCommand"/> does, gates on the
|
|
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role + the gateway being wired
|
|
/// (<b>fails closed</b>: a missing identity / missing role ⇒ <c>BadUserAccessDenied</c>; no gateway ⇒
|
|
/// <c>BadNotWritable</c>) via the pure <see cref="EvaluateEquipmentWriteGate"/>, and on pass dispatches
|
|
/// the value through <see cref="NodeWriteGateway"/>.
|
|
/// <para>
|
|
/// The dispatch is FIRE-AND-FORGET: the SDK's <c>CustomNodeManager2.Write</c> holds the node
|
|
/// manager <c>Lock</c> while invoking this handler, so a blocking driver round-trip here would
|
|
/// freeze every address-space operation (reads, subscription notifications, the publish path) for
|
|
/// the duration. The gateway only kicks off the asynchronous route. Returning
|
|
/// <see cref="ServiceResult.Good"/> lets the SDK apply the written value optimistically.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Write-outcome self-correction.</b> Before returning Good (which makes the SDK overwrite the
|
|
/// node with <paramref name="value"/>) we capture both the optimistic value AND the node's REAL
|
|
/// prior value/status — at handler entry the node still holds the prior value. An off-Lock
|
|
/// continuation on the <see cref="NodeWriteOutcome"/> then reverts the node to that prior
|
|
/// value/status on a FAILED outcome, but ONLY while the node still holds the optimistic value, so a
|
|
/// fresh driver poll that already republished the confirmed register value is not clobbered
|
|
/// (<see cref="RevertOptimisticWriteIfNeeded"/> / <see cref="ShouldRevert"/>). On success the
|
|
/// optimistic value stands and the next poll re-confirms it via the normal <see cref="WriteValue"/>
|
|
/// path.
|
|
/// </para>
|
|
/// </summary>
|
|
private ServiceResult OnEquipmentTagWrite(
|
|
ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
|
|
ref object value, ref StatusCode statusCode, ref DateTime timestamp)
|
|
{
|
|
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
|
|
var gateway = _nodeWriteGateway;
|
|
var gate = EvaluateEquipmentWriteGate(identity, gateway is not NullOpcUaNodeWriteGateway);
|
|
if (gate is not null) return gate;
|
|
|
|
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
|
|
// (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`).
|
|
var optimisticValue = value;
|
|
var nodeKey = node.NodeId.Identifier?.ToString() ?? string.Empty;
|
|
object? priorValue = null;
|
|
StatusCode priorStatus = StatusCodes.Good;
|
|
if (node is BaseDataVariableState variable)
|
|
{
|
|
priorValue = variable.Value;
|
|
priorStatus = variable.StatusCode;
|
|
}
|
|
|
|
// Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock
|
|
// continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never
|
|
// sticks when the route never resolved a real outcome. RunContinuationsAsynchronously guarantees the
|
|
// revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed
|
|
// task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never
|
|
// re-enters lock (Lock) while CustomNodeManager2.Write still holds it.
|
|
_ = gateway.WriteAsync(nodeKey, optimisticValue, CancellationToken.None)
|
|
.ContinueWith(
|
|
t =>
|
|
{
|
|
var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted");
|
|
RevertOptimisticWriteIfNeeded(nodeKey, outcome, optimisticValue, priorValue, priorStatus);
|
|
},
|
|
CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
|
|
|
|
return ServiceResult.Good;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure role + availability gate for an inbound equipment-tag write, extracted off
|
|
/// <see cref="OnEquipmentTagWrite"/> so it is unit-testable without booting an SDK server. Fails closed:
|
|
/// a null identity or an identity missing the <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role ⇒
|
|
/// <c>BadUserAccessDenied</c>. When the gate passes but no real gateway is wired
|
|
/// (<paramref name="gatewayWired"/> is false) ⇒ <c>BadNotWritable</c> ("writes unavailable"). A
|
|
/// <c>null</c> return means "proceed" (the caller dispatches + returns Good). Role comparison is
|
|
/// case-insensitive (the role set is built with <see cref="StringComparer.OrdinalIgnoreCase"/>),
|
|
/// matching the alarm gate.
|
|
/// </summary>
|
|
/// <param name="identity">The role-carrying identity extracted off the SDK context, or null when the
|
|
/// session is anonymous / carries no role-carrying identity.</param>
|
|
/// <param name="gatewayWired">True when a non-Null <see cref="IOpcUaNodeWriteGateway"/> is wired; false
|
|
/// for the Null default (no route — e.g. admin-only nodes / pre-boot).</param>
|
|
/// <returns><c>null</c> to proceed (gate passed); otherwise the veto <see cref="ServiceResult"/>
|
|
/// (<c>BadUserAccessDenied</c> on a failed role gate, <c>BadNotWritable</c> when no gateway is wired).</returns>
|
|
internal static ServiceResult? EvaluateEquipmentWriteGate(RoleCarryingUserIdentity? identity, bool gatewayWired)
|
|
{
|
|
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.WriteOperate, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
|
|
// write and surfaces the status to the client; we never route.
|
|
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
|
}
|
|
|
|
if (!gatewayWired)
|
|
{
|
|
// Gate passed but no gateway wired (admin-only nodes / pre-boot) ⇒ writes unavailable.
|
|
return new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure decision for the write-outcome self-correction: revert the node to its pre-write value ONLY on
|
|
/// a FAILED outcome AND only while the node still holds the optimistic value. The
|
|
/// still-holds-the-optimistic-value check is what stops a revert from clobbering a fresh driver poll
|
|
/// that already republished the confirmed register value over the optimistic write. Pure (value
|
|
/// comparison via <see cref="object.Equals(object?, object?)"/>) so it is unit-testable without an SDK
|
|
/// server.
|
|
/// </summary>
|
|
/// <param name="outcome">The device-write outcome routed back by the gateway.</param>
|
|
/// <param name="currentNodeValue">The node's current Value at revert time.</param>
|
|
/// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
|
|
/// <returns><c>true</c> to revert (failed outcome and node unchanged since the optimistic write);
|
|
/// <c>false</c> on success, or when a poll has already moved the node off the optimistic value.</returns>
|
|
internal static bool ShouldRevert(NodeWriteOutcome outcome, object? currentNodeValue, object? optimisticValue) =>
|
|
!outcome.Success && Equals(currentNodeValue, optimisticValue);
|
|
|
|
/// <summary>
|
|
/// Off-Lock continuation body for the write-outcome self-correction: re-takes <c>Lock</c> and, when
|
|
/// <see cref="ShouldRevert"/> says so, reverts the node's Value + StatusCode to the captured pre-write
|
|
/// value/status and notifies subscribers (same node-update shape as <see cref="WriteValue"/>). A
|
|
/// no-op when the node was rebuilt/removed in the interim, when the outcome succeeded, or when a fresh
|
|
/// poll already moved the node off the optimistic value. Silent — this node manager carries no logger;
|
|
/// the gateway logs the underlying write failure.
|
|
/// </summary>
|
|
/// <param name="nodeId">The string id of the written variable node.</param>
|
|
/// <param name="outcome">The device-write outcome routed back by the gateway.</param>
|
|
/// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
|
|
/// <param name="priorValue">The node's real value captured before the optimistic write.</param>
|
|
/// <param name="priorStatus">The node's real status captured before the optimistic write.</param>
|
|
private void RevertOptimisticWriteIfNeeded(
|
|
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus)
|
|
{
|
|
lock (Lock)
|
|
{
|
|
if (!_variables.TryGetValue(nodeId, out var variable)) return; // rebuilt/removed ⇒ no-op
|
|
if (!ShouldRevert(outcome, variable.Value, optimisticValue)) return; // success, or poll moved it on
|
|
variable.Value = priorValue;
|
|
variable.StatusCode = priorStatus;
|
|
variable.Timestamp = DateTime.UtcNow;
|
|
variable.ClearChangeMasks(SystemContext, includeChildren: false);
|
|
}
|
|
}
|
|
|
|
/// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
|
|
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
|
|
/// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
|
|
private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) => alarmType switch
|
|
{
|
|
"OffNormalAlarm" => new OffNormalAlarmState(parent),
|
|
"DiscreteAlarm" => new DiscreteAlarmState(parent),
|
|
// "LimitAlarm" / "AlarmCondition" / unknown ⇒ base: a script-driven alarm has no OPC limits
|
|
// to populate, so the limit subtypes would carry unset High/Low children.
|
|
_ => new AlarmConditionState(parent),
|
|
};
|
|
|
|
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
|
|
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
|
|
/// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
|
|
/// <remarks>
|
|
/// Phase C (Task 4): when a real historian is wired at promotion time (the source is NOT the
|
|
/// <see cref="NullHistorianDataSource"/>), the folder ALSO gets the
|
|
/// <see cref="EventNotifiers.HistoryRead"/> bit OR-ed in (keeping SubscribeToEvents) and registers
|
|
/// its NodeId identifier as an event-history source so the <see cref="HistoryReadEvents"/> override
|
|
/// accepts it. The HistoryRead-events bit is therefore only advertised when a historian is wired at
|
|
/// the moment of promotion: the Host wires the source at <c>StartAsync</c> — BEFORE any deployment
|
|
/// materialises alarms — so the normal boot ordering promotes folders with the bit set. A folder
|
|
/// promoted while the source is still Null advertises live-event subscription but NOT event history
|
|
/// until the next <see cref="RebuildAddressSpace"/> re-promotes it (acceptable, documented). The
|
|
/// HistoryRead bit + source registration happen inside the same first-time
|
|
/// <see cref="_notifierFolders"/> block so the idempotency guard covers them too.
|
|
/// </remarks>
|
|
private void EnsureFolderIsEventNotifier(FolderState folder)
|
|
{
|
|
if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
|
|
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
|
if (_historianDataSource is not NullHistorianDataSource)
|
|
{
|
|
// A historian is wired: advertise event history on this notifier and register it as a source.
|
|
// The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName.
|
|
folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead);
|
|
var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty;
|
|
_eventNotifierSources[sourceName] = sourceName;
|
|
}
|
|
AddRootNotifier(folder);
|
|
folder.ClearChangeMasks(SystemContext, includeChildren: false);
|
|
}
|
|
|
|
/// <summary>Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the
|
|
/// <see cref="EventSeverity"/> enum buckets the SDK's <c>SetSeverity</c> expects.</summary>
|
|
private static EventSeverity MapSeverity(int severity) => severity switch
|
|
{
|
|
< 200 => EventSeverity.Low,
|
|
< 400 => EventSeverity.MediumLow,
|
|
< 600 => EventSeverity.Medium,
|
|
< 800 => EventSeverity.MediumHigh,
|
|
_ => EventSeverity.High,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
|
|
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
|
|
/// #85 — used by <see cref="Phase7Applier"/> to materialise the UNS Area/Line/Equipment
|
|
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
|
|
/// folder so adding child variables under it still works.
|
|
/// </summary>
|
|
/// <param name="folderNodeId">The node identifier of the folder.</param>
|
|
/// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
|
|
/// <param name="displayName">The display name of the folder.</param>
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
|
|
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
|
|
|
if (_folders.ContainsKey(folderNodeId)) return;
|
|
|
|
lock (Lock)
|
|
{
|
|
if (_folders.ContainsKey(folderNodeId)) return;
|
|
|
|
var parent = ResolveParentFolder(parentNodeId);
|
|
var folder = new FolderState(parent)
|
|
{
|
|
NodeId = new NodeId(folderNodeId, NamespaceIndex),
|
|
BrowseName = new QualifiedName(folderNodeId, NamespaceIndex),
|
|
DisplayName = displayName,
|
|
EventNotifier = EventNotifiers.None,
|
|
TypeDefinitionId = ObjectTypeIds.FolderType,
|
|
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
|
};
|
|
parent.AddChild(folder);
|
|
AddPredefinedNode(SystemContext, folder);
|
|
_folders[folderNodeId] = folder;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
|
|
/// <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
|
|
/// timestamp=epoch — <see cref="WriteValue"/> fills these in once driver data flows.
|
|
/// Idempotent. Materialises equipment-namespace tags so they're browseable before drivers
|
|
/// issue SubscribeBulk. Note: because of the early <c>_variables.ContainsKey</c> return, a
|
|
/// re-apply of an EXISTING node with a changed historize intent (e.g. non-historized →
|
|
/// historized) is silently ignored — a historize-intent change only takes effect after a
|
|
/// <see cref="RebuildAddressSpace"/> (which the planner triggers on an equipment-tag delta).
|
|
/// </summary>
|
|
/// <param name="variableNodeId">The node identifier of the variable.</param>
|
|
/// <param name="parentFolderNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
|
|
/// <param name="displayName">The display name of the variable.</param>
|
|
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
|
|
/// <param name="writable">When true the node is created <c>CurrentReadWrite</c> (an authored
|
|
/// ReadWrite equipment tag) and the inbound-write handler <see cref="OnEquipmentTagWrite"/> is attached
|
|
/// to its <c>OnWriteValue</c> (Task 11) so a client write gates on the <c>WriteOperate</c> role + routes
|
|
/// to the backing driver; when false it stays <c>CurrentRead</c> (read-only) with no write handler.</param>
|
|
/// <param name="historianTagname">Phase C: null ⇒ the node is NOT historized (Historizing=false, no
|
|
/// HistoryRead bit, not registered). Non-null ⇒ the node is created <c>Historizing</c> with the
|
|
/// <c>HistoryRead</c> access bit OR-ed into both <c>AccessLevel</c> and <c>UserAccessLevel</c>, and the
|
|
/// (already default-resolved) tagname is registered in the NodeId→tagname map the HistoryRead override
|
|
/// resolves against.</param>
|
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
|
|
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
|
|
|
// If already present, leave it alone (idempotent re-applies).
|
|
if (_variables.ContainsKey(variableNodeId)) return;
|
|
|
|
lock (Lock)
|
|
{
|
|
if (_variables.ContainsKey(variableNodeId)) return;
|
|
|
|
var parent = ResolveParentFolder(parentFolderNodeId);
|
|
// Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead
|
|
// access bit (on top of the writable composite) so clients can browse + HistoryRead it.
|
|
var historized = historianTagname is not null;
|
|
// The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is
|
|
// CurrentRead | CurrentWrite. OR-ing two byte constants promotes to int, so cast back.
|
|
byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead;
|
|
if (historized) access = (byte)(access | AccessLevels.HistoryRead);
|
|
var variable = new BaseDataVariableState(parent)
|
|
{
|
|
NodeId = new NodeId(variableNodeId, NamespaceIndex),
|
|
BrowseName = new QualifiedName(variableNodeId, NamespaceIndex),
|
|
DisplayName = displayName,
|
|
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
|
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
|
DataType = ResolveBuiltInDataType(dataType),
|
|
ValueRank = ValueRanks.Scalar,
|
|
AccessLevel = access,
|
|
UserAccessLevel = access,
|
|
Historizing = historized,
|
|
Value = null,
|
|
StatusCode = StatusCodes.BadWaitingForInitialData,
|
|
Timestamp = DateTime.MinValue,
|
|
};
|
|
// Task 11: a writable equipment tag owns an inbound-write handler. The SDK invokes
|
|
// OnWriteValue on a client write; it gates on the WriteOperate role and routes to the backing
|
|
// driver via NodeWriteGateway. Read-only nodes leave the handler unattached so a write is
|
|
// rejected by the SDK's own AccessLevel check before it ever reaches a handler.
|
|
if (writable)
|
|
{
|
|
variable.OnWriteValue = OnEquipmentTagWrite;
|
|
}
|
|
parent.AddChild(variable);
|
|
AddPredefinedNode(SystemContext, variable);
|
|
_variables[variableNodeId] = variable;
|
|
// Phase C: register the resolved historian tagname so the HistoryRead override can map this
|
|
// NodeId back to its Aveva/historian source.
|
|
if (historized) _historizedTagnames[variableNodeId] = historianTagname!;
|
|
}
|
|
}
|
|
|
|
/// <summary>Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String",
|
|
/// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType
|
|
/// (matches CreateVariable's default for lazy-created nodes).</summary>
|
|
private static NodeId ResolveBuiltInDataType(string dataType) => dataType switch
|
|
{
|
|
"Boolean" => DataTypeIds.Boolean,
|
|
"SByte" => DataTypeIds.SByte,
|
|
"Byte" => DataTypeIds.Byte,
|
|
"Int16" => DataTypeIds.Int16,
|
|
"UInt16" => DataTypeIds.UInt16,
|
|
"Int32" => DataTypeIds.Int32,
|
|
"UInt32" => DataTypeIds.UInt32,
|
|
"Int64" => DataTypeIds.Int64,
|
|
"UInt64" => DataTypeIds.UInt64,
|
|
"Float" => DataTypeIds.Float,
|
|
"Double" => DataTypeIds.Double,
|
|
"String" => DataTypeIds.String,
|
|
"DateTime" => DataTypeIds.DateTime,
|
|
_ => DataTypeIds.BaseDataType,
|
|
};
|
|
|
|
/// <summary>Clear every registered variable + folder from the address space. Phase7Applier
|
|
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
|
|
/// EnsureFolder + WriteValue on the next pass.</summary>
|
|
public void RebuildAddressSpace()
|
|
{
|
|
lock (Lock)
|
|
{
|
|
foreach (var v in _variables.Values)
|
|
{
|
|
v.Parent?.RemoveChild(v);
|
|
PredefinedNodes?.Remove(v.NodeId);
|
|
}
|
|
_variables.Clear();
|
|
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
|
|
_historizedTagnames.Clear();
|
|
|
|
foreach (var alarm in _alarmConditions.Values)
|
|
{
|
|
alarm.Parent?.RemoveChild(alarm);
|
|
PredefinedNodes?.Remove(alarm.NodeId);
|
|
}
|
|
_alarmConditions.Clear();
|
|
|
|
foreach (var f in _folders.Values)
|
|
{
|
|
f.Parent?.RemoveChild(f);
|
|
PredefinedNodes?.Remove(f.NodeId);
|
|
}
|
|
_folders.Clear();
|
|
|
|
// Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the
|
|
// guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server
|
|
// object. RemoveRootNotifier just severs that link, so its order relative to the folder
|
|
// teardown above doesn't matter — but it must run under this same Lock.
|
|
foreach (var folder in _notifierFolders.Values)
|
|
{
|
|
RemoveRootNotifier(folder);
|
|
}
|
|
|
|
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
|
|
// equipment folders to event notifiers.
|
|
_notifierFolders.Clear();
|
|
// Phase C (Task 4): drop the event-history source registrations alongside the notifier folders
|
|
// they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion.
|
|
_eventNotifierSources.Clear();
|
|
}
|
|
}
|
|
|
|
private FolderState ResolveParentFolder(string? parentNodeId)
|
|
{
|
|
if (string.IsNullOrEmpty(parentNodeId)) return _root!;
|
|
return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Phase C — OPC UA HistoryRead over historized variable nodes.
|
|
//
|
|
// The base CustomNodeManager2.HistoryRead (public + protected dispatcher) does the heavy lifting:
|
|
// it validates handles under Lock, builds `nodesToProcess` (a NodeHandle list for nodes WE own
|
|
// that carry the HistoryRead access bit), validates the timestamp args, handles
|
|
// `releaseContinuationPoints`, and dispatches by `details` runtime type to the per-details
|
|
// protected virtuals below. We override all four arms: the three variable-history virtuals
|
|
// (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents, Task 4). Each override
|
|
// receives the pre-filtered handles and fills results[handle.Index] / errors[handle.Index] —
|
|
// handle.Index is the original index into the service-level results/errors lists, seeded by the
|
|
// base. The base pre-seeds every handle's error to BadHistoryOperationUnsupported, so a handle
|
|
// we don't recognise stays "unsupported" by default.
|
|
//
|
|
// NOTE: unlike OnWriteValue, the SDK does NOT hold the node-manager Lock while invoking these, so
|
|
// block-bridging the async data source (GetAwaiter().GetResult()) is safe — it can't freeze the
|
|
// address space. Each handle is served in isolation under try/catch so one node's failure (timeout,
|
|
// backend throw) never throws out of the batch.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Serve a HistoryRead-Raw request over the pre-filtered historized variable handles, dispatching
|
|
/// each to <see cref="IHistorianDataSource.ReadRawAsync"/>. Modified-history reads
|
|
/// (<c>IsReadModified</c>) are unsupported — we don't serve a modified-value history surface.
|
|
/// </summary>
|
|
protected override void HistoryReadRawModified(
|
|
ServerSystemContext context,
|
|
ReadRawModifiedDetails details,
|
|
TimestampsToReturn timestampsToReturn,
|
|
IList<HistoryReadValueId> nodesToRead,
|
|
IList<SdkHistoryReadResult> results,
|
|
IList<ServiceResult> errors,
|
|
List<NodeHandle> nodesToProcess,
|
|
IDictionary<NodeId, NodeState> cache)
|
|
{
|
|
foreach (var handle in nodesToProcess)
|
|
{
|
|
if (details.IsReadModified)
|
|
{
|
|
// We never serve modified-value history; mark this node unsupported and move on.
|
|
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
|
continue;
|
|
}
|
|
|
|
ServeNode(handle, results, errors, (source, tagname) => source.ReadRawAsync(
|
|
tagname,
|
|
details.StartTime,
|
|
details.EndTime,
|
|
details.NumValuesPerNode,
|
|
CancellationToken.None));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serve a HistoryRead-Processed request, mapping each node's per-node aggregate NodeId (from the
|
|
/// parallel <c>AggregateType</c> collection — the base guarantees it is the same length as
|
|
/// <c>nodesToRead</c>) to a <see cref="HistoryAggregateType"/> and dispatching to
|
|
/// <see cref="IHistorianDataSource.ReadProcessedAsync"/>. An unknown aggregate yields
|
|
/// <c>BadAggregateNotSupported</c> for that node.
|
|
/// </summary>
|
|
protected override void HistoryReadProcessed(
|
|
ServerSystemContext context,
|
|
ReadProcessedDetails details,
|
|
TimestampsToReturn timestampsToReturn,
|
|
IList<HistoryReadValueId> nodesToRead,
|
|
IList<SdkHistoryReadResult> results,
|
|
IList<ServiceResult> errors,
|
|
List<NodeHandle> nodesToProcess,
|
|
IDictionary<NodeId, NodeState> cache)
|
|
{
|
|
foreach (var handle in nodesToProcess)
|
|
{
|
|
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
|
|
// the base dispatcher). handle.Index is the node's position in that collection.
|
|
var aggregateNodeId = details.AggregateType[handle.Index];
|
|
var aggregate = MapAggregate(aggregateNodeId);
|
|
if (aggregate is null)
|
|
{
|
|
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
|
|
continue;
|
|
}
|
|
|
|
ServeNode(handle, results, errors, (source, tagname) => source.ReadProcessedAsync(
|
|
tagname,
|
|
details.StartTime,
|
|
details.EndTime,
|
|
// OPC UA ProcessingInterval is a Duration in milliseconds.
|
|
TimeSpan.FromMilliseconds(details.ProcessingInterval),
|
|
aggregate.Value,
|
|
CancellationToken.None));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serve a HistoryRead-AtTime request, dispatching the requested timestamps to
|
|
/// <see cref="IHistorianDataSource.ReadAtTimeAsync"/>.
|
|
/// </summary>
|
|
protected override void HistoryReadAtTime(
|
|
ServerSystemContext context,
|
|
ReadAtTimeDetails details,
|
|
TimestampsToReturn timestampsToReturn,
|
|
IList<HistoryReadValueId> nodesToRead,
|
|
IList<SdkHistoryReadResult> results,
|
|
IList<ServiceResult> errors,
|
|
List<NodeHandle> nodesToProcess,
|
|
IDictionary<NodeId, NodeState> cache)
|
|
{
|
|
// Snapshot the requested timestamps once — the same list is read for every node.
|
|
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
|
|
foreach (var handle in nodesToProcess)
|
|
{
|
|
ServeNode(handle, results, errors, (source, tagname) => source.ReadAtTimeAsync(
|
|
tagname,
|
|
timestamps,
|
|
CancellationToken.None));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serve a HistoryRead-Events request over the equipment-folder event-notifier nodes (the folders
|
|
/// that own alarm conditions). Each handle's NodeId identifier is resolved against
|
|
/// <see cref="_eventNotifierSources"/>: a miss ⇒ <c>BadHistoryOperationUnsupported</c> (a node we own
|
|
/// that isn't a registered event-history source — e.g. a plain folder, or one promoted while no
|
|
/// historian was wired); a hit block-bridges to <see cref="IHistorianDataSource.ReadEventsAsync"/>
|
|
/// for the folder's source name and projects each <see cref="HistoricalEvent"/> into a
|
|
/// <see cref="HistoryEventFieldList"/> per the request's event filter. Like the Raw/Processed/AtTime
|
|
/// arms this is NOT invoked under the node-manager <c>Lock</c>, so the block-bridge is safe; each
|
|
/// handle is served under try/catch so a backend throw becomes a Bad status for THAT node only and
|
|
/// never throws out of the batch.
|
|
/// </summary>
|
|
protected override void HistoryReadEvents(
|
|
ServerSystemContext context,
|
|
ReadEventDetails details,
|
|
TimestampsToReturn timestampsToReturn,
|
|
IList<HistoryReadValueId> nodesToRead,
|
|
IList<SdkHistoryReadResult> results,
|
|
IList<ServiceResult> errors,
|
|
List<NodeHandle> nodesToProcess,
|
|
IDictionary<NodeId, NodeState> cache)
|
|
{
|
|
// Snapshot the select clauses once — the same filter projects every node's events.
|
|
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
|
|
|
|
foreach (var handle in nodesToProcess)
|
|
{
|
|
var idString = handle.NodeId.Identifier?.ToString();
|
|
if (idString is null || !_eventNotifierSources.TryGetValue(idString, out var sourceName))
|
|
{
|
|
// Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported.
|
|
// Set both errors and results explicitly on every bad path — don't rely on the SDK base
|
|
// pre-seeding results[i], so every path is self-contained and the contract is obvious.
|
|
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
|
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
// NOT under the node-manager Lock — block-bridging the async source is safe here.
|
|
var sourceResult = _historianDataSource.ReadEventsAsync(
|
|
sourceName,
|
|
details.StartTime,
|
|
details.EndTime,
|
|
// NumValuesPerNode is uint; ReadEventsAsync takes int (<=0 ⇒ backend default cap).
|
|
ClampToInt(details.NumValuesPerNode),
|
|
CancellationToken.None).GetAwaiter().GetResult();
|
|
|
|
var historyEvent = ProjectEvents(sourceResult.Events, selectClauses);
|
|
results[handle.Index] = new SdkHistoryReadResult
|
|
{
|
|
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
|
|
StatusCode = sourceResult.Events.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
|
HistoryData = new ExtensionObject(historyEvent),
|
|
// We never issue continuation points — every read returns the full window in one shot.
|
|
ContinuationPoint = null,
|
|
};
|
|
errors[handle.Index] = ServiceResult.Good;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
|
|
// only. This manager carries no ILogger, so log via the SDK's static trace (see ServeNode).
|
|
#pragma warning disable CS0618 // Type or member is obsolete
|
|
Utils.LogError(ex, "OtOpcUaNodeManager: HistoryReadEvents failed for node {0}", handle.NodeId);
|
|
#pragma warning restore CS0618
|
|
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
|
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Clamp a <see cref="uint"/> request cap to a non-negative <see cref="int"/> for the
|
|
/// event-read surface (whose <c>maxEvents</c> is signed): values above <see cref="int.MaxValue"/>
|
|
/// saturate to <see cref="int.MaxValue"/>.</summary>
|
|
/// <param name="value">The uint cap from the request (<c>NumValuesPerNode</c>).</param>
|
|
/// <returns>The clamped non-negative int.</returns>
|
|
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
|
|
|
|
/// <summary>
|
|
/// Project a sequence of <see cref="HistoricalEvent"/>s into an SDK <see cref="HistoryEvent"/> —
|
|
/// one <see cref="HistoryEventFieldList"/> per event, each carrying the requested
|
|
/// <paramref name="selectClauses"/>' fields in select-clause order (see
|
|
/// <see cref="ProjectEventField"/>).
|
|
/// </summary>
|
|
/// <param name="events">The historian's event rows.</param>
|
|
/// <param name="selectClauses">The request's event-filter select clauses (the fields to emit, in order).</param>
|
|
/// <returns>The populated SDK <see cref="HistoryEvent"/>.</returns>
|
|
private static HistoryEvent ProjectEvents(
|
|
IReadOnlyList<HistoricalEvent> events, SimpleAttributeOperandCollection selectClauses)
|
|
{
|
|
var fieldLists = new HistoryEventFieldListCollection(events.Count);
|
|
foreach (var evt in events)
|
|
{
|
|
var fields = new VariantCollection(selectClauses.Count);
|
|
foreach (var operand in selectClauses)
|
|
{
|
|
fields.Add(ProjectEventField(evt, operand));
|
|
}
|
|
|
|
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
|
|
}
|
|
|
|
return new HistoryEvent { Events = fieldLists };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Project one <see cref="HistoricalEvent"/> field requested by a select <paramref name="operand"/>
|
|
/// into a <see cref="Variant"/>. Mapping is by the operand's BrowsePath LEAF QualifiedName name
|
|
/// (case-sensitive, per the OPC UA BaseEventType field names) against the
|
|
/// <see cref="HistoricalEvent"/> shape:
|
|
/// <c>EventId</c>→ByteString, <c>SourceName</c>→String, <c>Time</c>→DateTime,
|
|
/// <c>ReceiveTime</c>→DateTime, <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16. Any other
|
|
/// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
|
|
/// <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
|
|
/// </summary>
|
|
/// <param name="evt">The source event row.</param>
|
|
/// <param name="operand">The select-clause operand naming the field to project.</param>
|
|
/// <returns>The projected variant, or <see cref="Variant.Null"/> for an unsupported field.</returns>
|
|
private static Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand)
|
|
{
|
|
var leaf = LeafFieldName(operand);
|
|
return leaf switch
|
|
{
|
|
// BaseEventType/EventId is a ByteString — encode the driver-specific string id as UTF-8 bytes.
|
|
"EventId" => new Variant(System.Text.Encoding.UTF8.GetBytes(evt.EventId ?? string.Empty)),
|
|
"SourceName" => evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName),
|
|
"Time" => new Variant(evt.EventTimeUtc),
|
|
"ReceiveTime" => new Variant(evt.ReceivedTimeUtc),
|
|
"Message" => new Variant(new LocalizedText(evt.Message ?? string.Empty)),
|
|
"Severity" => new Variant(evt.Severity), // UInt16
|
|
// EventType / SourceNode / ConditionName / empty path / unrecognised leaf ⇒ null (spec-conformant).
|
|
_ => Variant.Null,
|
|
};
|
|
}
|
|
|
|
/// <summary>Extract the leaf (last) <see cref="QualifiedName.Name"/> from a select operand's BrowsePath,
|
|
/// or <c>null</c> when the BrowsePath is null/empty.</summary>
|
|
/// <param name="operand">The select-clause operand.</param>
|
|
/// <returns>The leaf field name, or null for an empty BrowsePath.</returns>
|
|
private static string? LeafFieldName(SimpleAttributeOperand operand)
|
|
{
|
|
var path = operand.BrowsePath;
|
|
if (path is null || path.Count == 0) return null;
|
|
return path[^1].Name;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Block-bridge to the historian source for one node handle and project the result onto the
|
|
/// service-level results/errors slots. Resolves the node's registered historian tagname first —
|
|
/// a single <see cref="TryGetHistorizedTagname"/> lookup; the resolved tagname is passed directly
|
|
/// to <paramref name="read"/>, removing any risk of a second concurrent lookup on the same key.
|
|
/// A node we don't recognise as historized maps to <c>BadHistoryOperationUnsupported</c>
|
|
/// (shouldn't normally reach us, since the base only hands us nodes with the HistoryRead access
|
|
/// bit, but we guard explicitly). The <paramref name="read"/> callback receives the resolved
|
|
/// tagname and is wrapped in try/catch so a backend throw / timeout becomes a Bad status for
|
|
/// THIS node without throwing out of the batch.
|
|
/// </summary>
|
|
/// <param name="handle">The pre-filtered node handle to serve; <c>handle.Index</c> indexes results/errors.</param>
|
|
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
|
|
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
|
|
/// <param name="read">Invokes the resolved data-source read with the resolved tagname; only called
|
|
/// once the tagname is confirmed present.</param>
|
|
private void ServeNode(
|
|
NodeHandle handle,
|
|
IList<SdkHistoryReadResult> results,
|
|
IList<ServiceResult> errors,
|
|
Func<IHistorianDataSource, string, Task<HistorianRead>> read)
|
|
{
|
|
var idString = handle.NodeId.Identifier?.ToString();
|
|
if (idString is null || !TryGetHistorizedTagname(idString, out var tagname))
|
|
{
|
|
// Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same
|
|
// status, but set it explicitly so the contract is local + obvious.)
|
|
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// HistoryRead is NOT invoked under the node-manager Lock (unlike OnWriteValue), so blocking
|
|
// on the async source here is safe and won't freeze the address space.
|
|
var sourceResult = read(HistorianDataSource, tagname!).GetAwaiter().GetResult();
|
|
var historyData = ToHistoryData(sourceResult);
|
|
|
|
results[handle.Index] = new SdkHistoryReadResult
|
|
{
|
|
// No source samples ⇒ GoodNoData (the node is historized, the window just held no data).
|
|
StatusCode = historyData.DataValues.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
|
HistoryData = new ExtensionObject(historyData),
|
|
// We never issue continuation points — every read returns the full window in one shot.
|
|
ContinuationPoint = null,
|
|
};
|
|
errors[handle.Index] = ServiceResult.Good;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// One node's backend failure (throw / timeout / cancellation) must not throw out of the
|
|
// batch — surface a Bad status for THIS node only. This CustomNodeManager2 carries no
|
|
// ILogger (see ReportConditionEvent), so log through the SDK's static trace rather than
|
|
// swallowing silently. Utils.LogError is [Obsolete] in 1.5.378 (favours an ITelemetryContext
|
|
// this manager doesn't wire) — suppress the deprecation, matching the existing pattern.
|
|
#pragma warning disable CS0618 // Type or member is obsolete
|
|
Utils.LogError(ex, "OtOpcUaNodeManager: HistoryRead failed for node {0}", handle.NodeId);
|
|
#pragma warning restore CS0618
|
|
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Map an OPC UA Part 13 standard-aggregate function NodeId to our
|
|
/// <see cref="HistoryAggregateType"/>. Returns <c>null</c> for any aggregate we don't serve so
|
|
/// the caller can surface <c>BadAggregateNotSupported</c>.
|
|
/// </summary>
|
|
/// <param name="aggregateNodeId">The per-node aggregate-function NodeId from the request.</param>
|
|
/// <returns>The mapped aggregate, or <c>null</c> when unsupported.</returns>
|
|
private static HistoryAggregateType? MapAggregate(NodeId aggregateNodeId)
|
|
{
|
|
if (aggregateNodeId == ObjectIds.AggregateFunction_Average) return HistoryAggregateType.Average;
|
|
if (aggregateNodeId == ObjectIds.AggregateFunction_Minimum) return HistoryAggregateType.Minimum;
|
|
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
|
|
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
|
|
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Project the historian source's <see cref="HistorianRead"/> (Core.Abstractions DTO) into an
|
|
/// SDK <see cref="HistoryData"/> — one <see cref="DataValue"/> per <see cref="DataValueSnapshot"/>,
|
|
/// carrying value / status / source+server timestamps. A null SourceTimestamp maps to
|
|
/// <c>DateTime.MinValue</c> (the SDK's "unset" sentinel for that field).
|
|
/// </summary>
|
|
/// <param name="sourceResult">The data source's read result.</param>
|
|
/// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
|
|
private static HistoryData ToHistoryData(HistorianRead sourceResult)
|
|
{
|
|
var values = new DataValueCollection(sourceResult.Samples.Count);
|
|
foreach (var sample in sourceResult.Samples)
|
|
{
|
|
values.Add(ToSdkDataValue(sample));
|
|
}
|
|
|
|
return new HistoryData { DataValues = values };
|
|
}
|
|
|
|
/// <summary>Convert one driver-agnostic <see cref="DataValueSnapshot"/> to an SDK
|
|
/// <see cref="DataValue"/>, mirroring value / status code / source + server timestamps.</summary>
|
|
/// <param name="snapshot">The source sample.</param>
|
|
/// <returns>The equivalent SDK data value.</returns>
|
|
private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new()
|
|
{
|
|
WrappedValue = new Variant(snapshot.Value),
|
|
StatusCode = new StatusCode(snapshot.StatusCode),
|
|
SourceTimestamp = snapshot.SourceTimestampUtc ?? DateTime.MinValue,
|
|
ServerTimestamp = snapshot.ServerTimestampUtc,
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
|
{
|
|
lock (Lock)
|
|
{
|
|
base.CreateAddressSpace(externalReferences);
|
|
|
|
// Create one root folder under Objects/ for every variable we mint to hang under.
|
|
_root = new FolderState(null)
|
|
{
|
|
NodeId = new NodeId("OtOpcUa", NamespaceIndex),
|
|
BrowseName = new QualifiedName("OtOpcUa", NamespaceIndex),
|
|
DisplayName = "OtOpcUa",
|
|
EventNotifier = EventNotifiers.None,
|
|
TypeDefinitionId = ObjectTypeIds.FolderType,
|
|
};
|
|
_root.AddReference(ReferenceTypeIds.Organizes, isInverse: true, ObjectIds.ObjectsFolder);
|
|
|
|
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var refs))
|
|
{
|
|
refs = new List<IReference>();
|
|
externalReferences[ObjectIds.ObjectsFolder] = refs;
|
|
}
|
|
refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, isInverse: false, _root.NodeId));
|
|
|
|
AddPredefinedNode(SystemContext, _root);
|
|
}
|
|
}
|
|
|
|
private BaseDataVariableState CreateVariable(string nodeId)
|
|
{
|
|
var v = new BaseDataVariableState(_root)
|
|
{
|
|
NodeId = new NodeId(nodeId, NamespaceIndex),
|
|
BrowseName = new QualifiedName(nodeId, NamespaceIndex),
|
|
DisplayName = nodeId,
|
|
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
|
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
|
DataType = DataTypeIds.BaseDataType,
|
|
ValueRank = ValueRanks.Scalar,
|
|
AccessLevel = AccessLevels.CurrentRead,
|
|
UserAccessLevel = AccessLevels.CurrentRead,
|
|
Historizing = false,
|
|
};
|
|
_root?.AddChild(v);
|
|
AddPredefinedNode(SystemContext, v);
|
|
return v;
|
|
}
|
|
|
|
private static StatusCode StatusFromQuality(OpcUaQuality quality) => quality switch
|
|
{
|
|
OpcUaQuality.Good => StatusCodes.Good,
|
|
OpcUaQuality.Uncertain => StatusCodes.Uncertain,
|
|
_ => StatusCodes.Bad,
|
|
};
|
|
}
|