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;
///
/// Custom OPC UA that owns the writable address space for
/// the OtOpcUa server. Variable nodes are created lazily on first
/// under the manager's namespace; subsequent writes update the existing node's Value +
/// StatusCode + SourceTimestamp and notify subscribed clients via the standard
/// ClearChangeMasks path.
///
/// This is the F10b production wiring behind the v2
/// seam — once a 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. "ns=2;s=eq-1/temp"). 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
/// under the namespace root.
///
public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
private readonly ConcurrentDictionary _variables = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _folders = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _alarmConditions = new(StringComparer.Ordinal);
/// Phase C: NodeId → resolved historian tagname for every variable materialised
/// Historizing. Populated by when a historian tagname is supplied; the
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
/// .
private readonly ConcurrentDictionary _historizedTagnames = new(StringComparer.Ordinal);
/// Folders we have already promoted to event-notifiers + registered as root notifiers,
/// so repeated calls don't double-add (idempotent guard).
/// Keyed by NodeId → the actual so can
/// pass the folder to RemoveRootNotifier on teardown.
private readonly Dictionary _notifierFolders = new();
/// Phase C (Task 4): event-notifier folder NodeId-identifier → the event-history source
/// name passed to . 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
/// only when a real historian is wired at promotion time,
/// and the override resolves an inbound request's notifier NodeId
/// against it (a miss ⇒ BadHistoryOperationUnsupported). Cleared on
/// .
private readonly ConcurrentDictionary _eventNotifierSources = new(StringComparer.Ordinal);
private FolderState? _root;
/// Initializes a new instance of the class with the OPC UA server and configuration.
/// The OPC UA server instance.
/// The application configuration.
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: base(server, configuration, DefaultNamespaceUri)
{
// SystemContext is initialised by the base ctor.
}
/// Gets the count of variable nodes currently managed.
public int VariableCount => _variables.Count;
/// Gets the count of folder nodes currently managed.
public int FolderCount => _folders.Count;
/// Gets the count of real Part 9 nodes currently managed.
public int AlarmConditionCount => _alarmConditions.Count;
///
/// 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 ) gates on the caller's
/// AlarmAck role and, when allowed, builds an and invokes this
/// delegate. The host sets it at boot to a non-blocking mediator.Tell onto the
/// alarm-commands DistributedPubSub topic; T19's engine-side subscriber consumes it.
///
/// This is the ONLY reverse coupling out of the node manager — by design it is a plain
/// (no Akka / IActorRef / DI handle). The handler
/// delegates run under the manager's Lock; the invoked action MUST be non-blocking
/// (a fire-and-forget Tell) so there is no deadlock. Null (the default) makes every
/// handler a safe no-op — it still gates + returns, just routes nowhere.
///
///
public Action? AlarmCommandRouter { get; set; }
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
///
/// 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
/// handler (, attached by when the
/// variable is writable) first gates on the caller's
/// role and, when allowed, calls with the node's
/// string id + the written value to route the write to the backing driver.
///
/// This is the write-side twin of ; the gateway abstraction keeps
/// this assembly Akka-free (the host wires an ActorNodeWriteGateway that Asks the local
/// DriverHostActor). The handler delegates run under the node-manager Lock (the OPC
/// UA SDK's CustomNodeManager2.Write holds Lock while invoking OnWriteValue),
/// so the dispatch is FIRE-AND-FORGET — the handler kicks off WriteAsync and returns
/// Good immediately so the SDK applies the client value optimistically; it MUST NOT block
/// on the device round-trip. When the asynchronous comes back
/// FAILED, an off-Lock continuation self-corrects: it re-takes Lock 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
/// / ).
///
///
/// Set by the host at StartAsync; the default
/// (assigning null restores it) makes every write resolve to a "writes unavailable"
/// failure. Backed by a volatile 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.
///
///
public IOpcUaNodeWriteGateway NodeWriteGateway
{
get => _nodeWriteGateway;
set => _nodeWriteGateway = value ?? NullOpcUaNodeWriteGateway.Instance;
}
private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;
///
/// 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
/// Historizing (a tag with 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.
///
/// Set by the Host at StartAsync (Task 5). The
/// default (assigning null restores it) means "no historian wired" → every read
/// returns empty, so a historized node's HistoryRead surfaces GoodNoData rather than
/// faulting. Backed by a volatile 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
/// , the HistoryRead override does NOT run under the
/// node-manager Lock, so the override may block-bridge to this (async) source.
///
///
public IHistorianDataSource HistorianDataSource
{
get => _historianDataSource;
set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
}
private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore();
///
/// The store that holds the server-side resume state behind an opaque HistoryRead continuation
/// point for the count-capped variable-history arms (Raw / Processed). The default
/// binds points to the OPC UA session — so they are
/// capped (ServerConfiguration.MaxHistoryContinuationPoints, SDK default 100, oldest-evicted)
/// and disposed when the session closes. Exposed (internal) so the session-less in-process tests can
/// inject an and exercise the full multi-page round
/// trip through the same dispatch path. Assigning null restores the session-backed default.
///
internal IHistoryContinuationStore HistoryContinuationStore
{
get => _historyContinuationStore;
set => _historyContinuationStore = value ?? new SessionHistoryContinuationStore();
}
/// 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.
/// The alarm node identifier (== ScriptedAlarmId).
/// The cached , or null when none is registered.
public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId) =>
_alarmConditions.TryGetValue(alarmNodeId, out var condition) ? condition : null;
/// 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.
/// The variable node identifier.
/// The resolved historian tagname when historized; otherwise null.
/// True when the node is registered as historized; otherwise false.
public bool TryGetHistorizedTagname(string nodeId, out string? tagname)
{
if (_historizedTagnames.TryGetValue(nodeId, out var t)) { tagname = t; return true; }
tagname = null;
return false;
}
/// 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.
/// The variable node identifier.
/// The cached , or null when none is registered.
internal BaseDataVariableState? TryGetVariable(string nodeId) =>
_variables.TryGetValue(nodeId, out var variable) ? variable : null;
/// 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).
/// The folder node identifier.
/// The cached , or null when none is registered.
internal FolderState? TryGetFolder(string nodeId) =>
_folders.TryGetValue(nodeId, out var folder) ? folder : null;
///
/// Apply a value write from . Creates the
/// variable node on first call; subsequent calls update Value + StatusCode +
/// SourceTimestamp and call ClearChangeMasks so subscribed clients see the change.
///
/// The node identifier of the variable.
/// The new value to write.
/// The OPC UA quality status code.
/// The timestamp of the value in UTC.
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);
}
}
///
/// Apply a full Part 9 alarm-condition write. When a real has
/// been materialised for (via ),
/// this projects the whole snapshot
/// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
/// node and recomputes Retain (T15 — richer state; still no event firing, that lands in T16).
/// Otherwise it falls back to the legacy two-element [Active, Acknowledged]
/// placeholder so callers whose alarm node hasn't been
/// materialised (and the existing unit tests) keep working.
///
/// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).
/// The full condition state to project onto the node.
/// The timestamp of the alarm state change in UTC.
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);
}
}
///
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
/// materialised . The caller MUST already hold Lock and
/// have applied the new state via the Set* projection — this stamps a fresh per-event
/// EventId, ClearChangeMasks, then ReportEvent with an
/// (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).
///
/// A fresh EventId 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
/// GetEventByEventId / GetBranch), so T17's ack routing relies on it being unique
/// per emission. We use the main branch only (BranchId == NodeId.Null, set at
/// materialise) — no branch creation here.
///
///
/// Double-emit note (resolved by delta-gate). 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 . The engine then re-projects that same logical
/// transition through , which would otherwise fire a second
/// event (E3). '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.
///
///
/// The materialised condition whose new state has already been projected; must be non-null.
/// The source/receive timestamp (UTC) for this event.
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
}
}
///
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
/// condition event AND that an can change. As a record, two
/// instances compare by value, so is a plain inequality.
///
/// Severity is stored as the MAPPED bucket (a
/// ) — the same value the node holds after SetSeverity — so two
/// raw severities that fall in the same bucket are correctly treated as "no change". The
/// Shelving 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.
///
///
/// Why CommentAdded is not a field here (intentional).
/// EmissionKind.CommentAdded is produced only by
/// Part9StateMachine.ApplyAddComment, which is reached only via
/// ScriptedAlarmEngine.AddCommentAsync, which is called only from
/// ScriptedAlarmHostActor's inbound AlarmCommand handler — meaning
/// CommentAdded ALWAYS originates from a client calling the condition's
/// AddComment method. On that path T18's OnAddComment delegate returns
/// ServiceResult.Good, 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 . 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
/// CommentAdded would double-emit. There is no engine-internal or script-driven
/// comment path, so suppression never drops a needed event.
///
///
/// Why Retain is absent (intentional — safe today).
/// Retain is projected as state.Active || !state.Acknowledged in
/// . Every path that flips Retain necessarily
/// changes Active or Acknowledged (both ARE compared fields), so a
/// Retain flip always rides along with a real delta and fires correctly. If a
/// future engine were to set Retain independently — without touching
/// Active/Acknowledged — it would need to be added here.
///
///
internal readonly record struct AlarmConditionDelta(
bool Active,
bool Acknowledged,
bool Confirmed,
bool Enabled,
AlarmShelvingKind Shelving,
ushort MappedSeverity,
string Message);
/// Decide whether a 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 true iff
/// any gate-relevant field differs. An inbound client ack the SDK already applied makes
/// == ⇒ false (suppress the re-projected
/// double-emit); a genuine engine-driven transition differs ⇒ true (fire).
/// The node's current (pre-projection) gate-relevant state.
/// The incoming snapshot's gate-relevant state.
/// true to fire a condition event; false to suppress (no delta).
internal static bool ShouldFireConditionEvent(AlarmConditionDelta current, AlarmConditionDelta incoming) =>
current != incoming;
/// 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
/// bucket the node stores, and shelving is mapped from the shelving
/// state machine's CurrentState so it lines up with .
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);
/// 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 so it matches
/// the bucket the node holds (the projection calls SetSeverity(MapSeverity(...))), 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.
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);
/// Map the live shelving state machine's CurrentState back to our 3-way
/// 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
/// — the same value folds an
/// unsupported shelving snapshot to, so the two stay comparable.
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;
}
///
/// Materialise a real OPC UA Part 9 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
/// calls — which target that same id — update this node.
///
/// This is the T14 production replacement for the bool[2] placeholder: it creates
/// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
/// no events itself.
///
/// Idempotent: a second call with the same tears down the prior
/// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
///
/// The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.
/// The equipment folder node id the condition parents under (null/unknown ⇒ root).
/// Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).
/// Domain alarm type — maps to the SDK condition subtype (see remarks).
/// Domain severity (treated as an OPC UA 1..1000 severity); mapped to .
///
/// AlarmType → SDK subtype mapping. Script-driven alarms have no OPC limit /
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
/// map: OffNormalAlarm → , DiscreteAlarm →
/// , and everything else (including AlarmCondition and
/// LimitAlarm, which has no script-supplied limits) → the base
/// . LimitAlarm deliberately falls back to base per the T13
/// notes — a script alarm carries no High/Low limits to populate.
///
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;
}
}
///
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
/// principal off the SDK , applies the AlarmAck role gate
/// (fails closed: a missing identity or a missing role is denied), and on success builds a
/// mapped and routes it through .
///
/// The SDK context the handler delegate was invoked with — a
/// ServerSystemContext (an ) carrying the session
/// identity. T17 attached the LDAP roles as a .
/// The condition the method targets; its NodeId identifier is the
/// ScriptedAlarmId (T14 aligned them), which becomes .
/// The Part 9 operation name (e.g. Acknowledge, TimedShelve).
/// The call's comment text, or null when none was supplied.
/// For TimedShelve, the computed UTC expiry; otherwise null.
/// ServiceResult.Good when allowed (the SDK then applies state + auto-fires its
/// event); BadUserAccessDenied when the gate vetoes (no route, no state mutation).
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;
}
///
/// The attached to a writable equipment-tag variable by
/// (Task 11). The OPC UA SDK invokes it when a client writes the
/// node's Value. It resolves the calling principal off the SDK the
/// SAME way does, gates on the
/// role + the gateway being wired
/// (fails closed: a missing identity / missing role ⇒ BadUserAccessDenied; no gateway ⇒
/// BadNotWritable) via the pure , and on pass dispatches
/// the value through .
///
/// The dispatch is FIRE-AND-FORGET: the SDK's CustomNodeManager2.Write holds the node
/// manager Lock 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
/// lets the SDK apply the written value optimistically.
///
///
/// Item A — synchronous structural fail-fast. After the authz gate passes but BEFORE the
/// optimistic dispatch, a pure pre-check rejects a
/// structurally-invalid write (e.g. a null payload, or a confidently-detected built-in-type
/// mismatch) INLINE — returning Bad synchronously so the SDK never applies it, avoiding the
/// optimistic-Good-then-revert round-trip + a pointless device dispatch.
///
///
/// Write-outcome self-correction. Before returning Good (which makes the SDK overwrite the
/// node with ) we capture both the optimistic value AND the node's REAL
/// prior value/status — at handler entry the node still holds the prior value — plus the writing
/// principal's user-id (threaded to the audit event). An off-Lock continuation on the
/// then, on a FAILED outcome and ONLY while the node still holds the
/// optimistic value (so a fresh driver poll that already republished the confirmed register value is
/// not clobbered): surfaces a transient Bad-quality blip (Item B), reverts the node to its
/// prior value/status, and raises a Part 8 AuditWriteUpdateEvent (Item C) recording the
/// rejected write ( / ). On
/// success the optimistic value stands and the next poll re-confirms it via the normal
/// path.
///
///
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;
// Item A (synchronous structural fail-fast): reject a structurally-invalid write INLINE — return Bad
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
// caller) but BEFORE the optimistic dispatch below.
var structure = EvaluateEquipmentWriteStructure(value, node);
if (structure is not null) return structure;
// 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;
}
// Item C: thread the writing principal's user-id string into the failure continuation so the audit
// event can populate ClientUserId. Resolved here off the same identity the gate used (null when the
// session is anonymous / carries no role-carrying identity — the gate would already have vetoed, so in
// practice non-null on this path, but kept defensive).
var clientUserId = identity?.DisplayName;
// 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, clientUserId);
},
CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
return ServiceResult.Good;
}
///
/// Pure role + availability gate for an inbound equipment-tag write, extracted off
/// so it is unit-testable without booting an SDK server. Fails closed:
/// a null identity or an identity missing the role ⇒
/// BadUserAccessDenied. When the gate passes but no real gateway is wired
/// ( is false) ⇒ BadNotWritable ("writes unavailable"). A
/// null return means "proceed" (the caller dispatches + returns Good). Role comparison is
/// case-insensitive (the role set is built with ),
/// matching the alarm gate.
///
/// The role-carrying identity extracted off the SDK context, or null when the
/// session is anonymous / carries no role-carrying identity.
/// True when a non-Null is wired; false
/// for the Null default (no route — e.g. admin-only nodes / pre-boot).
/// null to proceed (gate passed); otherwise the veto
/// (BadUserAccessDenied on a failed role gate, BadNotWritable when no gateway is wired).
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;
}
///
/// Item A — synchronous structural fail-fast. Pure structural pre-check for an inbound
/// equipment-tag write, run AFTER passes but BEFORE the
/// optimistic device dispatch in . A structurally-invalid write is
/// rejected INLINE (a Bad is returned synchronously, so the SDK never
/// applies the value) instead of being optimistically applied and later reverted — saving both the
/// phantom value-blip and a pointless device round-trip.
///
/// Interpretation / tradeoff (the item was deliberately under-specified). The minimum
/// sensible structural check is a null value write to a value variable ⇒
/// BadTypeMismatch (a value node always holds a typed scalar/array; a null payload is never a
/// valid value write here). On top of that, when the node is a
/// whose resolves to a concrete built-in type AND the
/// written value's runtime built-in type is also resolvable, a CHEAP built-in-type compatibility
/// check is applied: a clear mismatch ⇒ BadTypeMismatch. The check is intentionally
/// conservative — it only rejects when BOTH the expected and actual built-in types are confidently
/// resolved AND differ (with numeric widening + the BaseDataType "accept anything" wildcard
/// allowed); anything uncertain (a non-variable node, an unresolved/abstract DataType, a
/// /array payload whose element type isn't cheaply known) is allowed through so
/// the SDK's own (authoritative) type coercion in BaseVariableState.WriteValue remains the
/// final arbiter. We deliberately do NOT attempt deep array-dimension / structured-type validation
/// here — that is left to the SDK.
///
/// Pure (no SDK server / Lock needed): reads only and the node's static
/// DataType, so it is unit-testable in isolation.
///
/// The value the client wrote (the SDK's pre-coercion payload).
/// The target node (expected to be a writable ).
/// null to proceed; otherwise the veto
/// (BadTypeMismatch for a null write or a confidently-detected built-in-type mismatch).
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node)
{
// Minimum sensible check: a null payload is never a valid value write to a value variable.
if (value is null)
{
return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected");
}
// Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable
// with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply
// resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative.
if (node is not BaseDataVariableState variable) return null;
var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
// actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
// StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
// a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
if (!IsCheckableExpectedType(expected)) return null;
// TypeInfo.Construct(object) classifies the runtime payload; an unclassifiable value ⇒ Unknown (Null).
var actual = TypeInfo.Construct(value).BuiltInType;
if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
if (!IsBuiltInTypeCompatible(expected, actual))
{
return new ServiceResult(
StatusCodes.BadTypeMismatch,
$"value built-in type {actual} is not compatible with node data type {expected}");
}
return null;
}
///
/// Conservative built-in-type compatibility test for .
/// Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass;
/// numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics); a
/// ↔ array nuance and any
/// on either side are treated as compatible. Only a clear cross-family
/// mismatch (e.g. writing a String to a Boolean node) returns false. Pure + static.
///
private static bool IsBuiltInTypeCompatible(BuiltInType expected, BuiltInType actual)
{
if (expected == actual) return true;
// Any side a wildcard/unclassified ⇒ defer (compatible) — the caller already filtered most of these.
if (expected is BuiltInType.Variant or BuiltInType.Null) return true;
if (actual is BuiltInType.Variant or BuiltInType.Null) return true;
// Numeric family widening/narrowing is the SDK's job; treat numeric↔numeric as compatible.
if (IsNumeric(expected) && IsNumeric(actual)) return true;
// ByteString and Byte are routinely interchangeable on the wire; don't reject that pairing.
if ((expected, actual) is (BuiltInType.ByteString, BuiltInType.Byte) or (BuiltInType.Byte, BuiltInType.ByteString)) return true;
return false;
}
/// True for the OPC UA numeric built-in types (the integer + floating families).
private static bool IsNumeric(BuiltInType t) => t is
BuiltInType.SByte or BuiltInType.Byte or BuiltInType.Int16 or BuiltInType.UInt16 or
BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or
BuiltInType.Float or BuiltInType.Double;
/// The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
/// against — exactly the types can emit for a writable equipment node
/// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is
/// deferred to the SDK so a coercible write (Int32→Enumeration, etc.) is never false-rejected.
private static bool IsCheckableExpectedType(BuiltInType t) =>
IsNumeric(t) || t is BuiltInType.Boolean or BuiltInType.String or BuiltInType.DateTime or BuiltInType.ByteString;
///
/// 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 ) so it is unit-testable without an SDK
/// server.
///
/// The device-write outcome routed back by the gateway.
/// The node's current Value at revert time.
/// The value the SDK optimistically applied on the write.
/// true to revert (failed outcome and node unchanged since the optimistic write);
/// false on success, or when a poll has already moved the node off the optimistic value.
internal static bool ShouldRevert(NodeWriteOutcome outcome, object? currentNodeValue, object? optimisticValue) =>
!outcome.Success && Equals(currentNodeValue, optimisticValue);
///
/// Off-Lock continuation body for the write-outcome self-correction: re-takes Lock and, when
/// says so, surfaces the device-write rejection to subscribed clients in three
/// ways, then leaves the node holding its captured pre-write value/status (same node-update shape as
/// ). 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.
///
/// -
/// Item B — Bad-quality blip. Before restoring the prior value, the node is published
/// once holding the (still-applied) optimistic value but with StatusCode
/// BadDeviceFailure, then published again with the prior value/status restored. A
/// value-subscribed client therefore sees the rejection (a Bad quality) rather than the value
/// silently snapping back. Caveat: the two ClearChangeMasks calls happen
/// back-to-back within one server publishing interval, so the SDK's monitored-item queue may
/// COALESCE them — a slow / queue-size-1 subscriber can see only the final restored value and
/// miss the transient Bad blip. The blip is a best-effort live signal; the
/// raised below (Item C) is the reliable, durable record
/// of the rejected write.
///
/// -
/// Item C — AuditWriteUpdateEvent. A Part 8 is
/// raised through the SDK so an auditing client gets a
/// durable record of the rejected write (OldValue = prior, NewValue = the attempted optimistic
/// value, the boolean AuditEvent Status = false ⇒ failed, and the device's
/// .Reason carried in the event Message). It is reported
/// OUTSIDE Lock (the event-state is built under Lock, then reported after release) to keep
/// the lock hold short and avoid any re-entrancy risk via the server's event path. Auditing being
/// disabled / no subscribers is handled gracefully — ReportEvent simply reaches no monitored
/// items, and any failure is swallowed (logged to the SDK trace) so the revert is never broken by it.
///
///
/// Silent value-wise — this node manager carries no logger; the gateway logs the underlying write failure
/// and the SDK trace captures any audit-report failure.
///
/// The string id of the written variable node.
/// The device-write outcome routed back by the gateway.
/// The value the SDK optimistically applied on the write.
/// The node's real value captured before the optimistic write.
/// The node's real status captured before the optimistic write.
/// The writing principal's user-id string (the identity's DisplayName), threaded
/// from to populate the audit event's ClientUserId; null when unknown.
private void RevertOptimisticWriteIfNeeded(
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
string? clientUserId)
{
// Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released.
AuditWriteUpdateEventState? auditEvent = null;
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
// Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore
// the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's
// node-update shape). See the method remarks for the queue-coalescing caveat.
variable.StatusCode = StatusCodes.BadDeviceFailure;
variable.Timestamp = DateTime.UtcNow;
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip
variable.Value = priorValue;
variable.StatusCode = priorStatus;
variable.Timestamp = DateTime.UtcNow;
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior
// Item C: build the audit event-state while we hold the node reference + Lock, but DON'T report yet.
// Guarded like ReportAuditEvent: the revert above has ALREADY happened, so a surprise from the SDK
// child-population path (e.g. a SetChildValue on a null OldValue/NewValue) must be swallowed + logged,
// never thrown out of this fire-and-forget continuation.
try
{
auditEvent = BuildWriteFailureAuditEvent(variable, outcome, optimisticValue, priorValue, clientUserId);
}
catch (Exception ex)
{
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogError(ex, "OtOpcUaNodeManager: failed to build AuditWriteUpdateEvent for {0}", nodeId);
#pragma warning restore CS0618
auditEvent = null;
}
}
// Report OUTSIDE Lock — keeps the hold short and sidesteps any re-entrancy through the server event path.
if (auditEvent is not null) ReportAuditEvent(auditEvent);
}
///
/// Item C — build (but do not report) a Part 8 recording a
/// rejected device write on . The caller holds Lock (the node is read
/// here); reporting happens after Lock release. The standard AuditEvent envelope (EventId, EventType,
/// Time/ReceiveTime, ServerId, Severity, Message, SourceNode/SourceName, Status, ActionTimeStamp) is
/// stamped by the SDK's helper; this method then fills the write-specific fields
/// (AttributeId = Value, IndexRange = empty, OldValue = prior, NewValue = attempted) plus ClientUserId.
///
/// The written node (the audit event's SourceNode).
/// The failed device-write outcome (its Reason goes into the event Message + Status).
/// The value the client attempted (the audit NewValue).
/// The node's real pre-write value (the audit OldValue).
/// The writing principal's user-id string; null when unknown.
/// A populated, unreported .
private AuditWriteUpdateEventState BuildWriteFailureAuditEvent(
BaseDataVariableState variable, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue,
string? clientUserId)
{
var reason = string.IsNullOrEmpty(outcome.Reason) ? "device write rejected" : outcome.Reason!;
var audit = new AuditWriteUpdateEventState(null);
// Initialize stamps EventId (fresh GUID bytes), EventType, Time/ReceiveTime, ServerId, Severity, Message,
// SourceNode/SourceName (from `variable`), Status and ActionTimeStamp. The AuditEvent `Status` field is a
// PropertyState (true=succeeded / false=failed), so the failed device write is recorded as `false`;
// the device's textual Reason is carried in the Message (the StatusCode itself has no audit-field home).
audit.Initialize(
SystemContext,
source: variable,
severity: EventSeverity.Medium,
message: new LocalizedText($"Inbound write rejected by device: {reason}"),
status: false,
actionTimestamp: DateTime.UtcNow);
// Write-specific fields (AuditWriteUpdateEventType). AttributeId = Value; IndexRange empty (full write);
// OldValue = the real pre-write value; NewValue = the value the client attempted. The AuditWriteUpdate
// child PropertyStates are NOT instantiated by Initialize — SetChildValue lazily creates + sets them
// (verified against the 1.5.378 SDK; it tolerates a null value, creating the child with Value=null).
audit.SetChildValue(SystemContext, BrowseNames.AttributeId, (uint)Attributes.Value, false);
audit.SetChildValue(SystemContext, BrowseNames.IndexRange, string.Empty, false);
audit.SetChildValue(SystemContext, BrowseNames.OldValue, priorValue!, false);
audit.SetChildValue(SystemContext, BrowseNames.NewValue, optimisticValue!, false);
// Standard AuditEvent client-identity fields. ClientUserId is the writing principal (threaded from the
// handler); ClientAuditEntryId carries the SDK context's audit entry id when present.
if (clientUserId is not null) audit.SetChildValue(SystemContext, BrowseNames.ClientUserId, clientUserId, false);
var auditEntryId = SystemContext.AuditEntryId;
if (!string.IsNullOrEmpty(auditEntryId))
audit.SetChildValue(SystemContext, BrowseNames.ClientAuditEntryId, auditEntryId, false);
return audit;
}
///
/// Item C — report a built audit event through the SDK server, guarding against auditing being disabled
/// / no subscribers / a transient server-event-path failure. A failure here MUST NOT break the revert
/// that already happened, so it is swallowed and logged to the SDK trace (this node manager has no
/// ILogger) rather than propagated. Reported with the node manager's SystemContext, which
/// is what the alarm event path uses too.
///
/// The populated audit event-state to report.
private void ReportAuditEvent(AuditWriteUpdateEventState auditEvent)
{
try
{
Server.ReportEvent(SystemContext, auditEvent);
}
catch (Exception ex)
{
// Auditing disabled / no monitored items / server shutting down ⇒ ReportEvent may no-op or throw;
// either way the revert already stands. Surface a recurring failure in the SDK trace, don't rethrow.
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report AuditWriteUpdateEvent for {0}", auditEvent.SourceNode?.Value);
#pragma warning restore CS0618
}
}
/// Map our domain AlarmType 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 remarks).
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),
};
/// Promote to and
/// register it as a root notifier (idempotent — guarded by ) so the
/// alarm condition has a notifier path to the Server object for T16's event propagation.
///
/// Phase C (Task 4): when a real historian is wired at promotion time (the source is NOT the
/// ), the folder ALSO gets the
/// bit OR-ed in (keeping SubscribeToEvents) and registers
/// its NodeId identifier as an event-history source so the 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 StartAsync — 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 re-promotes it (acceptable, documented). The
/// HistoryRead bit + source registration happen inside the same first-time
/// block so the idempotency guard covers them too.
///
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);
}
/// Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the
/// enum buckets the SDK's SetSeverity expects.
private static EventSeverity MapSeverity(int severity) => severity switch
{
< 200 => EventSeverity.Low,
< 400 => EventSeverity.MediumLow,
< 600 => EventSeverity.Medium,
< 800 => EventSeverity.MediumHigh,
_ => EventSeverity.High,
};
///
/// Ensure a folder node exists at with the given display
/// name, parented under (or the namespace root when null).
/// #85 — used by 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.
///
/// The node identifier of the folder.
/// The node identifier of the parent folder; null to use the namespace root.
/// The display name of the folder.
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;
}
}
///
/// Ensure a Variable node exists at parented under
/// (or root when null). Initial value=null, quality=Bad,
/// timestamp=epoch — 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 _variables.ContainsKey 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
/// (which the planner triggers on an equipment-tag delta).
///
/// The node identifier of the variable.
/// The node identifier of the parent folder; null to use the namespace root.
/// The display name of the variable.
/// The OPC UA data type name (e.g., "Boolean", "Int32", "String").
/// When true the node is created CurrentReadWrite (an authored
/// ReadWrite equipment tag) and the inbound-write handler is attached
/// to its OnWriteValue (Task 11) so a client write gates on the WriteOperate role + routes
/// to the backing driver; when false it stays CurrentRead (read-only) with no write handler.
/// Phase C: null ⇒ the node is NOT historized (Historizing=false, no
/// HistoryRead bit, not registered). Non-null ⇒ the node is created Historizing with the
/// HistoryRead access bit OR-ed into both AccessLevel and UserAccessLevel, and the
/// (already default-resolved) tagname is registered in the NodeId→tagname map the HistoryRead override
/// resolves against.
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!;
}
}
/// 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).
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,
};
/// 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.
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.
// ---------------------------------------------------------------------------------------------
///
/// Serve a HistoryRead-Raw request over the pre-filtered historized variable handles, dispatching
/// each to . Modified-history reads
/// (IsReadModified) are unsupported — we don't serve a modified-value history surface.
///
/// Raw is the only arm that pages server-side: ReadRawModifiedDetails carries a client
/// count cap (NumValuesPerNode), so a page that returns exactly that many samples MAY
/// have more behind it ⇒ a time-based continuation point is emitted (see
/// ). An inbound continuation point on a node resumes its stored
/// read. NumValuesPerNode == 0 ("all values") never pages.
///
///
protected override void HistoryReadRawModified(
ServerSystemContext context,
ReadRawModifiedDetails details,
TimestampsToReturn timestampsToReturn,
IList nodesToRead,
IList results,
IList errors,
List nodesToProcess,
IDictionary cache)
{
var session = context.OperationContext?.Session;
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;
}
ServeRawPaged(
handle, session, nodesToRead, results, errors,
details.StartTime, details.EndTime, details.NumValuesPerNode);
}
}
///
/// Serve a HistoryRead-Processed request, mapping each node's per-node aggregate NodeId (from the
/// parallel AggregateType collection — the base guarantees it is the same length as
/// nodesToRead) to a and dispatching to
/// . An unknown aggregate yields
/// BadAggregateNotSupported for that node. Single-shot (no continuation point):
/// ReadProcessedDetails carries no client count cap — the bucket count is deterministic
/// (window / interval) — so there is no "full page" signal to page on.
///
protected override void HistoryReadProcessed(
ServerSystemContext context,
ReadProcessedDetails details,
TimestampsToReturn timestampsToReturn,
IList nodesToRead,
IList results,
IList errors,
List nodesToProcess,
IDictionary cache)
{
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
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;
}
// Processed is SINGLE-SHOT (no continuation point). Unlike Raw, ReadProcessedDetails carries
// NO client count cap (NumValuesPerNode) — the bucket count is deterministic (window / interval)
// and the single-shot backend returns every bucket in one read, so there is no "full page ⇒
// maybe more" signal to page on. Returning the complete aggregate result with a null CP is
// spec-conformant (OPC UA Part 11 lets a server return all available data in one response).
ServeNode(handle, results, errors, (source, tagname) => source.ReadProcessedAsync(
tagname,
details.StartTime,
details.EndTime,
interval,
aggregate.Value,
CancellationToken.None));
}
}
///
/// Serve a HistoryRead-AtTime request, dispatching the requested timestamps to
/// . Single-shot (no continuation point):
/// AtTime carries no client count cap — the request IS the timestamp list and the result is
/// exactly one sample per requested timestamp — so there is no "full page" signal to page on.
///
protected override void HistoryReadAtTime(
ServerSystemContext context,
ReadAtTimeDetails details,
TimestampsToReturn timestampsToReturn,
IList nodesToRead,
IList results,
IList errors,
List nodesToProcess,
IDictionary cache)
{
// Snapshot the requested timestamps once — the same list is read for every node.
var timestamps = details.ReqTimes?.ToList() ?? new List();
foreach (var handle in nodesToProcess)
{
ServeNode(handle, results, errors, (source, tagname) => source.ReadAtTimeAsync(
tagname,
timestamps,
CancellationToken.None));
}
}
///
/// 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
/// : a miss ⇒ BadHistoryOperationUnsupported (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
/// for the folder's source name and projects each into a
/// per the request's event filter. Like the Raw/Processed/AtTime
/// arms this is NOT invoked under the node-manager Lock, 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.
///
protected override void HistoryReadEvents(
ServerSystemContext context,
ReadEventDetails details,
TimestampsToReturn timestampsToReturn,
IList nodesToRead,
IList results,
IList errors,
List nodesToProcess,
IDictionary 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 };
}
}
}
/// Clamp a request cap to a non-negative for the
/// event-read surface (whose maxEvents is signed): values above
/// saturate to .
/// The uint cap from the request (NumValuesPerNode).
/// The clamped non-negative int.
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
///
/// Project a sequence of s into an SDK —
/// one per event, each carrying the requested
/// ' fields in select-clause order (see
/// ).
///
/// The historian's event rows.
/// The request's event-filter select clauses (the fields to emit, in order).
/// The populated SDK .
private static HistoryEvent ProjectEvents(
IReadOnlyList 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 };
}
///
/// Project one field requested by a select
/// into a . Mapping is by the operand's BrowsePath LEAF QualifiedName name
/// (case-sensitive, per the OPC UA BaseEventType field names) against the
/// shape:
/// EventId→ByteString, SourceName→String, Time→DateTime,
/// ReceiveTime→DateTime, Message→LocalizedText, Severity→UInt16. Any other
/// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
/// — spec-conformant: a field the server can't supply is null.
///
/// The source event row.
/// The select-clause operand naming the field to project.
/// The projected variant, or for an unsupported field.
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,
};
}
/// Extract the leaf (last) from a select operand's BrowsePath,
/// or null when the BrowsePath is null/empty.
/// The select-clause operand.
/// The leaf field name, or null for an empty BrowsePath.
private static string? LeafFieldName(SimpleAttributeOperand operand)
{
var path = operand.BrowsePath;
if (path is null || path.Count == 0) return null;
return path[^1].Name;
}
///
/// 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 lookup; the resolved tagname is passed directly
/// to , removing any risk of a second concurrent lookup on the same key.
/// A node we don't recognise as historized maps to BadHistoryOperationUnsupported
/// (shouldn't normally reach us, since the base only hands us nodes with the HistoryRead access
/// bit, but we guard explicitly). The 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.
///
/// The pre-filtered node handle to serve; handle.Index indexes results/errors.
/// The service-level results list to fill at handle.Index.
/// The service-level errors list to fill at handle.Index.
/// Invokes the resolved data-source read with the resolved tagname; only called
/// once the tagname is confirmed present.
private void ServeNode(
NodeHandle handle,
IList results,
IList errors,
Func> 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),
// Single-shot arms (Processed / AtTime) never page — the backend returns the complete
// result in one read (no client count cap to detect a "full page" against), so no
// continuation point. Raw pages via ServeRawPaged, not this helper.
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;
}
}
///
/// Serve one historized variable handle for a HistoryRead-Raw request WITH server-side
/// continuation-point paging. The single-shot Wonderware backend does not page, so paging is
/// synthesised time-based:
///
/// - Fresh read (no inbound continuation point): read the window from
/// details.StartTime to capped at
/// . If the page comes back FULL (exactly the cap, and the
/// cap is > 0), store a resume cursor and emit a continuation point.
/// - Resume read (inbound continuation point present): take the stored cursor, read
/// the next page from the boundary forward, trim already-emitted boundary ties, and emit a
/// FRESH continuation point only if THIS page is also full — else null (done).
///
/// The resume cursor is tie-safe (see /
/// ): the next page resumes from the boundary
/// timestamp INCLUSIVE and drops the head ties already returned, so samples sharing the boundary
/// SourceTimestamp are neither duplicated nor skipped. Continuation points live in
/// — session-bound + capped in production. Per-node error
/// isolation matches : a backend throw / an unknown continuation point
/// becomes a Bad status for THIS node only and never throws out of the batch.
///
/// The pre-filtered node handle; handle.Index indexes results/errors.
/// The session the read runs under (null on the session-less in-process path).
/// The per-node read list; nodesToRead[handle.Index].ContinuationPoint
/// carries the inbound continuation point (non-null ⇒ a resume read).
/// The service-level results list to fill at handle.Index.
/// The service-level errors list to fill at handle.Index.
/// The request window's (inclusive) lower bound, used for a fresh read.
/// The (inclusive) upper bound of the read window; unchanged across pages.
/// The client's per-page cap; 0 means "all values, no paging".
private void ServeRawPaged(
NodeHandle handle,
ISession? session,
IList nodesToRead,
IList results,
IList errors,
DateTime startTimeUtc,
DateTime endUtc,
uint numValuesPerNode)
{
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
try
{
DateTime startUtc;
var boundarySkip = 0;
string tagname;
if (inboundCp is { Length: > 0 })
{
// Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒
// BadContinuationPointInvalid for THIS node.
var state = _historyContinuationStore.TryTake(session, inboundCp);
if (state is null)
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
return;
}
tagname = state.Tagname;
startUtc = state.NextStartUtc;
boundarySkip = state.BoundarySkipCount;
endUtc = state.EndUtc;
numValuesPerNode = state.NumValuesPerNode;
}
else
{
// Fresh read: resolve the node's historian tagname (as ServeNode does).
var idString = handle.NodeId.Identifier?.ToString();
if (idString is null || !TryGetHistorizedTagname(idString, out var resolved) || resolved is null)
{
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
return;
}
tagname = resolved;
startUtc = startTimeUtc;
}
// HistoryRead is NOT under the node-manager Lock — block-bridging the async source is safe.
var sourceResult = HistorianDataSource
.ReadRawAsync(tagname, startUtc, endUtc, numValuesPerNode, CancellationToken.None)
.GetAwaiter().GetResult();
var backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, numValuesPerNode);
// On a resume read, drop the boundary ties already returned on the prior page.
var samples = inboundCp is { Length: > 0 }
? HistoryPaging.TrimBoundaryDuplicates(sourceResult.Samples, startUtc, boundarySkip)
: sourceResult.Samples;
// Degenerate tie cluster: a resume read returned a FULL backend page that the boundary-tie trim
// emptied entirely. That can only happen when more than NumValuesPerNode samples share the resume
// boundary timestamp — a tie cluster larger than the page cap. The fixed-(start,end,cap) backend
// can only ever return the first `cap` of those ties, so a (timestamp, skip) cursor can never
// advance past the cluster. Fail LOUDLY for this node rather than silently truncate to GoodNoData
// (which would permanently drop the un-emitted ties). The operator's remedy is a larger
// NumValuesPerNode; see docs/Historian.md "Paging limitation".
if (inboundCp is { Length: > 0 } && backendFull && samples.Count == 0)
{
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogError(
"OtOpcUaNodeManager: HistoryReadRaw paging stalled — tie cluster at {0:O} for tag '{1}' " +
"exceeds NumValuesPerNode={2}; cannot page past it. Increase NumValuesPerNode.",
startUtc, tagname, numValuesPerNode);
#pragma warning restore CS0618
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
return;
}
// The "full page" test is against the RAW backend count (before trimming): the backend honoured
// the cap, so a full backend page ⇒ there may be more even if we trimmed some boundary ties.
var historyData = ToHistoryDataFromSamples(samples);
byte[]? outboundCp = null;
if (backendFull && samples.Count > 0)
{
HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip);
var nextState = new HistoryContinuationState(
tagname, nextStart, endUtc, skip, numValuesPerNode);
// Save may return null (no session on this request) ⇒ degrade to single-shot for this node.
// Built AFTER historyData so a failure projecting samples can never orphan a stored cursor.
outboundCp = _historyContinuationStore.Save(session, nextState);
}
results[handle.Index] = new SdkHistoryReadResult
{
// No samples ⇒ GoodNoData (the node is historized, the window just held no data). With the
// degenerate-cluster guard above, a resumed empty page now only means the window/cluster is
// genuinely drained — never silent data loss.
StatusCode = samples.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyData),
ContinuationPoint = outboundCp,
};
errors[handle.Index] = ServiceResult.Good;
}
catch (Exception ex)
{
// One node's backend failure must not throw out of the batch — Bad for THIS node only.
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogError(ex, "OtOpcUaNodeManager: HistoryReadRaw (paged) failed for node {0}", handle.NodeId);
#pragma warning restore CS0618
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
}
}
///
/// Drop the resume state for any continuation points the client asked to release
/// (releaseContinuationPoints == true) and return WITHOUT reading data, per OPC UA Part 4.
/// The base dispatcher routes a release-only HistoryRead here (it never reaches the per-details
/// arms), so this is the single place that must free Raw's stored cursors. Each handle's released
/// point is nodesToRead[handle.Index].ContinuationPoint; releasing an unknown / null point
/// is a harmless no-op. Errors are left Good (the base pre-seeds them) — a release does not fail.
///
protected override void HistoryReleaseContinuationPoints(
ServerSystemContext context,
IList nodesToRead,
IList errors,
List nodesToProcess,
IDictionary cache)
{
var session = context.OperationContext?.Session;
foreach (var handle in nodesToProcess)
{
var cp = nodesToRead[handle.Index].ContinuationPoint;
if (cp is { Length: > 0 })
{
_historyContinuationStore.Release(session, cp);
}
errors[handle.Index] = ServiceResult.Good;
}
}
/// Project a plain sample list into an SDK (the paged Raw path
/// works on a trimmed rather than a whole ).
/// The samples to project (already trimmed of boundary duplicates).
/// The populated SDK .
private static HistoryData ToHistoryDataFromSamples(IReadOnlyList samples)
{
var values = new DataValueCollection(samples.Count);
foreach (var sample in samples) values.Add(ToSdkDataValue(sample));
return new HistoryData { DataValues = values };
}
///
/// Map an OPC UA Part 13 standard-aggregate function NodeId to our
/// . Returns null for any aggregate we don't serve so
/// the caller can surface BadAggregateNotSupported.
///
/// The per-node aggregate-function NodeId from the request.
/// The mapped aggregate, or null when unsupported.
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;
}
///
/// Project the historian source's (Core.Abstractions DTO) into an
/// SDK — one per ,
/// carrying value / status / source+server timestamps. A null SourceTimestamp maps to
/// DateTime.MinValue (the SDK's "unset" sentinel for that field).
///
/// The data source's read result.
/// The populated SDK .
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 };
}
/// Convert one driver-agnostic to an SDK
/// , mirroring value / status code / source + server timestamps.
/// The source sample.
/// The equivalent SDK data value.
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,
};
///
public override void CreateAddressSpace(IDictionary> 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();
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,
};
}