feat(focas): cnc_statinfo status flags as Status/ fixed-tree nodes (#257)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+89
@@ -0,0 +1,89 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Live-wire coverage (focas-mock) for #257 (<c>cnc_statinfo</c> status flags) and #266 (PMC
|
||||
/// range coalescing). Skips cleanly when <c>OTOPCUA_FOCAS_SIM_ENDPOINT</c> (default
|
||||
/// <c>localhost:8193</c>) is unreachable. Read-only: no write path is exercised.
|
||||
/// </summary>
|
||||
[Collection(FocasSimCollection.Name)]
|
||||
public sealed class WireBackendStatusAndPmcTests
|
||||
{
|
||||
private readonly FocasSimFixture _fx;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="WireBackendStatusAndPmcTests"/> class.</summary>
|
||||
/// <param name="fx">The FOCAS simulator fixture.</param>
|
||||
public WireBackendStatusAndPmcTests(FocasSimFixture fx) => _fx = fx;
|
||||
|
||||
private string DeviceHost => _fx.DeviceUri;
|
||||
|
||||
/// <summary>The mock's <c>cnc_statinfo</c> flags surface through the Status/ subtree and an authored FIXED: tag.</summary>
|
||||
[Fact]
|
||||
public async Task Status_flags_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
sysinfo = new
|
||||
{
|
||||
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
|
||||
series = "30i ", version = "A1.0", axes = "1 ",
|
||||
},
|
||||
axis_names = new[] { "X" },
|
||||
statinfo = new { aut = 1, run = 3, motion = 1, mstb = 0, emergency = 0, alarm = 1, edit = 0 },
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
RawTags = FocasRawTags.Entries(
|
||||
new FocasTagDefinition("RunFlag", DeviceHost, "FIXED:Status/Run", FocasDataType.Int32, Writable: false)),
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
ProgramPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-status", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus: not null }, TimeSpan.FromSeconds(5));
|
||||
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
state.ShouldNotBeNull();
|
||||
state.LastStatus.ShouldNotBeNull();
|
||||
state.LastStatus.Run.ShouldBe(3);
|
||||
state.LastStatus.Alarm.ShouldBe(1);
|
||||
|
||||
var snaps = await driver.ReadAsync(["RunFlag", $"{DeviceHost}/Status/Alarm", $"{DeviceHost}/Status/RunText"], ct);
|
||||
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
snaps[0].Value.ShouldBe(3);
|
||||
snaps[1].Value.ShouldBe(1);
|
||||
snaps[2].Value.ShouldBe("START");
|
||||
|
||||
// The flags follow the machine.
|
||||
await _fx.PatchStateAsync(new { statinfo = new { run = 1, emergency = 1 } }, ct);
|
||||
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus.Run: 1 }, TimeSpan.FromSeconds(5));
|
||||
driver.GetDeviceState(DeviceHost)!.LastStatus!.Emergency.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,21 @@ internal class FakeFocasClient : IFocasClient
|
||||
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
|
||||
Task.FromResult(ProgramInfo);
|
||||
|
||||
/// <summary>Gets or sets the cnc_statinfo status snapshot.</summary>
|
||||
public FocasStatus Status { get; set; } = new(1, 3, 1, 0, 0, 0, 0, 0);
|
||||
/// <summary>Gets or sets a value indicating whether <see cref="GetStatusAsync"/> throws (older series / refused call).</summary>
|
||||
public bool ThrowOnStatus { get; set; }
|
||||
/// <summary>Gets the number of status reads served.</summary>
|
||||
public int StatusReads { get; private set; }
|
||||
/// <summary>Gets the CNC status flags asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<FocasStatus> GetStatusAsync(CancellationToken ct)
|
||||
{
|
||||
StatusReads++;
|
||||
if (ThrowOnStatus) throw new InvalidOperationException("cnc_statinfo refused");
|
||||
return Task.FromResult(Status);
|
||||
}
|
||||
|
||||
/// <summary>Gets the dictionary of timers keyed by timer kind.</summary>
|
||||
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
|
||||
/// <summary>Gets timer data asynchronously.</summary>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #257 — <c>cnc_statinfo</c> (Auto / Run / Motion / Mstb / Emergency / Alarm / Edit / TmMode)
|
||||
/// surfaces as a read-only <c>Status/</c> fixed-tree subtree, authorable as <c>FIXED:Status/Run</c>.
|
||||
/// The wire client already decoded the call (<c>FocasWireClient.ReadStatusAsync</c>) but nothing consumed it.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasStatusFlagsTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasDriver NewDriver(FakeFocasClient client, params FocasTagDefinition[] tags) =>
|
||||
new(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
RawTags = FocasRawTags.Entries(tags),
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(10),
|
||||
ProgramPollInterval = TimeSpan.FromMilliseconds(10),
|
||||
},
|
||||
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
|
||||
|
||||
private static async Task WaitAsync(Func<bool> predicate)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && !predicate()) await Task.Delay(15);
|
||||
predicate().ShouldBeTrue("condition never became true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_wire_status_block_maps_field_for_field()
|
||||
{
|
||||
var s = Wire.WireFocasClient.MapStatus(new Wire.WireStatus(Auto: 1, Run: 2, Motion: 3, Mstb: 4, Emergency: 5, Alarm: 6, Edit: 7, TmMode: 8));
|
||||
s.ShouldBe(new FocasStatus(1, 2, 3, 4, 5, 6, 7, 8));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "RESET")]
|
||||
[InlineData(1, "STOP")]
|
||||
[InlineData(2, "HOLD")]
|
||||
[InlineData(3, "START")]
|
||||
[InlineData(4, "MSTR")]
|
||||
[InlineData(9, "Run9")]
|
||||
public void Run_state_text(int run, string text) => FocasRunState.ToText(run).ShouldBe(text);
|
||||
|
||||
[Fact]
|
||||
public async Task Status_flags_read_good_through_the_fixed_tree_references()
|
||||
{
|
||||
var client = new FakeFocasClient { Status = new FocasStatus(Auto: 1, Run: 3, Motion: 1, Mstb: 2, Emergency: 0, Alarm: 1, Edit: 0, TmMode: 4) };
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
|
||||
|
||||
string[] fields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"];
|
||||
int[] expected = [1, 3, 1, 2, 0, 1, 0, 4];
|
||||
var read = await drv.ReadAsync([.. fields.Select(f => $"{Host}/Status/{f}")], CancellationToken.None);
|
||||
for (var i = 0; i < fields.Length; i++)
|
||||
{
|
||||
read[i].StatusCode.ShouldBe(FocasStatusMapper.Good, fields[i]);
|
||||
read[i].Value.ShouldBe(expected[i], fields[i]);
|
||||
}
|
||||
|
||||
var text = await drv.ReadAsync([$"{Host}/Status/RunText"], CancellationToken.None);
|
||||
text[0].Value.ShouldBe("START");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_authored_FIXED_Status_tag_reads_the_flag()
|
||||
{
|
||||
var client = new FakeFocasClient { Status = new FocasStatus(1, 3, 1, 0, 1, 0, 0, 0) };
|
||||
var drv = NewDriver(client,
|
||||
new FocasTagDefinition("EStop", Host, "FIXED:Status/Emergency", FocasDataType.Int32, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
|
||||
|
||||
var snap = (await drv.ReadAsync(["EStop"], CancellationToken.None))[0];
|
||||
snap.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snap.Value.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_updates_follow_the_machine()
|
||||
{
|
||||
var client = new FakeFocasClient { Status = new FocasStatus(1, 0, 0, 0, 0, 0, 0, 0) };
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
|
||||
|
||||
client.Status = new FocasStatus(1, 3, 1, 0, 0, 0, 0, 0);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus?.Run == 3);
|
||||
(await drv.ReadAsync([$"{Host}/Status/Run"], CancellationToken.None))[0].Value.ShouldBe(3);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cnc_that_refuses_statinfo_gets_no_Status_node_and_the_rest_of_the_tree_survives()
|
||||
{
|
||||
var client = new FakeFocasClient { ThrowOnStatus = true };
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "Status");
|
||||
builder.Variables.ShouldContain(v => v.Info.FullName == "FIXED:Identity/SeriesNumber");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discovery_emits_every_status_leaf_with_a_parseable_read_only_address()
|
||||
{
|
||||
var drv = NewDriver(new FakeFocasClient());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Status");
|
||||
var leaves = builder.Variables.Where(v => v.Info.FullName.StartsWith("FIXED:Status/", StringComparison.Ordinal)).ToList();
|
||||
leaves.Select(l => l.BrowseName).ShouldBe(
|
||||
["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode", "RunText"], ignoreOrder: true);
|
||||
foreach (var (_, info) in leaves)
|
||||
{
|
||||
FocasAddress.TryParse(info.FullName).ShouldNotBeNull();
|
||||
info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
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 name, DriverDataType type, object? value) { }
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user