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() { }
+ }
+ }
+}