Some checks failed
v2-ci / build (push) Failing after 38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
OtOpcUaNodeManager + SdkAddressSpaceSink: the v2 IOpcUaAddressSpaceSink
seam now has a production adapter against a real Opc.Ua.Server
CustomNodeManager2. Writes through OpcUaPublishActor's sink materialise
as real OPC UA Variable updates that subscribed clients see via the
standard ClearChangeMasks notification path.
OtOpcUaNodeManager (CustomNodeManager2):
- Owns a ConcurrentDictionary<string, BaseDataVariableState> under a
single namespace (https://zb.com/otopcua/ns) hung off Objects/.
- WriteValue lazy-creates the variable on first write, sets Value +
StatusCode (mapped from OpcUaQuality severity bits) + SourceTimestamp,
then ClearChangeMasks to notify subscribers.
- WriteAlarmState surfaces a [active, acknowledged] pair on a
dedicated node id — full AlarmConditionState/event firing comes
with #85 F14b (EquipmentNodeWalker SDK integration).
- RebuildAddressSpace tears down every registered variable + clears
the dictionary so the next write-pass starts fresh.
- Address-space root folder is materialised in CreateAddressSpace.
SdkAddressSpaceSink: thin IOpcUaAddressSpaceSink → OtOpcUaNodeManager
bridge. Production DI binding (#108) constructs this once the host's
StandardServer has booted.
OtOpcUaSdkServer (StandardServer subclass): overrides
CreateMasterNodeManager to inject OtOpcUaNodeManager via the
MasterNodeManager additionalManagers ctor. NodeManager property
exposes the live instance so OpcUaApplicationHost callers can wrap
it in a sink.
Tests: OpcUaServer 20 -> 24 (+4):
- WriteValue creates + updates variables in the manager
- WriteAlarmState creates a node distinct from value writes
- RebuildAddressSpace clears everything; subsequent writes start fresh
- NullOpcUaAddressSpaceSink no-op sanity
Each test boots a real OpcUaApplicationHost on a free port with the
SDK certificate auto-create flow (F13a) intact — full integration
slice on macOS.
All 6 v2 test suites green: 167 tests passing.
F10 status updated to reflect SDK binding shipped. Residuals:
- #109 OpcUaPublishActor.RebuildAddressSpace → Phase7Applier wiring
- #108 Host DI default to SdkAddressSpaceSink when hasDriver
- #85 F14b EquipmentNodeWalker integration (proper AlarmConditionState
+ folder hierarchy)
- IServiceLevelPublisher SDK binding (writes Server.ServiceLevel node)
120 lines
4.3 KiB
C#
120 lines
4.3 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Integration tests for the F10b production binding: boot a real <see cref="OtOpcUaSdkServer"/>
|
|
/// through <see cref="OpcUaApplicationHost"/>, attach a <see cref="SdkAddressSpaceSink"/>,
|
|
/// drive <c>WriteValue</c>/<c>WriteAlarmState</c>/<c>RebuildAddressSpace</c>, and verify the
|
|
/// <see cref="OtOpcUaNodeManager"/> reflects the writes.
|
|
/// </summary>
|
|
public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"otopcua-sink-{Guid.NewGuid():N}");
|
|
|
|
[Fact]
|
|
public async Task WriteValue_creates_and_updates_variable_in_node_manager()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
|
|
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow);
|
|
|
|
server.NodeManager!.VariableCount.ShouldBe(2);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAlarmState_creates_dedicated_node_distinct_from_value_writes()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
|
|
sink.WriteAlarmState("alarm-7", active: true, acknowledged: false, DateTime.UtcNow);
|
|
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
|
|
|
server.NodeManager!.VariableCount.ShouldBe(2);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RebuildAddressSpace_clears_all_registered_variables()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
|
|
|
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteAlarmState("alarm-c", true, false, DateTime.UtcNow);
|
|
server.NodeManager!.VariableCount.ShouldBe(3);
|
|
|
|
sink.RebuildAddressSpace();
|
|
server.NodeManager.VariableCount.ShouldBe(0);
|
|
|
|
// After rebuild, subsequent writes start fresh.
|
|
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow);
|
|
server.NodeManager.VariableCount.ShouldBe(1);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NullOpcUaAddressSpaceSink_does_not_crash_on_any_call()
|
|
{
|
|
// Sanity check that the F10 fallback still works — production callers default to
|
|
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
|
|
var sink = NullOpcUaAddressSpaceSink.Instance;
|
|
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
|
sink.WriteAlarmState("a", true, false, DateTime.UtcNow);
|
|
sink.RebuildAddressSpace();
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
|
{
|
|
var host = new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.SinkTest",
|
|
ApplicationUri = $"urn:OtOpcUa.SinkTest:{Guid.NewGuid():N}",
|
|
OpcUaPort = AllocateFreePort(),
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance);
|
|
|
|
var server = new OtOpcUaSdkServer();
|
|
await host.StartAsync(server, Ct);
|
|
return (host, server);
|
|
}
|
|
|
|
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 cleanup */ }
|
|
}
|
|
}
|
|
}
|