diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs
new file mode 100644
index 0000000..092845c
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs
@@ -0,0 +1,19 @@
+namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+///
+/// Late-binding adapter that holds an inner reference
+/// swappable at runtime. Mirrors : Akka actors resolve
+/// the publisher at DI time, but the production SdkServiceLevelPublisher only exists
+/// after StandardServer.Start. The Host's hosted service swaps the inner once the SDK
+/// is up; until then writes route through .
+///
+public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
+{
+ private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
+
+ /// Swap the underlying publisher. Pass null to revert to the Null no-op.
+ public void SetInner(IServiceLevelPublisher? inner) =>
+ _inner = inner ?? NullServiceLevelPublisher.Instance;
+
+ public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
index 28acb60..b86f706 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
@@ -22,6 +22,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
{
private readonly IConfiguration _configuration;
private readonly DeferredAddressSpaceSink _deferredSink;
+ private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
@@ -32,11 +33,13 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
public OtOpcUaServerHostedService(
IConfiguration configuration,
DeferredAddressSpaceSink deferredSink,
+ DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
ILoggerFactory loggerFactory)
{
_configuration = configuration;
_deferredSink = deferredSink;
+ _deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger();
@@ -75,14 +78,24 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
}
_deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager));
- _logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space sink bound");
+
+ // ServiceLevel publisher needs IServerInternal — only available after Start.
+ if (_server.CurrentInstance is { } serverInternal)
+ {
+ _deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
+ serverInternal,
+ _loggerFactory.CreateLogger()));
+ }
+
+ _logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
}
public Task StopAsync(CancellationToken cancellationToken)
{
- // Revert to Null sink so any in-flight writes from a poison-pilled actor don't hit a
+ // Revert to Null adapters so any in-flight writes from a poison-pilled actor don't hit a
// half-disposed NodeManager.
_deferredSink.SetSink(null);
+ _deferredServiceLevel.SetInner(null);
return Task.CompletedTask;
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 8452a3c..3043d0a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -60,6 +60,12 @@ if (hasDriver)
builder.Services.AddSingleton(sp =>
sp.GetRequiredService());
+ // Same late-binding pattern for the ServiceLevel publisher — actor wants it at ctor time,
+ // production SdkServiceLevelPublisher needs IServerInternal which only exists after Start.
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(sp =>
+ sp.GetRequiredService());
+
// F13c — bind UserName tokens to the same LDAP backend the Admin cookie/JWT flows use.
// ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes
// it isn't, so we register the LDAP options + service unconditionally for driver hosts
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs
new file mode 100644
index 0000000..9f7e4b3
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs
@@ -0,0 +1,56 @@
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.Server;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
+
+///
+/// Production that writes the OPC UA Server object's
+/// ServiceLevel Variable through the SDK. Clients reading
+/// VariableIds.Server_ServiceLevel see the live value updated whenever the redundancy
+/// state changes — that's the standard OPC UA non-transparent-redundancy signal callers use
+/// to pick a primary.
+///
+/// Uses (a ) and
+/// its child variable, which the SDK populates
+/// automatically during initialization. Writes are
+/// guarded by so concurrent diagnostics scans
+/// from the SDK don't fight with our update.
+///
+public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
+{
+ private readonly IServerInternal _serverInternal;
+ private readonly ILogger _logger;
+
+ public SdkServiceLevelPublisher(IServerInternal serverInternal, ILogger logger)
+ {
+ _serverInternal = serverInternal;
+ _logger = logger;
+ }
+
+ public void Publish(byte serviceLevel)
+ {
+ var node = _serverInternal.ServerObject?.ServiceLevel;
+ if (node is null)
+ {
+ _logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable; skipping write");
+ return;
+ }
+
+ try
+ {
+ lock (_serverInternal.DiagnosticsLock)
+ {
+ node.Value = serviceLevel;
+ node.Timestamp = DateTime.UtcNow;
+ node.StatusCode = StatusCodes.Good;
+ node.ClearChangeMasks(_serverInternal.DefaultSystemContext, includeChildren: false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs
new file mode 100644
index 0000000..d33391d
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredServiceLevelPublisherTests.cs
@@ -0,0 +1,48 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+public sealed class DeferredServiceLevelPublisherTests
+{
+ [Fact]
+ public void Publish_before_SetInner_is_a_safe_noop()
+ {
+ var deferred = new DeferredServiceLevelPublisher();
+
+ Should.NotThrow(() => deferred.Publish(123));
+ }
+
+ [Fact]
+ public void Publish_after_SetInner_routes_to_the_inner()
+ {
+ var recording = new RecordingPublisher();
+ var deferred = new DeferredServiceLevelPublisher();
+ deferred.SetInner(recording);
+
+ deferred.Publish(200);
+
+ recording.LastValue.ShouldBe((byte)200);
+ }
+
+ [Fact]
+ public void SetInner_null_reverts_to_Null_publisher()
+ {
+ var recording = new RecordingPublisher();
+ var deferred = new DeferredServiceLevelPublisher();
+ deferred.SetInner(recording);
+ deferred.Publish(50);
+
+ deferred.SetInner(null);
+ deferred.Publish(99);
+
+ recording.LastValue.ShouldBe((byte)50, "writes after SetInner(null) must not reach the previous inner");
+ }
+
+ private sealed class RecordingPublisher : IServiceLevelPublisher
+ {
+ public byte? LastValue { get; private set; }
+ public void Publish(byte serviceLevel) => LastValue = serviceLevel;
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs
new file mode 100644
index 0000000..39a09f9
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkServiceLevelPublisherTests.cs
@@ -0,0 +1,94 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua.Server;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// #81 residual — verifies locates the standard
+/// VariableIds.Server_ServiceLevel node through the SDK's DiagnosticsNodeManager and
+/// writes the byte value. Boots a real on a free port so the
+/// SDK populates its predefined diagnostics nodes — that's what production sees.
+///
+public sealed class SdkServiceLevelPublisherTests : IDisposable
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private readonly string _pkiRoot = Path.Combine(
+ Path.GetTempPath(),
+ $"otopcua-pki-{Guid.NewGuid():N}");
+
+ [Fact]
+ public async Task Publish_writes_value_to_Server_ServiceLevel_variable()
+ {
+ var server = new StandardServer();
+ await using var host = new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.SvcLevel",
+ ApplicationUri = $"urn:OtOpcUa.SvcLevel:{Guid.NewGuid():N}",
+ OpcUaPort = AllocateFreePort(),
+ PublicHostname = "localhost",
+ PkiStoreRoot = _pkiRoot,
+ },
+ NullLogger.Instance);
+
+ await host.StartAsync(server, Ct);
+
+ var publisher = new SdkServiceLevelPublisher(
+ server.CurrentInstance,
+ NullLogger.Instance);
+
+ publisher.Publish(200);
+
+ var variable = server.CurrentInstance.ServerObject.ServiceLevel;
+ variable.ShouldNotBeNull("Server.ServiceLevel must be present in the address space");
+ variable.Value.ShouldBe((byte)200);
+ }
+
+ [Fact]
+ public async Task Publish_is_idempotent_when_called_multiple_times()
+ {
+ var server = new StandardServer();
+ await using var host = new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.SvcLevel.Idem",
+ ApplicationUri = $"urn:OtOpcUa.SvcLevel.Idem:{Guid.NewGuid():N}",
+ OpcUaPort = AllocateFreePort(),
+ PublicHostname = "localhost",
+ PkiStoreRoot = _pkiRoot,
+ },
+ NullLogger.Instance);
+
+ await host.StartAsync(server, Ct);
+ var publisher = new SdkServiceLevelPublisher(
+ server.CurrentInstance,
+ NullLogger.Instance);
+
+ publisher.Publish(100);
+ publisher.Publish(150);
+ publisher.Publish(240);
+
+ server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240);
+ }
+
+ private static int AllocateFreePort()
+ {
+ using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_pkiRoot))
+ {
+ try { Directory.Delete(_pkiRoot, recursive: true); }
+ catch { /* best-effort */ }
+ }
+ }
+}