using System.Collections.Concurrent; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa; /// /// Generic, driver-agnostic backbone for populating the OPC UA address space from an /// . Walks the driver's discovery, wires the alarm + data-change + /// rediscovery subscription events, and hands each variable to the supplied /// . Concrete OPC UA server implementations provide the /// builder — see the Server project's OpcUaAddressSpaceBuilder for the materialization /// against CustomNodeManager2. /// /// /// Per docs/v2/plan.md decision #52 + #62 — Core owns the node tree, drivers stream /// Folder/Variable calls, alarm-bearing variables are annotated via /// and subsequent /// payloads route to the sink the builder returned. /// public class GenericDriverNodeManager(IDriver driver) : IDisposable { protected IDriver Driver { get; } = driver ?? throw new ArgumentNullException(nameof(driver)); public string DriverInstanceId => Driver.DriverInstanceId; // Source tag (DriverAttributeInfo.FullName) → alarm-condition sink. Populated during // BuildAddressSpaceAsync by a recording IAddressSpaceBuilder implementation that captures the // IVariableHandle per attr.IsAlarm=true variable and calls MarkAsAlarmCondition. private readonly ConcurrentDictionary _alarmSinks = new(StringComparer.OrdinalIgnoreCase); private EventHandler? _alarmForwarder; private bool _disposed; /// /// Populates the address space by streaming nodes from the driver into the supplied builder, /// wraps the builder so alarm-condition sinks are captured, subscribes to the driver's /// alarm event stream, and routes each transition to the matching sink by SourceNodeId. /// Driver exceptions are isolated per decision #12 — the driver's subtree is marked Faulted, /// but other drivers remain available. /// public async Task BuildAddressSpaceAsync(IAddressSpaceBuilder builder, CancellationToken ct) { ArgumentNullException.ThrowIfNull(builder); if (Driver is not ITagDiscovery discovery) throw new NotSupportedException($"Driver '{Driver.DriverInstanceId}' does not implement ITagDiscovery."); var capturing = new CapturingBuilder(builder, _alarmSinks); await discovery.DiscoverAsync(capturing, ct); if (Driver is IAlarmSource alarmSource) { _alarmForwarder = (_, e) => { // Route the alarm to the sink registered for the originating variable, if any. // Unknown source ids are dropped silently — they may belong to another driver or // to a variable the builder chose not to flag as an alarm condition. if (_alarmSinks.TryGetValue(e.SourceNodeId, out var sink)) sink.OnTransition(e); }; alarmSource.OnAlarmEvent += _alarmForwarder; } } public void Dispose() { if (_disposed) return; _disposed = true; if (_alarmForwarder is not null && Driver is IAlarmSource alarmSource) { alarmSource.OnAlarmEvent -= _alarmForwarder; } _alarmSinks.Clear(); } /// /// Snapshot the current alarm-sink registry by source node id. Diagnostic + test hook; /// not part of the hot path. /// internal IReadOnlyCollection TrackedAlarmSources => _alarmSinks.Keys.ToList(); /// /// Wraps the caller-supplied so every /// call registers the returned sink in /// the node manager's source-node-id map. The builder itself drives materialization; /// this wrapper only observes. /// private sealed class CapturingBuilder( IAddressSpaceBuilder inner, ConcurrentDictionary sinks) : IAddressSpaceBuilder { public IAddressSpaceBuilder Folder(string browseName, string displayName) => new CapturingBuilder(inner.Folder(browseName, displayName), sinks); public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) => new CapturingHandle(inner.Variable(browseName, displayName, attributeInfo), sinks); public void AddProperty(string browseName, DriverDataType dataType, object? value) => inner.AddProperty(browseName, dataType, value); } private sealed class CapturingHandle( IVariableHandle inner, ConcurrentDictionary sinks) : IVariableHandle { public string FullReference => inner.FullReference; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) { var sink = inner.MarkAsAlarmCondition(info); // Register by the driver-side full reference so the alarm forwarder can look it up // using AlarmEventArgs.SourceNodeId (which the driver populates with the same tag). sinks[inner.FullReference] = sink; return sink; } } }