using System.Collections.Concurrent; using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; 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); /// 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(); 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; /// 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; /// /// 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); if (_alarmConditions.TryGetValue(alarmNodeId, out var condition)) { lock (Lock) { // EnabledState / AckedState / ActiveState are mandatory children — always present after // Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is // that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a // future SDK that builds a leaner child set) may leave them null. Null-guard each optional // child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a // no-op rather than an NRE. condition.SetEnableState(SystemContext, state.Enabled); condition.SetActiveState(SystemContext, state.Active); condition.SetAcknowledgedState(SystemContext, state.Acknowledged); if (condition.ConfirmedState is not null) { condition.SetConfirmedState(SystemContext, state.Confirmed); } if (condition.ShelvingState is not null) { // SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's // (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the // SDK timer, so we pass shelvingTime=0 (no SDK-managed auto-unshelve). condition.SetShelvingState( SystemContext, shelved: state.Shelving != AlarmShelvingKind.Unshelved, oneShot: state.Shelving == AlarmShelvingKind.OneShot, shelvingTime: 0); } condition.SetSeverity(SystemContext, MapSeverity(state.Severity)); condition.Message.Value = new LocalizedText(state.Message); // Part 9: retain the condition while it is active OR unacknowledged so a client's // ConditionRefresh replays it. T16's event firing will also drive Retain; here we keep // it correct for the projection. condition.Retain.Value = state.Active || !state.Acknowledged; condition.Time.Value = sourceTimestampUtc; condition.ReceiveTime.Value = sourceTimestampUtc; // NO ReportEvent here — T16 owns event firing. ClearChangeMasks just notifies any // attribute (not event) subscribers watching the condition's children directly. condition.ClearChangeMasks(SystemContext, includeChildren: true); } return; } // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so // un-materialised callers (and the existing unit tests) keep working. lock (Lock) { // CreateVariable mutates the SDK address space, so it MUST run under Lock (see WriteValue). 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); } } /// /// Materialise a real OPC UA Part 9 node under its equipment /// folder so clients can browse it as a proper condition (and, once T16 lands, subscribe to its /// events). The node id is the alarm node id (the ScriptedAlarmId) so subsequent /// 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; 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; } } /// 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. private void EnsureFolderIsEventNotifier(FolderState folder) { if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return; folder.EventNotifier = EventNotifiers.SubscribeToEvents; 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 Galaxy / SystemPlatform tags so they're browseable before the /// Galaxy driver issues SubscribeBulk. /// /// 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"). public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { 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); 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 = AccessLevels.CurrentRead, UserAccessLevel = AccessLevels.CurrentRead, Historizing = false, Value = null, StatusCode = StatusCodes.BadWaitingForInitialData, Timestamp = DateTime.MinValue, }; parent.AddChild(variable); AddPredefinedNode(SystemContext, variable); _variables[variableNodeId] = variable; } } /// 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(); 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(); } } private FolderState ResolveParentFolder(string? parentNodeId) { if (string.IsNullOrEmpty(parentNodeId)) return _root!; return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!; } /// 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, }; }