diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs
index e37d7cc..d41303c 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs
@@ -24,6 +24,17 @@ public sealed class DriverHost : IAsyncDisposable
return _drivers.TryGetValue(driverInstanceId, out var d) ? d.GetHealth() : null;
}
+ ///
+ /// Look up a registered driver by instance id. Used by the OPC UA server runtime
+ /// (OtOpcUaServer) to instantiate one DriverNodeManager per driver at
+ /// startup. Returns null when the driver is not registered.
+ ///
+ public IDriver? GetDriver(string driverInstanceId)
+ {
+ lock (_lock)
+ return _drivers.TryGetValue(driverInstanceId, out var d) ? d : null;
+ }
+
///
/// Registers the driver and calls . If initialization
/// throws, the driver is kept in the registry so the operator can retry; quality on its
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
new file mode 100644
index 0000000..dedb9de
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
@@ -0,0 +1,360 @@
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Server;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
+
+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 ILogger _logger;
+
+ private FolderState? _driverRoot;
+ private readonly Dictionary _variablesByFullRef = 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!;
+
+ public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
+ IDriver driver, ILogger logger)
+ : base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
+ {
+ _driver = driver;
+ _readable = driver as IReadable;
+ _writable = driver as IWritable;
+ _logger = logger;
+ }
+
+ protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();
+
+ 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),
+ EventNotifier = EventNotifiers.None,
+ };
+
+ // 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)
+ {
+ var v = new BaseDataVariableState(_currentFolder)
+ {
+ SymbolicName = browseName,
+ ReferenceTypeId = ReferenceTypeIds.Organizes,
+ TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
+ NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
+ BrowseName = new QualifiedName(browseName, NamespaceIndex),
+ DisplayName = new LocalizedText(displayName),
+ DataType = MapDataType(attributeInfo.DriverDataType),
+ ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
+ AccessLevel = AccessLevels.CurrentReadOrWrite,
+ UserAccessLevel = AccessLevels.CurrentReadOrWrite,
+ Historizing = attributeInfo.IsHistorized,
+ };
+ _currentFolder.AddChild(v);
+ AddPredefinedNode(SystemContext, v);
+ _variablesByFullRef[attributeInfo.FullName] = v;
+
+ 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)
+ {
+ if (_readable is null)
+ {
+ statusCode = StatusCodes.BadNotReadable;
+ return ServiceResult.Good;
+ }
+
+ try
+ {
+ var fullRef = node.NodeId.Identifier as string ?? "";
+ var result = _readable.ReadAsync([fullRef], CancellationToken.None).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;
+ }
+
+ 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),
+ };
+ alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, false);
+ 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;
+
+ _variable.AddChild(alarm);
+ _owner.AddPredefinedNode(_owner.SystemContext, alarm);
+
+ return new ConditionSink(_owner, alarm);
+ }
+ }
+
+ private static int MapSeverity(AlarmSeverity s) => s switch
+ {
+ AlarmSeverity.Low => 250,
+ AlarmSeverity.Medium => 500,
+ AlarmSeverity.High => 700,
+ AlarmSeverity.Critical => 900,
+ _ => 500,
+ };
+ }
+
+ 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)
+ {
+ if (_writable is null) return StatusCodes.BadNotWritable;
+ var fullRef = node.NodeId.Identifier as string;
+ if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
+
+ try
+ {
+ var results = _writable.WriteAsync(
+ [new DriverWriteRequest(fullRef!, value)],
+ CancellationToken.None).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!);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
new file mode 100644
index 0000000..622fe2e
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Server;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
+
+///
+/// subclass that wires one per
+/// registered driver from . Anonymous endpoint on
+/// opc.tcp://0.0.0.0:4840, no security — PR 16 minimum-viable scope; LDAP + security
+/// profiles are deferred to their own PR on top of this.
+///
+public sealed class OtOpcUaServer : StandardServer
+{
+ private readonly DriverHost _driverHost;
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly List _driverNodeManagers = new();
+
+ public OtOpcUaServer(DriverHost driverHost, ILoggerFactory loggerFactory)
+ {
+ _driverHost = driverHost;
+ _loggerFactory = loggerFactory;
+ }
+
+ ///
+ /// Read-only snapshot of the driver node managers materialized at server start. Used by
+ /// the generic-driver-node-manager-driven discovery flow after the server starts — the
+ /// host walks each entry and invokes
+ /// GenericDriverNodeManager.BuildAddressSpaceAsync(manager) passing the manager
+ /// as its own .
+ ///
+ public IReadOnlyList DriverNodeManagers => _driverNodeManagers;
+
+ protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
+ {
+ foreach (var driverId in _driverHost.RegisteredDriverIds)
+ {
+ var driver = _driverHost.GetDriver(driverId);
+ if (driver is null) continue;
+
+ var logger = _loggerFactory.CreateLogger();
+ var manager = new DriverNodeManager(server, configuration, driver, logger);
+ _driverNodeManagers.Add(manager);
+ }
+
+ return new MasterNodeManager(server, configuration, null, _driverNodeManagers.ToArray());
+ }
+
+ protected override ServerProperties LoadServerProperties() => new()
+ {
+ ManufacturerName = "OtOpcUa",
+ ProductName = "OtOpcUa.Server",
+ ProductUri = "urn:OtOpcUa:Server",
+ SoftwareVersion = "2.0.0",
+ BuildNumber = "0",
+ BuildDate = DateTime.UtcNow,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
index 1b7791d..d4fad87 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
@@ -21,6 +21,8 @@
+
+
@@ -30,6 +32,9 @@
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj
index 83ff431..70c996b 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj
+++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj
@@ -27,6 +27,7 @@
+