Merge pull request '[abcip] AbCip — Online tag-DB refresh trigger' (#350) from auto/abcip/2.5 into auto/driver-gaps
This commit was merged in pull request #350.
This commit is contained in:
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Distinct from <see cref="IRediscoverable"/>: 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
|
||||
/// <see cref="RebrowseAsync"/> → driver re-walks → driver fires
|
||||
/// <c>OnRediscoveryNeeded</c> 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.
|
||||
/// </remarks>
|
||||
public interface IDriverControl
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AbCipDriver.RebrowseAsync"/>, 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory <see cref="IAddressSpaceBuilder"/> 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.
|
||||
/// </summary>
|
||||
private sealed class ConsoleAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
private readonly string _prefix;
|
||||
private readonly Counter _counter;
|
||||
public List<string> Lines { get; }
|
||||
public int VariableCount => _counter.Count;
|
||||
|
||||
public ConsoleAddressSpaceBuilder() : this("", new List<string>(), new Counter()) { }
|
||||
private ConsoleAddressSpaceBuilder(string prefix, List<string> 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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||
/// </remarks>
|
||||
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<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _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<DataChangeEventArgs>? 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="DiscoverAsync"/> drives. Serialised against
|
||||
/// other rebrowse / discovery passes via <see cref="_discoverySemaphore"/> so two
|
||||
/// concurrent triggers don't double-issue the @tags read.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
141
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs
Normal file
141
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<IDriverControl>();
|
||||
}
|
||||
|
||||
// ---- 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) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks both <see cref="Create"/> calls (one per discovery / rebrowse pass) and
|
||||
/// <see cref="EnumerationCount"/> (incremented when the resulting enumerator is
|
||||
/// actually iterated). Two consecutive RebrowseAsync calls must bump both counters.
|
||||
/// </summary>
|
||||
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<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
outer.EnumerationCount++;
|
||||
await Task.CompletedTask;
|
||||
foreach (var t in outer._tags) yield return t;
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user