diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 66663ef8..1dda4ad7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -666,8 +666,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery { foreach (var reference in fullReferences) { - if (!_resolver.TryResolve(reference, out var def)) continue; - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue; + // An unresolvable reference (unknown RawPath, or a tag routed to an unconfigured device) + // publishes BadNodeIdUnknown once — the poll path's status — instead of leaving the node on + // whatever status the server seeded it with. + if (!_resolver.TryResolve(reference, out var def) + || !_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + RaiseDataChange(handle, reference, new DataValueSnapshot( + null, TwinCATStatusMapper.BadNodeIdUnknown, null, DateTime.UtcNow)); + continue; + } var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; @@ -724,6 +732,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery return handle; } + /// Raises one data change for a native subscription. + private void RaiseDataChange(ISubscriptionHandle handle, string reference, DataValueSnapshot snapshot) => + OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, snapshot)); + /// /// Registers one native notification and records its replayable intent on the device — both /// under the device so the register can't race a diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeIsolationTests.cs new file mode 100644 index 00000000..0d04eaf6 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeIsolationTests.cs @@ -0,0 +1,59 @@ +using System.Collections.Concurrent; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +/// +/// Native-subscribe isolation (#739): an unresolvable reference publishes BadNodeIdUnknown +/// (U12) instead of keeping whatever status the server seeded, and one failing registration does not +/// take down the rest of the subscription (H11). +/// +[Trait("Category", "Unit")] +public sealed class TwinCATNativeIsolationTests +{ + private const string Host = "ads://5.23.91.23.1.1:851"; + + private static TwinCATDriver NewNativeDriver(FakeTwinCATClientFactory factory, params TwinCATTagDefinition[] tags) => + new(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(Host)], + RawTags = TwinCATRawTags.Entries(tags), + Probe = new TwinCATProbeOptions { Enabled = false }, + UseNativeNotifications = true, + }, "drv-1", factory); + + /// + /// U12: an unknown reference and a tag routed to an unconfigured device each publish one + /// BadNodeIdUnknown snapshot; the resolvable tag still registers and delivers Good (positive control). + /// + [Fact] + public async Task Native_subscribe_publishes_BadNodeIdUnknown_for_unresolvable_references() + { + var factory = new FakeTwinCATClientFactory(); + var drv = NewNativeDriver(factory, + new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt), + new TwinCATTagDefinition("Orphan", "ads://9.9.9.9.1.1:851", "MAIN.Orphan", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var events = new ConcurrentQueue(); + drv.OnDataChange += (_, e) => events.Enqueue(e); + + _ = await drv.SubscribeAsync(["Ghost", "Speed", "Orphan"], TimeSpan.FromMilliseconds(100), + TestContext.Current.CancellationToken); + + var ghost = events.Single(e => e.FullReference == "Ghost"); + ghost.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); + ghost.Snapshot.Value.ShouldBeNull(); + events.Single(e => e.FullReference == "Orphan").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); + + // Positive control: the resolvable tag registered and its push arrives Good. + factory.Clients.ShouldHaveSingleItem().Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Speed"); + factory.Clients[0].FireNotification("MAIN.Speed", 42); + var good = events.Single(e => e.FullReference == "Speed"); + good.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good); + good.Snapshot.Value.ShouldBe(42); + } +}