using System.Globalization;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
using ZB.MOM.WW.OtOpcUa.Server.History;
using ZB.MOM.WW.OtOpcUa.Server.Security;
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
// point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We
// assign driver-side results to an explicitly-aliased local and construct only the service
// type in the overrides below.
using OpcHistoryReadResult = Opc.Ua.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
///
/// Concrete that materializes the driver's address space
/// into OPC UA nodes. Implements itself so
/// GenericDriverNodeManager.BuildAddressSpaceAsync can stream nodes directly into the
/// OPC UA server's namespace. PR 15's MarkAsAlarmCondition hook creates a sibling
/// node per alarm-flagged variable; subsequent driver
/// OnAlarmEvent pushes land through the returned sink to drive Activate /
/// Acknowledge / Deactivate transitions.
///
///
/// Read / Subscribe / Write are routed to the driver's capability interfaces — the node
/// manager holds references to , , and
/// when present. Nodes with no driver backing (plain folders) are
/// served directly from the internal PredefinedNodes table.
///
public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
{
private readonly IDriver _driver;
private readonly IReadable? _readable;
private readonly IWritable? _writable;
private readonly IPerCallHostResolver? _hostResolver;
private readonly CapabilityInvoker _invoker;
private readonly ILogger _logger;
// Per-variable idempotency flag populated during Variable() registration from
// DriverAttributeInfo.WriteIdempotent. Drives ExecuteWriteAsync's retry gating in
// OnWriteValue; absent entries default to false (decisions #44, #45, #143).
private readonly Dictionary _writeIdempotentByFullRef = new(StringComparer.OrdinalIgnoreCase);
/// The driver whose address space this node manager exposes.
public IDriver Driver => _driver;
private FolderState? _driverRoot;
private readonly Dictionary _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
// NodeId-identifier (string) → driver FullReference. OPC UA Part 3 §5.2.2 requires NodeIds
// to be immutable across a node's lifetime, which precludes minting them from the driver's
// native address (a backend rename would change the NodeId and break every subscribed
// client). NodeIds are therefore path-based (`{driverId}/{folder-path}/{browseName}`) and
// this map recovers the driver-side FullReference for read/write/history dispatch. The
// fallback in lookups preserves the pre-refactor behaviour for any caller that still
// registered a variable via a FullRef-shaped NodeId.
private readonly Dictionary _fullRefByNodeId = new(StringComparer.Ordinal);
// PR 26: SecurityClassification per variable, populated during Variable() registration.
// OnWriteValue looks up the classification here to gate the write by the session's roles.
// Drivers never enforce authz themselves — the classification is discovery-time metadata
// only (feedback_acl_at_server_layer.md).
private readonly Dictionary _securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
// Active building folder — set per Folder() call so Variable() lands under the right parent.
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
// returns a child builder per Folder call and the caller threads nesting through those references.
private FolderState _currentFolder = null!;
// Phase 6.2 Stream C follow-up — optional gate + scope resolver. When both are null
// the old pre-Phase-6.2 dispatch path runs unchanged (backwards compat for every
// integration test that constructs DriverNodeManager without the gate). When wired,
// OnReadValue / OnWriteValue / HistoryRead all consult the gate before the invoker call.
private readonly AuthorizationGate? _authzGate;
private readonly NodeScopeResolver? _scopeResolver;
// Phase 7 Stream G follow-up — per-variable NodeSourceKind so OnReadValue can dispatch
// to the VirtualTagEngine / ScriptedAlarmEngine instead of the driver's IReadable per
// ADR-002. Absent entries default to Driver so drivers registered before Phase 7
// keep working unchanged.
private readonly Dictionary _sourceByFullRef = new(StringComparer.OrdinalIgnoreCase);
private readonly IReadable? _virtualReadable;
private readonly IReadable? _scriptedAlarmReadable;
// PR 1.3 — server-level history routing. When non-null + a source is registered for
// the requested full reference, the four HistoryRead* overrides dispatch through the
// router. Otherwise we fall back to the legacy `_driver as IHistoryProvider` path
// wrapped in a thin adapter, so existing tests and drivers that still implement
// IHistoryProvider directly keep working until PR 1.W flips DI to register the
// legacy path inside the router.
private readonly IHistoryRouter? _historyRouter;
private LegacyDriverHistoryAdapter? _legacyHistoryAdapter;
// PR 2.3 — server-level alarm-condition state machine. When non-null, every
// MarkAsAlarmCondition call also registers the condition with the service so the
// server runs the Active/Acknowledged/Inactive transitions itself instead of
// relying on the driver's own tracker. _conditionSinks maps conditionId →
// ConditionSink so service-raised transitions reach the right OPC UA AlarmCondition
// sibling. Legacy IAlarmSource path keeps working in parallel until PR 7.2.
private readonly AlarmConditionService? _alarmService;
private readonly Dictionary _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
private EventHandler? _alarmTransitionHandler;
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, CapabilityInvoker invoker, ILogger logger,
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null,
IHistoryRouter? historyRouter = null,
AlarmConditionService? alarmService = null)
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
{
_driver = driver;
_readable = driver as IReadable;
_writable = driver as IWritable;
_hostResolver = driver as IPerCallHostResolver;
_invoker = invoker;
_authzGate = authzGate;
_scopeResolver = scopeResolver;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_historyRouter = historyRouter;
_alarmService = alarmService;
_logger = logger;
if (_alarmService is not null)
{
_alarmTransitionHandler = OnAlarmServiceTransition;
_alarmService.TransitionRaised += _alarmTransitionHandler;
}
}
///
/// Routes to the matching
/// registered during MarkAsAlarmCondition. Translates
/// into the legacy
/// shape the existing sink consumes — the sink's switch on AlarmType string
/// ("Active" / "Acknowledged" / "Inactive") is preserved so PR 2.3 doesn't perturb the
/// OPC UA Part 9 state mapping. Stale transitions for an untracked condition are
/// silently dropped.
///
private void OnAlarmServiceTransition(object? sender, AlarmConditionTransition t)
{
ConditionSink? sink;
lock (Lock)
{
_conditionSinks.TryGetValue(t.ConditionId, out sink);
}
if (sink is null) return;
var transitionName = t.Transition switch
{
AlarmStateTransition.Active => "Active",
AlarmStateTransition.Acknowledged => "Acknowledged",
AlarmStateTransition.Inactive => "Inactive",
_ => "Unknown",
};
sink.OnTransition(new AlarmEventArgs(
SubscriptionHandle: null!,
SourceNodeId: t.ConditionId,
ConditionId: t.ConditionId,
AlarmType: transitionName,
Message: t.Description ?? t.ConditionId,
Severity: MapPriorityToSeverity(t.Priority),
SourceTimestampUtc: t.AtUtc));
}
///
/// Maps the integer priority Galaxy carries on .Priority (typically 1-1000) to
/// the four-bucket the OPC UA condition sibling consumes.
/// Mirrors the legacy GalaxyProxyDriver.MapSeverity bucketing.
///
private static AlarmSeverity MapPriorityToSeverity(int priority) => priority switch
{
<= 250 => AlarmSeverity.Low,
<= 500 => AlarmSeverity.Medium,
<= 800 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
///
/// Default bound to a driver's .
/// Writes the operator comment to the alarm's .AckMsg sub-attribute via the same
/// dispatcher OnWriteValue uses so the resilience pipeline gates the call. Returns
/// false when the driver doesn't implement — alarms whose
/// drivers can't write are tracked but cannot be acknowledged through this path.
///
private sealed class DriverWritableAcknowledger(
IWritable? writable, CapabilityInvoker invoker, string driverInstanceId) : IAlarmAcknowledger
{
public async Task WriteAckMessageAsync(
string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
{
if (writable is null || string.IsNullOrEmpty(ackMsgWriteRef)) return false;
var request = new DriverWriteRequest(
FullReference: ackMsgWriteRef,
Value: comment ?? string.Empty);
try
{
// Ack writes are not idempotent — repeating an ack would re-trigger the
// driver-side acknowledgement state change. False matches the OnWriteValue
// default path for non-Idempotent attributes.
var results = await invoker.ExecuteWriteAsync(
driverInstanceId,
isIdempotent: false,
async ct => await writable.WriteAsync(new[] { request }, ct).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
return results.Count > 0 && results[0].StatusCode == 0;
}
catch
{
return false;
}
}
}
///
/// PR B.3 — preferred for drivers that implement
/// (today: Galaxy via the gateway-side AcknowledgeAlarm
/// RPC). Routes the operator comment through the driver's native ack API, which
/// preserves operator-comment fidelity end-to-end (the value-driven sub-attribute
/// fallback collapses the comment into a single string write).
///
private sealed class DriverAlarmSourceAcknowledger(
IAlarmSource alarmSource,
string conditionId,
ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker alarmInvoker) : IAlarmAcknowledger
{
public async Task WriteAckMessageAsync(
string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
{
// ackMsgWriteRef is unused on this path — the driver's IAlarmSource.AcknowledgeAsync
// routes the ack against the alarm condition itself, not against the
// sub-attribute. ConditionId carries the alarm full reference; SourceNodeId
// is left empty since the gateway only addresses by full reference.
// _ = alarmSource keeps the analyzer-required reference visible without an
// unwrapped call — the actual ack runs through the AlarmSurfaceInvoker which
// wires the AlarmAcknowledge resilience pipeline (no-retry per decision #143).
_ = alarmSource;
try
{
await alarmInvoker.AcknowledgeAsync(
new[]
{
new AlarmAcknowledgeRequest(
SourceNodeId: string.Empty,
ConditionId: conditionId,
Comment: comment ?? string.Empty),
},
cancellationToken).ConfigureAwait(false);
return true;
}
catch
{
return false;
}
}
}
///
/// Detach from the alarm service before the base disposes. The service is shared across
/// drivers, so leaking the handler keeps a dead DriverNodeManager pinned in memory and
/// dispatches transitions to a sink that's no longer wired to any OPC UA node.
///
protected override void Dispose(bool disposing)
{
if (disposing && _alarmService is not null && _alarmTransitionHandler is not null)
{
_alarmService.TransitionRaised -= _alarmTransitionHandler;
_alarmTransitionHandler = null;
}
base.Dispose(disposing);
}
protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();
///
/// Resolve the host name fed to the Phase 6.1 CapabilityInvoker for a per-tag call.
/// Multi-host drivers that implement get their
/// per-PLC isolation (decision #144); single-host drivers + drivers that don't
/// implement the resolver fall back to the DriverInstanceId — preserves existing
/// Phase 6.1 pipeline-key semantics for those drivers.
///
private string ResolveHostFor(string fullReference)
{
if (_hostResolver is null) return _driver.DriverInstanceId;
var resolved = _hostResolver.ResolveHost(fullReference);
return string.IsNullOrWhiteSpace(resolved) ? _driver.DriverInstanceId : resolved;
}
public override void CreateAddressSpace(IDictionary> externalReferences)
{
lock (Lock)
{
_driverRoot = new FolderState(null)
{
SymbolicName = _driver.DriverInstanceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = new NodeId(_driver.DriverInstanceId, NamespaceIndex),
BrowseName = new QualifiedName(_driver.DriverInstanceId, NamespaceIndex),
DisplayName = new LocalizedText(_driver.DriverInstanceId),
// Driver root is the conventional event notifier for HistoryReadEvents — clients
// request alarm history by targeting it and the node manager routes through
// IHistoryProvider.ReadEventsAsync. SubscribeToEvents is also set so live-event
// subscriptions (Alarm & Conditions) can point here in a future PR; today the
// alarm events are emitted by per-variable AlarmConditionState siblings but a
// "subscribe to all events from this driver" path would use this notifier.
EventNotifier = (byte)(EventNotifiers.SubscribeToEvents | EventNotifiers.HistoryRead),
};
// Link under Objects folder so clients see the driver subtree at browse root.
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var references))
{
references = new List();
externalReferences[ObjectIds.ObjectsFolder] = references;
}
references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, _driverRoot.NodeId));
AddPredefinedNode(SystemContext, _driverRoot);
_currentFolder = _driverRoot;
}
}
// ------- IAddressSpaceBuilder implementation (PR 15 contract) -------
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
lock (Lock)
{
var folder = new FolderState(_currentFolder)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(displayName),
};
_currentFolder.AddChild(folder);
AddPredefinedNode(SystemContext, folder);
return new NestedBuilder(this, folder);
}
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
lock (Lock)
{
// Path-based NodeId per OPC UA Part 3 §5.2.2 (NodeIds MUST NOT change across the
// node's lifetime). Shape `{driverId}/{folder-path}/{browseName}` is stable across
// driver-side renames of the underlying FullReference + keeps the identifier
// self-describing against the browse tree.
var nodeKey = $"{_currentFolder.NodeId.Identifier}/{browseName}";
var v = new BaseDataVariableState(_currentFolder)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
NodeId = new NodeId(nodeKey, NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(displayName),
DataType = MapDataType(attributeInfo.DriverDataType),
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
// Historized attributes get the HistoryRead access bit so the stack dispatches
// incoming HistoryRead service calls to this node. Without it the base class
// returns BadHistoryOperationUnsupported before our per-kind hook ever runs.
// HistoryWrite isn't granted — history rewrite is a separate capability the
// driver doesn't support today.
AccessLevel = (byte)(AccessLevels.CurrentReadOrWrite
| (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)),
UserAccessLevel = (byte)(AccessLevels.CurrentReadOrWrite
| (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)),
Historizing = attributeInfo.IsHistorized,
};
_currentFolder.AddChild(v);
AddPredefinedNode(SystemContext, v);
_variablesByFullRef[attributeInfo.FullName] = v;
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
_writeIdempotentByFullRef[attributeInfo.FullName] = attributeInfo.WriteIdempotent;
_sourceByFullRef[attributeInfo.FullName] = attributeInfo.Source;
_fullRefByNodeId[nodeKey] = attributeInfo.FullName;
v.OnReadValue = OnReadValue;
v.OnWriteValue = OnWriteValue;
return new VariableHandle(this, v, attributeInfo.FullName);
}
}
public void AddProperty(string browseName, DriverDataType dataType, object? value)
{
lock (Lock)
{
var p = new PropertyState(_currentFolder)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.HasProperty,
TypeDefinitionId = VariableTypeIds.PropertyType,
NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(browseName),
DataType = MapDataType(dataType),
ValueRank = ValueRanks.Scalar,
Value = value,
};
_currentFolder.AddChild(p);
AddPredefinedNode(SystemContext, p);
}
}
private ServiceResult OnReadValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
var fullRef = NodeIdToFullRef(node.NodeId);
var source = _sourceByFullRef.TryGetValue(fullRef, out var s) ? s : NodeSourceKind.Driver;
var readable = SelectReadable(source, _readable, _virtualReadable, _scriptedAlarmReadable);
if (readable is null)
{
statusCode = source == NodeSourceKind.Driver ? StatusCodes.BadNotReadable : StatusCodes.BadNotFound;
return ServiceResult.Good;
}
try
{
// Phase 6.2 Stream C — authorization gate. Runs ahead of the invoker so a denied
// read never hits the driver. Returns true in lax mode when identity lacks LDAP
// groups; strict mode denies those cases. See AuthorizationGate remarks.
if (_authzGate is not null && _scopeResolver is not null)
{
var scope = _scopeResolver.Resolve(fullRef);
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.Read, scope))
{
statusCode = StatusCodes.BadUserAccessDenied;
return ServiceResult.Good;
}
}
var result = _invoker.ExecuteAsync(
DriverCapability.Read,
ResolveHostFor(fullRef),
async ct => (IReadOnlyList)await readable.ReadAsync([fullRef], ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
if (result.Count == 0)
{
statusCode = StatusCodes.BadNoData;
return ServiceResult.Good;
}
var snap = result[0];
value = snap.Value;
statusCode = snap.StatusCode;
timestamp = snap.ServerTimestampUtc;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OnReadValue failed for {NodeId}", node.NodeId);
statusCode = StatusCodes.BadInternalError;
}
return ServiceResult.Good;
}
///
/// Phase 6.2 Stream C — Browse gating. Post-filters the reference list the base
/// produced so nodes the session isn't allowed to
/// see disappear from the browse result silently (OPC UA convention: deny = omit,
/// not an error).
///
///
///
/// Each surviving reference is a ; we map its
/// back to the driver-side fullRef the
/// node manager uses as its identifier, resolve a via
/// , and ask
/// whether is allowed for that scope.
///
///
/// References with non-string NodeId identifiers (e.g. stack-synthesized numeric
/// standard-type references) bypass the gate — only driver-materialized nodes
/// key into _variablesByFullRef and carry an authz policy.
///
///
/// Ancestor-visibility implication (a user with Read at Line/Tag should
/// be able to browse Line even without an explicit Browse grant there) is
/// a follow-up that needs the TriePermissionEvaluator to expose a
/// "subtree-has-any-grant" query. For now this filter does a strict point check;
/// admins grant Browse at the right levels in practice.
///
///
public override void Browse(
OperationContext context,
ref ContinuationPoint continuationPoint,
IList references)
{
base.Browse(context, ref continuationPoint, references);
FilterBrowseReferences(references, context.UserIdentity, _authzGate, _scopeResolver);
}
///
/// Phase 6.2 Stream C — Subscribe/MonitoredItems gating. Pre-populates
/// slots with
/// for any monitored-item request whose target node the session can't
/// on, then delegates to the base
/// implementation. The OPC Foundation stack honours pre-populated non-success error
/// slots and skips creation for those items.
///
///
///
/// Decision #153 per-item ACL stamping (so a revoked grant on a running
/// subscription surfaces BadUserAccessDenied on the next publish cycle
/// rather than continuing to stream data) is a follow-up — it needs the
/// subscription layer to plumb (AuthGenerationId, MembershipVersion)
/// through per monitored item + re-evaluate on every publish. The current
/// filter catches creation-time denials, which is the common case.
///
///
public override void CreateMonitoredItems(
OperationContext context,
uint subscriptionId,
double publishingInterval,
TimestampsToReturn timestampsToReturn,
IList itemsToCreate,
IList errors,
IList filterResults,
IList monitoredItems,
ref long globalIdCounter)
{
GateMonitoredItemCreateRequests(
itemsToCreate, errors, context.UserIdentity, _authzGate, _scopeResolver);
base.CreateMonitoredItems(
context, subscriptionId, publishingInterval, timestampsToReturn,
itemsToCreate, errors, filterResults, monitoredItems, ref globalIdCounter);
}
///
/// Pure-function gate for a batch of .
/// Sets [i] to
/// for every slot whose target node's scope the session isn't allowed to
/// on. No-op when
/// or is null (matches the
/// pre-Phase-6.2 no-authz dispatch). Extracted for unit-testability without the
/// full OPC UA server stack.
///
internal static void GateMonitoredItemCreateRequests(
IList itemsToCreate,
IList errors,
IUserIdentity? userIdentity,
AuthorizationGate? gate,
NodeScopeResolver? scopeResolver)
{
if (gate is null || scopeResolver is null) return;
if (itemsToCreate.Count == 0) return;
for (var i = 0; i < itemsToCreate.Count; i++)
{
// Only slots the caller has't already flagged — preserve earlier per-item
// errors (e.g. BadNodeIdUnknown the stack might have filled in).
if (errors[i] is not null && ServiceResult.IsBad(errors[i])) continue;
if (itemsToCreate[i].ItemToMonitor.NodeId.Identifier is not string fullRef) continue;
var scope = scopeResolver.Resolve(fullRef);
if (!gate.IsAllowed(userIdentity, OpcUaOperation.CreateMonitoredItems, scope))
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
}
}
///
/// Phase 6.2 Stream C — method Call gating, covering the three Part 9 alarm methods
/// (Acknowledge / Confirm / Shelve) plus any driver-exposed method nodes. Pre-gates
/// each : denied calls return
/// without running the method.
///
///
///
/// Operation kind per request is inferred from the MethodId — alarm
/// acknowledge / confirm / shelve map to the corresponding
/// values so operator-UI clients can have separate
/// "can acknowledge" vs "can shelve" grants. Everything else (non-alarm method
/// nodes) gates as generic .
///
///
/// Scope is resolved from the ObjectId (the owning node the method lives
/// on, e.g. the alarm condition). Methods on nodes outside the driver's
/// namespace (stack-synthesized standard-type methods with numeric NodeId
/// identifiers) bypass the gate.
///
///
public override void Call(
OperationContext context,
IList methodsToCall,
IList results,
IList errors)
{
GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver);
base.Call(context, methodsToCall, results, errors);
}
///
/// Pure-function gate for a batch of . Pre-populates
/// slots with
/// for calls the session isn't allowed to make. Extracted for unit-testability.
///
internal static void GateCallMethodRequests(
IList methodsToCall,
IList errors,
IUserIdentity? userIdentity,
AuthorizationGate? gate,
NodeScopeResolver? scopeResolver)
{
if (gate is null || scopeResolver is null) return;
if (methodsToCall.Count == 0) return;
for (var i = 0; i < methodsToCall.Count; i++)
{
if (errors[i] is not null && ServiceResult.IsBad(errors[i])) continue;
var request = methodsToCall[i];
if (request.ObjectId.Identifier is not string fullRef) continue;
var scope = scopeResolver.Resolve(fullRef);
var operation = MapCallOperation(request.MethodId);
if (!gate.IsAllowed(userIdentity, operation, scope))
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
}
}
///
/// Maps a method's to the the gate
/// should check. Alarm methods resolve to their specific operation kinds so
/// operator-UI grants can distinguish acknowledge/confirm/shelve; everything else
/// falls through to generic .
///
internal static OpcUaOperation MapCallOperation(NodeId methodId)
{
// Standard Part 9 method ids on AcknowledgeableConditionType. The stack models these
// as ns=0 numeric ids; comparisons are value-based. Shelve is dispatched on the
// ShelvedStateMachine instance's methods — those arrive with per-instance NodeIds
// rather than well-known type NodeIds, so we can't reliably constant-match them
// here. Shelve falls through to OpcUaOperation.Call; the caller can still set a
// permissive Call grant for operators who are allowed to shelve alarms, and
// finer-grained AlarmShelve gating is a follow-up when the method-invocation path
// also carries a "method-role" annotation.
if (methodId == MethodIds.AcknowledgeableConditionType_Acknowledge)
return OpcUaOperation.AlarmAcknowledge;
if (methodId == MethodIds.AcknowledgeableConditionType_Confirm)
return OpcUaOperation.AlarmConfirm;
return OpcUaOperation.Call;
}
///
/// Pure-function filter over a list. Extracted so
/// the Browse-gate policy is unit-testable without standing up the OPC UA server
/// stack. When either the gate or the resolver is null, the list is left
/// untouched — matches the pre-Phase-6.2 no-authz path.
///
///
/// References whose isn't a string (stack-synthesized
/// standard-type references, numeric identifiers, etc.) bypass the gate — only
/// driver-materialized nodes key into the authz trie.
///
internal static void FilterBrowseReferences(
IList references,
IUserIdentity? userIdentity,
AuthorizationGate? gate,
NodeScopeResolver? scopeResolver)
{
if (gate is null || scopeResolver is null) return;
if (references.Count == 0) return;
// Remove by index from the back so indices stay valid as we shrink the list.
for (var i = references.Count - 1; i >= 0; i--)
{
if (references[i].NodeId.Identifier is not string fullRef) continue;
var scope = scopeResolver.Resolve(fullRef);
if (!gate.IsAllowed(userIdentity, OpcUaOperation.Browse, scope))
references.RemoveAt(i);
}
}
///
/// Picks the the dispatch layer routes through based on the
/// node's Phase 7 source kind (ADR-002). Extracted as a pure function for unit test
/// coverage — the full dispatch requires the OPC UA server stack, but this kernel is
/// deterministic and small.
///
internal static IReadable? SelectReadable(
NodeSourceKind source,
IReadable? driverReadable,
IReadable? virtualReadable,
IReadable? scriptedAlarmReadable) => source switch
{
NodeSourceKind.Virtual => virtualReadable,
NodeSourceKind.ScriptedAlarm => scriptedAlarmReadable,
_ => driverReadable,
};
///
/// Plan decision #6 gate — returns true only when the write is allowed. Virtual tags
/// and scripted alarms reject OPC UA writes because the write path for virtual tags
/// is ctx.SetVirtualTag from within a script, and the write path for alarm
/// state is the Part 9 method nodes (Acknowledge / Confirm / Shelve).
///
internal static bool IsWriteAllowedBySource(NodeSourceKind source) =>
source == NodeSourceKind.Driver;
private static NodeId MapDataType(DriverDataType t) => t switch
{
DriverDataType.Boolean => DataTypeIds.Boolean,
DriverDataType.Int32 => DataTypeIds.Int32,
DriverDataType.Float32 => DataTypeIds.Float,
DriverDataType.Float64 => DataTypeIds.Double,
DriverDataType.String => DataTypeIds.String,
DriverDataType.DateTime => DataTypeIds.DateTime,
_ => DataTypeIds.BaseDataType,
};
///
/// Nested builder returned by . Temporarily retargets the parent's
/// during each call so Variable/Folder calls land under the
/// correct subtree. Not thread-safe if callers drive Discovery concurrently — but
/// GenericDriverNodeManager discovery is sequential per driver.
///
private sealed class NestedBuilder(DriverNodeManager owner, FolderState folder) : IAddressSpaceBuilder
{
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
var prior = owner._currentFolder;
owner._currentFolder = folder;
try { return owner.Folder(browseName, displayName); }
finally { owner._currentFolder = prior; }
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
var prior = owner._currentFolder;
owner._currentFolder = folder;
try { return owner.Variable(browseName, displayName, attributeInfo); }
finally { owner._currentFolder = prior; }
}
public void AddProperty(string browseName, DriverDataType dataType, object? value)
{
var prior = owner._currentFolder;
owner._currentFolder = folder;
try { owner.AddProperty(browseName, dataType, value); }
finally { owner._currentFolder = prior; }
}
}
private sealed class VariableHandle : IVariableHandle
{
private readonly DriverNodeManager _owner;
private readonly BaseDataVariableState _variable;
public string FullReference { get; }
public VariableHandle(DriverNodeManager owner, BaseDataVariableState variable, string fullRef)
{
_owner = owner;
_variable = variable;
FullReference = fullRef;
}
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
lock (_owner.Lock)
{
var alarm = new AlarmConditionState(_variable)
{
SymbolicName = _variable.BrowseName.Name + "_Condition",
ReferenceTypeId = ReferenceTypeIds.HasComponent,
NodeId = new NodeId(FullReference + ".Condition", _owner.NamespaceIndex),
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
DisplayName = new LocalizedText(info.SourceName),
};
// assignNodeIds=true makes the stack allocate NodeIds for every inherited
// AlarmConditionState child (Severity / Message / ActiveState / AckedState /
// EnabledState / …). Without this the children keep Foundation (ns=0) type-
// declaration NodeIds that aren't in the node manager's predefined-node index.
// The newly-allocated NodeIds default to ns=0 via the shared identifier
// counter — we remap them to the node manager's namespace below so client
// Read/Browse on children resolves against the predefined-node dictionary.
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, true);
// Assign every descendant a stable, collision-free NodeId in the node manager's
// namespace keyed on the condition path. The stack's default assignNodeIds path
// allocates from a shared ns=0 counter and does not update parent→child
// references when we remap, so we do the rename up front, symbolically:
// {condition-full-ref}/{symbolic-path-under-condition}
AssignSymbolicDescendantIds(alarm, alarm.NodeId, _owner.NamespaceIndex);
alarm.SourceName.Value = info.SourceName;
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
alarm.EnabledState.Value = new LocalizedText("Enabled");
alarm.EnabledState.Id.Value = true;
alarm.Retain.Value = false;
alarm.AckedState.Value = new LocalizedText("Acknowledged");
alarm.AckedState.Id.Value = true;
alarm.ActiveState.Value = new LocalizedText("Inactive");
alarm.ActiveState.Id.Value = false;
// Enable ConditionRefresh support so clients that connect *after* a transition
// can pull the current retained-condition snapshot.
alarm.ClientUserId.Value = string.Empty;
alarm.BranchId.Value = NodeId.Null;
_variable.AddChild(alarm);
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
// Part 9 event propagation: AddRootNotifier registers the alarm as an event
// source reachable from Objects/Server so subscriptions placed on Server-object
// EventNotifier receive the ReportEvent calls ConditionSink.OnTransition emits.
// Without this the Report fires but has no subscribers to deliver to.
_owner.AddRootNotifier(alarm);
var sink = new ConditionSink(_owner, alarm);
// PR 2.3 — when the server-level alarm-condition service is wired, register
// this condition with it so the state machine runs server-side. The sink-map
// entry routes future TransitionRaised events back to this OPC UA node.
// Conditions whose info lacks an InAlarmRef can't be observed without driver
// help — those still rely on the legacy IAlarmSource path until PR 7.2.
if (_owner._alarmService is not null && !string.IsNullOrEmpty(info.InAlarmRef))
{
_owner._conditionSinks[FullReference] = sink;
// PR B.3 — prefer IAlarmSource.AcknowledgeAsync (driver-native path)
// when the driver supports it. Galaxy implements this since PR B.2;
// for drivers without IAlarmSource the value-driven sub-attribute
// fallback (DriverWritableAcknowledger) preserves the existing
// behaviour.
IAlarmAcknowledger acker;
if (_owner._driver is IAlarmSource alarmSource)
{
var alarmInvoker = new ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker(
_owner._invoker, alarmSource, _owner._driver.DriverInstanceId);
acker = new DriverAlarmSourceAcknowledger(alarmSource, FullReference, alarmInvoker);
}
else
{
acker = new DriverWritableAcknowledger(
_owner._writable, _owner._invoker, _owner._driver.DriverInstanceId);
}
_owner._alarmService.Track(FullReference, info, acker);
}
return sink;
}
}
private static int MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 250,
AlarmSeverity.Medium => 500,
AlarmSeverity.High => 700,
AlarmSeverity.Critical => 900,
_ => 500,
};
// After alarm.Create(assignNodeIds=true), every descendant has *some* NodeId but
// they default to ns=0 via the shared identifier counter — allocations from two
// different alarms collide when we move them into the driver's namespace. Rewriting
// symbolically based on the condition path gives each descendant a unique, stable
// NodeId in the node manager's namespace. Browse + Read resolve against the current
// NodeId because the stack's CustomNodeManager2.Browse traverses NodeState.Children
// (NodeState references) and uses each child's current .NodeId in the response.
private static void AssignSymbolicDescendantIds(
NodeState parent, NodeId parentNodeId, ushort namespaceIndex)
{
var children = new List();
parent.GetChildren(null!, children);
foreach (var child in children)
{
child.NodeId = new NodeId(
$"{parentNodeId.Identifier}.{child.SymbolicName}", namespaceIndex);
AssignSymbolicDescendantIds(child, child.NodeId, namespaceIndex);
}
}
}
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
: IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args)
{
lock (owner.Lock)
{
alarm.Severity.Value = (ushort)MapSeverity(args.Severity);
alarm.Time.Value = args.SourceTimestampUtc;
alarm.Message.Value = new LocalizedText(args.Message);
// Map the driver's transition type to OPC UA Part 9 state. The driver uses
// AlarmEventArgs but the state transition kind is encoded in AlarmType by
// convention — Galaxy's GalaxyAlarmTracker emits "Active"/"Acknowledged"/"Inactive".
switch (args.AlarmType)
{
case "Active":
alarm.SetActiveState(owner.SystemContext, true);
alarm.SetAcknowledgedState(owner.SystemContext, false);
alarm.Retain.Value = true;
break;
case "Acknowledged":
alarm.SetAcknowledgedState(owner.SystemContext, true);
break;
case "Inactive":
alarm.SetActiveState(owner.SystemContext, false);
// Retain stays true until the condition is both Inactive and Acknowledged
// so alarm clients keep the record in their condition refresh snapshot.
if (alarm.AckedState.Id.Value) alarm.Retain.Value = false;
break;
}
alarm.ClearChangeMasks(owner.SystemContext, true);
alarm.ReportEvent(owner.SystemContext, alarm);
}
}
private static int MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 250,
AlarmSeverity.Medium => 500,
AlarmSeverity.High => 700,
AlarmSeverity.Critical => 900,
_ => 500,
};
}
///
/// Per-variable write hook wired on each . Routes the
/// value into the driver's and surfaces its per-tag status code.
///
private ServiceResult OnWriteValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
var fullRef = NodeIdToFullRef(node.NodeId);
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
// Per Phase 7 plan decision #6 — virtual tags + scripted alarms reject direct
// OPC UA writes with BadUserAccessDenied. Scripts can write to virtual tags
// via ctx.SetVirtualTag; operators cannot. Operator alarm actions go through
// the Part 9 method nodes (Acknowledge / Confirm / Shelve), not through the
// variable-value write path.
if (_sourceByFullRef.TryGetValue(fullRef!, out var source) && !IsWriteAllowedBySource(source))
return new ServiceResult(StatusCodes.BadUserAccessDenied);
if (_writable is null) return StatusCodes.BadNotWritable;
// PR 26: server-layer write authorization. Look up the attribute's classification
// (populated during Variable() in Discover) and check the session's roles against the
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync
// never sees a request we'd have refused here.
if (_securityByFullRef.TryGetValue(fullRef!, out var classification))
{
var roles = context.UserIdentity is IRoleBearer rb ? rb.Roles : [];
if (!WriteAuthzPolicy.IsAllowed(classification, roles))
{
_logger.LogInformation(
"Write denied for {FullRef}: classification={Classification} userRoles=[{Roles}]",
fullRef, classification, string.Join(",", roles));
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
// Phase 6.2 Stream C — additive gate check. The classification/role check above
// is the pre-Phase-6.2 baseline; the gate adds per-tag ACL enforcement on top. In
// lax mode (default during rollout) the gate falls through when the identity
// lacks LDAP groups, so existing integration tests keep passing.
if (_authzGate is not null && _scopeResolver is not null)
{
var scope = _scopeResolver.Resolve(fullRef!);
var writeOp = WriteAuthzPolicy.ToOpcUaOperation(classification);
if (!_authzGate.IsAllowed(context.UserIdentity, writeOp, scope))
{
_logger.LogInformation(
"Write denied by ACL gate for {FullRef}: operation={Op} classification={Classification}",
fullRef, writeOp, classification);
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
}
}
try
{
var isIdempotent = _writeIdempotentByFullRef.GetValueOrDefault(fullRef!, false);
var capturedValue = value;
var results = _invoker.ExecuteWriteAsync(
ResolveHostFor(fullRef!),
isIdempotent,
async ct => (IReadOnlyList)await _writable.WriteAsync(
[new DriverWriteRequest(fullRef!, capturedValue)],
ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
if (results.Count > 0 && results[0].StatusCode != 0)
{
statusCode = results[0].StatusCode;
return ServiceResult.Good;
}
return ServiceResult.Good;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Write failed for {FullRef}", fullRef);
return new ServiceResult(StatusCodes.BadInternalError);
}
}
// Diagnostics hook for tests — number of variables registered in this node manager.
internal int VariableCount => _variablesByFullRef.Count;
internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
=> _variablesByFullRef.TryGetValue(fullRef, out v!);
// ===================== HistoryRead service handlers (LMX #1, PR 38; PR 1.3 routing) =====================
//
// Wires HistoryRead to the server-level IHistoryRouter (PR 1.2). For each tag:
// (1) the router resolves the longest-matching IHistorianDataSource registration —
// when a server-registered source covers the namespace it wins; (2) when the router
// doesn't match (or no router is configured), we fall back to the driver's own
// IHistoryProvider capability via a thin adapter, preserving the legacy behavior tests
// rely on. PR 1.W will register the legacy adapter inside the router as well, at
// which point this fallback can be deleted.
//
// Continuation-point handling is pass-through only: the source returns null from its
// ContinuationPoint today so the outer result's ContinuationPoint stays empty. Proper
// Session.SaveHistoryContinuationPoint plumbing is a follow-up when a source actually
// needs paging — the dispatch shape doesn't change, only the result-population.
///
/// Resolves the historian data source for a given driver full reference. Returns
/// null when neither the router nor the legacy IHistoryProvider path can serve it.
///
///
/// Full reference, or null for driver-root event-history queries (event reads can
/// target a notifier rather than a specific variable). Null fullRef skips router
/// lookup and goes straight to the legacy fallback so today's "all events in the
/// driver namespace" path keeps working.
///
private IHistorianDataSource? ResolveHistory(string? fullRef)
{
if (fullRef is not null
&& _historyRouter?.Resolve(fullRef) is { } routed)
{
return routed;
}
if (_driver is IHistoryProvider legacy)
{
return _legacyHistoryAdapter ??= new LegacyDriverHistoryAdapter(legacy);
}
return null;
}
///
/// Wraps a driver's as an
/// so the four HistoryRead* methods can dispatch
/// through one interface regardless of resolution path. PR 1.W's legacy
/// auto-registration uses the same adapter; PR 7.2 deletes both once
/// IHistoryProvider stops being a driver capability.
///
// OTOPCUA0001 (UnwrappedCapabilityCallAnalyzer) flags every direct IHistoryProvider call
// that isn't lexically inside a CapabilityInvoker.ExecuteAsync lambda. The adapter's
// pass-throughs are direct calls — but the four HistoryRead* call sites that own the
// adapter ARE inside ExecuteAsync lambdas, so the wrapping is preserved at runtime.
// Suppress here rather than at every call site.
#pragma warning disable OTOPCUA0001
private sealed class LegacyDriverHistoryAdapter(IHistoryProvider provider) : IHistorianDataSource
{
// HistoryReadResult is unqualified-ambiguous in this file (Core.Abstractions vs.
// Opc.Ua); fully qualify on the adapter signatures so the file's existing var-based
// dispatch sites stay readable.
public Task ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
=> provider.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
public Task ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
=> provider.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
public Task ReadAtTimeAsync(
string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken)
=> provider.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
public Task ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
=> provider.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
// Legacy IHistoryProvider has no health surface. Return an "unknown but reachable"
// snapshot so dashboards don't show the data source as broken.
public HistorianHealthSnapshot GetHealthSnapshot()
=> new(0, 0, 0, 0, null, null, null,
ProcessConnectionOpen: true, EventConnectionOpen: true,
ActiveProcessNode: null, ActiveEventNode: null,
Nodes: []);
// Legacy lifecycle is the driver's responsibility — disposing the adapter must
// not dispose the driver out from under DriverNodeManager.
public void Dispose() { }
}
#pragma warning restore OTOPCUA0001
protected override void HistoryReadRawModified(
ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestamps,
IList nodesToRead, IList results,
IList errors, List nodesToProcess,
IDictionary cache)
{
// IsReadModified=true requests a "modifications" history (who changed the data, when
// it was re-written). The driver side has no modifications store — surface that
// explicitly rather than silently returning raw data, which would mislead the client.
if (details.IsReadModified)
{
MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadHistoryOperationUnsupported);
return;
}
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
// are interleaved across multiple node managers.
var i = handle.Index;
var fullRef = ResolveFullRef(handle);
if (fullRef is null)
{
WriteNodeIdUnknown(results, errors, i);
continue;
}
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
if (_authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
{
WriteAccessDenied(results, errors, i);
continue;
}
}
try
{
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
ResolveHostFor(fullRef),
async ct => await source.ReadRawAsync(
fullRef,
details.StartTime,
details.EndTime,
details.NumValuesPerNode,
ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
WriteResult(results, errors, i, StatusCodes.Good,
BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
}
catch (NotSupportedException)
{
WriteUnsupported(results, errors, i);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "HistoryReadRaw failed for {FullRef}", fullRef);
WriteInternalError(results, errors, i);
}
}
}
protected override void HistoryReadProcessed(
ServerSystemContext context, ReadProcessedDetails details, TimestampsToReturn timestamps,
IList nodesToRead, IList results,
IList errors, List nodesToProcess,
IDictionary cache)
{
// AggregateType is one NodeId shared across every item in the batch — map once.
var aggregate = MapAggregate(details.AggregateType?.FirstOrDefault());
if (aggregate is null)
{
MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadAggregateNotSupported);
return;
}
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
var i = handle.Index;
var fullRef = ResolveFullRef(handle);
if (fullRef is null)
{
WriteNodeIdUnknown(results, errors, i);
continue;
}
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
if (_authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
{
WriteAccessDenied(results, errors, i);
continue;
}
}
try
{
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
ResolveHostFor(fullRef),
async ct => await source.ReadProcessedAsync(
fullRef,
details.StartTime,
details.EndTime,
interval,
aggregate.Value,
ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
WriteResult(results, errors, i, StatusCodes.Good,
BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
}
catch (NotSupportedException)
{
WriteUnsupported(results, errors, i);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "HistoryReadProcessed failed for {FullRef}", fullRef);
WriteInternalError(results, errors, i);
}
}
}
protected override void HistoryReadAtTime(
ServerSystemContext context, ReadAtTimeDetails details, TimestampsToReturn timestamps,
IList nodesToRead, IList results,
IList errors, List nodesToProcess,
IDictionary cache)
{
var requestedTimes = (IReadOnlyList)(details.ReqTimes?.ToArray() ?? Array.Empty());
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
var i = handle.Index;
var fullRef = ResolveFullRef(handle);
if (fullRef is null)
{
WriteNodeIdUnknown(results, errors, i);
continue;
}
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
if (_authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
{
WriteAccessDenied(results, errors, i);
continue;
}
}
try
{
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
ResolveHostFor(fullRef),
async ct => await source.ReadAtTimeAsync(fullRef, requestedTimes, ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
WriteResult(results, errors, i, StatusCodes.Good,
BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
}
catch (NotSupportedException)
{
WriteUnsupported(results, errors, i);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "HistoryReadAtTime failed for {FullRef}", fullRef);
WriteInternalError(results, errors, i);
}
}
}
protected override void HistoryReadEvents(
ServerSystemContext context, ReadEventDetails details, TimestampsToReturn timestamps,
IList nodesToRead, IList results,
IList errors, List nodesToProcess,
IDictionary cache)
{
// SourceName filter extraction is deferred — EventFilter SelectClauses + WhereClause
// handling is a dedicated concern. This PR treats the event query as "all events in
// range for the node's source" and populates only the standard BaseEventType fields.
var maxEvents = (int)details.NumValuesPerNode;
if (maxEvents <= 0) maxEvents = 1000;
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
var i = handle.Index;
// Event history queries may target a notifier object (e.g. the driver-root folder)
// rather than a specific variable — in that case fullRef is null and we pass
// sourceName=null to the source meaning "all sources in this source's namespace."
var fullRef = ResolveFullRef(handle);
// ResolveHistory tolerates null fullRef — for notifier queries the router is
// skipped and the legacy fallback handles "all sources" reads.
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
// fullRef is null for event-history queries that target a notifier (driver root).
// Those are cluster-wide reads + need a different scope shape; skip the gate here
// and let the driver-level authz handle them. Non-null path gets per-node gating.
if (fullRef is not null && _authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
{
WriteAccessDenied(results, errors, i);
continue;
}
}
try
{
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
fullRef is null ? _driver.DriverInstanceId : ResolveHostFor(fullRef),
async ct => await source.ReadEventsAsync(
sourceName: fullRef,
startUtc: details.StartTime,
endUtc: details.EndTime,
maxEvents: maxEvents,
cancellationToken: ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
WriteResult(results, errors, i, StatusCodes.Good,
BuildHistoryEvent(driverResult.Events), driverResult.ContinuationPoint);
}
catch (NotSupportedException)
{
WriteUnsupported(results, errors, i);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "HistoryReadEvents failed for {FullRef}", fullRef);
WriteInternalError(results, errors, i);
}
}
}
private string? ResolveFullRef(NodeHandle handle)
{
if (handle.NodeId is null) return null;
return NodeIdToFullRef(handle.NodeId);
}
///
/// Recover the driver-side FullReference for a given OPC UA . Looks
/// the identifier up in ; when no entry exists (e.g. for
/// legacy test fixtures that still register variables with FullRef-shaped NodeIds) we
/// fall through to the raw identifier string so those code paths keep working.
///
private string NodeIdToFullRef(NodeId nodeId)
{
if (nodeId?.Identifier is not string key) return string.Empty;
return _fullRefByNodeId.TryGetValue(key, out var fullRef) ? fullRef : key;
}
// Both the results list AND the parallel errors list must be populated — MasterNodeManager
// merges them and the merged StatusCode is what the client sees. Leaving errors[i] at its
// default (BadHistoryOperationUnsupported) overrides a Good result with Unsupported, which
// masks a correctly-constructed HistoryData response. This was the subtle failure mode
// that cost most of PR 38's debugging budget.
private static void WriteResult(IList results, IList errors,
int i, uint statusCode, ExtensionObject historyData, byte[]? continuationPoint)
{
results[i] = new OpcHistoryReadResult
{
StatusCode = statusCode,
HistoryData = historyData,
ContinuationPoint = continuationPoint,
};
errors[i] = statusCode == StatusCodes.Good
? ServiceResult.Good
: new ServiceResult(statusCode);
}
private static void WriteUnsupported(IList results, IList errors, int i)
{
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
errors[i] = StatusCodes.BadHistoryOperationUnsupported;
}
private static void WriteInternalError(IList results, IList errors, int i)
{
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadInternalError };
errors[i] = StatusCodes.BadInternalError;
}
private static void WriteAccessDenied(IList results, IList errors, int i)
{
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied };
errors[i] = StatusCodes.BadUserAccessDenied;
}
private static void WriteNodeIdUnknown(IList results, IList errors, int i)
{
WriteNodeIdUnknown(results, errors, i);
errors[i] = StatusCodes.BadNodeIdUnknown;
}
private static void MarkAllUnsupported(
List nodes, IList results, IList errors,
uint statusCode = StatusCodes.BadHistoryOperationUnsupported)
{
foreach (var handle in nodes)
{
results[handle.Index] = new OpcHistoryReadResult { StatusCode = statusCode };
errors[handle.Index] = statusCode == StatusCodes.Good ? ServiceResult.Good : new ServiceResult(statusCode);
}
}
///
/// Map the OPC UA Part 13 aggregate-function NodeId to the driver's
/// . Internal so the test suite can pin the mapping
/// without exposing public API. Returns null for unsupported aggregates so the service
/// handler can surface BadAggregateNotSupported on the whole batch.
///
internal static HistoryAggregateType? MapAggregate(NodeId? aggregateNodeId)
{
if (aggregateNodeId is null) return null;
// Every AggregateFunction_* identifier is a numeric uint on the Server (0) namespace.
// Comparing NodeIds by value handles all the cross-encoding cases (expanded vs plain).
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;
}
///
/// Wrap driver samples as HistoryData in an ExtensionObject — the on-wire
/// shape the OPC UA HistoryRead service expects for raw / processed / at-time reads.
///
internal static ExtensionObject BuildHistoryData(IReadOnlyList samples)
{
var values = new DataValueCollection(samples.Count);
foreach (var s in samples) values.Add(ToDataValue(s));
return new ExtensionObject(new HistoryData { DataValues = values });
}
///
/// Wrap driver events as HistoryEvent in an ExtensionObject. Populates
/// the minimum BaseEventType field set (SourceName, Message, Severity, Time,
/// ReceiveTime, EventId) so clients that request the default
/// SimpleAttributeOperand select-clauses see useful data. Custom EventFilter
/// SelectClause evaluation is deferred — when a client sends a specific operand list,
/// they currently get the standard fields back and ignore the extras. Documented on the
/// public follow-up list.
///
internal static ExtensionObject BuildHistoryEvent(IReadOnlyList events)
{
var fieldLists = new HistoryEventFieldListCollection(events.Count);
foreach (var e in events)
{
var fields = new VariantCollection
{
// Order must match BaseEventType's conventional field ordering so clients that
// didn't customize the SelectClauses still see recognizable columns. A future
// PR that respects the client's SelectClause list will drive this from the filter.
new Variant(e.EventId),
new Variant(e.SourceName ?? string.Empty),
new Variant(new LocalizedText(e.Message ?? string.Empty)),
new Variant(e.Severity),
new Variant(e.EventTimeUtc),
new Variant(e.ReceivedTimeUtc),
};
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
}
return new ExtensionObject(new HistoryEvent { Events = fieldLists });
}
internal static DataValue ToDataValue(DataValueSnapshot s)
{
var dv = new DataValue
{
Value = s.Value,
StatusCode = new StatusCode(s.StatusCode),
ServerTimestamp = s.ServerTimestampUtc,
};
if (s.SourceTimestampUtc.HasValue) dv.SourceTimestamp = s.SourceTimestampUtc.Value;
return dv;
}
}