diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs new file mode 100644 index 0000000..4899c54 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs @@ -0,0 +1,27 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Optional control-plane capability — drivers whose backend exposes a way to refresh +/// the symbol table on-demand (without tearing the driver down) implement this so the +/// Admin UI / CLI can trigger a re-walk in response to an operator action. +/// +/// +/// Distinct from : that interface is the driver telling Core +/// a refresh is needed; this one is Core asking the driver to refresh now. For drivers that +/// implement both, the typical wiring is "operator clicks Rebrowse → Core calls +/// → driver re-walks → driver fires +/// OnRediscoveryNeeded so the address space is rebuilt". +/// +/// For AB CIP this is the "force re-walk of @tags" hook — useful after a controller +/// program download added new tags but the static config still drives the address space. +/// +public interface IDriverControl +{ + /// + /// Re-run the driver's discovery pass against live backend state and stream the + /// resulting nodes through the supplied builder. Implementations must be safe to call + /// concurrently with reads / writes; they typically serialize internally so a second + /// concurrent rebrowse waits for the first to complete rather than racing it. + /// + Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/RebrowseCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/RebrowseCommand.cs new file mode 100644 index 0000000..26f0106 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/RebrowseCommand.cs @@ -0,0 +1,107 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands; + +/// +/// Force a controller-side @tags re-walk on a live AbCip driver instance. Issue #233 — +/// online tag-DB refresh trigger. The CLI variant builds a transient driver against the +/// supplied gateway, runs , and prints the freshly +/// discovered tag names. In-server (Tier-A) operators wire this same call to an Admin UI +/// button so a controller program-download is reflected in the address space without a +/// driver restart. +/// +[Command("rebrowse", Description = + "Re-walk the AB CIP controller symbol table (force @tags refresh) and print discovered tags.")] +public sealed class RebrowseCommand : AbCipCommandBase +{ + public override async ValueTask ExecuteAsync(IConsole console) + { + ConfigureLogging(); + var ct = console.RegisterCancellationHandler(); + + // EnableControllerBrowse must be true for the @tags walk to happen; the CLI baseline + // (BuildOptions in AbCipCommandBase) leaves it off for one-shot probes, so we flip it + // here without touching the base helper. + var baseOpts = BuildOptions(tags: []); + var options = new AbCipDriverOptions + { + Devices = baseOpts.Devices, + Tags = baseOpts.Tags, + Timeout = baseOpts.Timeout, + Probe = baseOpts.Probe, + EnableControllerBrowse = true, + EnableAlarmProjection = false, + }; + + await using var driver = new AbCipDriver(options, DriverInstanceId); + try + { + await driver.InitializeAsync("{}", ct); + + var builder = new ConsoleAddressSpaceBuilder(); + await driver.RebrowseAsync(builder, ct); + + await console.Output.WriteLineAsync($"Gateway: {Gateway}"); + await console.Output.WriteLineAsync($"Family: {Family}"); + await console.Output.WriteLineAsync($"Variables: {builder.VariableCount}"); + await console.Output.WriteLineAsync(); + foreach (var line in builder.Lines) + await console.Output.WriteLineAsync(line); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + /// + /// Minimal in-memory that flattens the tree to one + /// line per variable for CLI display. Folder nesting is captured in the prefix so the + /// operator can see the same shape the in-server builder would receive. + /// + private sealed class ConsoleAddressSpaceBuilder : IAddressSpaceBuilder + { + private readonly string _prefix; + private readonly Counter _counter; + public List Lines { get; } + public int VariableCount => _counter.Count; + + public ConsoleAddressSpaceBuilder() : this("", new List(), new Counter()) { } + private ConsoleAddressSpaceBuilder(string prefix, List sharedLines, Counter counter) + { + _prefix = prefix; + Lines = sharedLines; + _counter = counter; + } + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + var newPrefix = string.IsNullOrEmpty(_prefix) ? browseName : $"{_prefix}/{browseName}"; + return new ConsoleAddressSpaceBuilder(newPrefix, Lines, _counter); + } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) + { + _counter.Count++; + Lines.Add($" {_prefix}/{browseName} ({info.DriverDataType}, {info.SecurityClass})"); + return new Handle(info.FullName); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Counter { public int Count; } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index d89fb51..5075db4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -22,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// and reconnects each device. /// public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, - IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable + IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDriverControl, IDisposable, IAsyncDisposable { private readonly AbCipDriverOptions _options; private readonly string _driverInstanceId; @@ -34,6 +34,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly AbCipAlarmProjection _alarmProjection; + private readonly SemaphoreSlim _discoverySemaphore = new(1, 1); private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? OnDataChange; @@ -967,6 +968,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); + await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false); + } + finally + { + _discoverySemaphore.Release(); + } + } + + /// + /// PR abcip-2.5 — operator-triggered rebrowse. Drops the cached UDT template shapes so + /// the next read re-fetches them from the controller, then runs the same enumerator + /// walk + builder fan-out that drives. Serialised against + /// other rebrowse / discovery passes via so two + /// concurrent triggers don't double-issue the @tags read. + /// + public async Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(builder); + await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Stale template shapes can outlive a controller program-download, so a rebrowse + // is the natural moment to drop them; subsequent UDT reads re-populate on demand. + _templateCache.Clear(); + await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false); + } + finally + { + _discoverySemaphore.Release(); + } + } + + private async Task DiscoverCoreAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { var root = builder.Folder("AbCip", "AbCip"); foreach (var device in _options.Devices) @@ -1076,6 +1114,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, public async ValueTask DisposeAsync() { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); + _discoverySemaphore.Dispose(); } /// diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs new file mode 100644 index 0000000..7233bc2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs @@ -0,0 +1,141 @@ +using System.Runtime.CompilerServices; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Issue #233 — RebrowseAsync forces a re-walk of the controller symbol table without +/// restarting the driver. Tests cover the call-counting contract (each invocation issues +/// a fresh enumeration pass), the IDriverControl interface implementation, and that the +/// UDT template cache is dropped so stale shapes don't survive a program-download. +/// +[Trait("Category", "Unit")] +public sealed class AbCipRebrowseTests +{ + [Fact] + public async Task RebrowseAsync_runs_enumerator_once_per_call() + { + var factory = new CountingEnumeratorFactory( + new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false)); + + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + EnableControllerBrowse = true, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None); + factory.CreateCount.ShouldBe(1); + factory.EnumerationCount.ShouldBe(1); + + await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None); + factory.CreateCount.ShouldBe(2); + factory.EnumerationCount.ShouldBe(2); + } + + [Fact] + public async Task RebrowseAsync_emits_discovered_tags_through_supplied_builder() + { + var factory = new CountingEnumeratorFactory( + new AbCipDiscoveredTag("NewTag", null, AbCipDataType.DInt, ReadOnly: false)); + + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + EnableControllerBrowse = true, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var builder = new RecordingBuilder(); + await drv.RebrowseAsync(builder, CancellationToken.None); + + builder.Variables.Select(v => v.Info.FullName).ShouldContain("NewTag"); + } + + [Fact] + public async Task RebrowseAsync_clears_template_cache() + { + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + drv.TemplateCache.Put("ab://10.0.0.5/1,0", 42, new AbCipUdtShape("T", 4, [])); + drv.TemplateCache.Count.ShouldBe(1); + + await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None); + + drv.TemplateCache.Count.ShouldBe(0); + } + + [Fact] + public async Task AbCipDriver_implements_IDriverControl() + { + await using var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1"); + drv.ShouldBeAssignableTo(); + } + + // ---- helpers ---- + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { Folders.Add((browseName, displayName)); return this; } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) + { Variables.Add((browseName, info)); return new Handle(info.FullName); } + + public void AddProperty(string _, DriverDataType __, object? ___) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } + + /// + /// Tracks both calls (one per discovery / rebrowse pass) and + /// (incremented when the resulting enumerator is + /// actually iterated). Two consecutive RebrowseAsync calls must bump both counters. + /// + private sealed class CountingEnumeratorFactory : IAbCipTagEnumeratorFactory + { + private readonly AbCipDiscoveredTag[] _tags; + public int CreateCount { get; private set; } + public int EnumerationCount { get; private set; } + + public CountingEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags; + + public IAbCipTagEnumerator Create() + { + CreateCount++; + return new CountingEnumerator(this); + } + + private sealed class CountingEnumerator(CountingEnumeratorFactory outer) : IAbCipTagEnumerator + { + public async IAsyncEnumerable EnumerateAsync( + AbCipTagCreateParams deviceParams, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + outer.EnumerationCount++; + await Task.CompletedTask; + foreach (var t in outer._tags) yield return t; + } + public void Dispose() { } + } + } +}