7dd64b8ccd
Fixture renamed off ignitionoee's otopcua-focas-sim, port 8193 published, project label, default endpoint 10.100.0.35:8193; infra/dev-environment docs. FOCAS.md: MACRO:3901 parts count, PMC override + cycle-time recipes, won't-do/blocked backlog. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
180 lines
9.2 KiB
C#
180 lines
9.2 KiB
C#
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>10.100.0.35:8193</c>, the <c>otopcua-focas-fixture</c> container) 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A poll batch of adjacent PMC bytes is served by ONE range read against the live wire, every tag gets
|
|
/// its own value, and a tag outside the merge window still reads through the per-tag path. Seeds are
|
|
/// byte-typed: the mock serves one entry per ADDRESS for every PMC type, which matches the real
|
|
/// byte-addressed range only for width 1.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Adjacent_pmc_bytes_coalesce_into_one_range_read_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
|
|
{
|
|
pmc = new { R = new Dictionary<string, object>
|
|
{
|
|
["200"] = new { type = "byte", value = 17 },
|
|
["201"] = new { type = "byte", value = 200 },
|
|
["202"] = new { type = "byte", value = 0b0000_0100 },
|
|
["203"] = new { type = "byte", value = 9 },
|
|
["900"] = new { type = "byte", value = 33 },
|
|
}},
|
|
}, ct);
|
|
|
|
var counting = new CountingFactory();
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions(DeviceHost)],
|
|
RawTags = FocasRawTags.Entries(
|
|
new FocasTagDefinition("R200", DeviceHost, "R200", FocasDataType.Byte, Writable: false),
|
|
new FocasTagDefinition("R201", DeviceHost, "R201", FocasDataType.Byte, Writable: false),
|
|
new FocasTagDefinition("R202b2", DeviceHost, "R202.2", FocasDataType.Bit, Writable: false),
|
|
new FocasTagDefinition("R203", DeviceHost, "R203", FocasDataType.Byte, Writable: false),
|
|
new FocasTagDefinition("R900", DeviceHost, "R900", FocasDataType.Byte, Writable: false)),
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, driverInstanceId: "wire-pmc-coalesce", clientFactory: counting);
|
|
|
|
await using (drv)
|
|
{
|
|
await drv.InitializeAsync("{}", ct);
|
|
var snaps = await drv.ReadAsync(["R200", "R201", "R202b2", "R203", "R900"], ct);
|
|
|
|
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
|
Convert.ToInt32(snaps[0].Value).ShouldBe(17);
|
|
Convert.ToInt32(snaps[1].Value).ShouldBe(-56); // 200 as a signed byte
|
|
snaps[2].Value.ShouldBe(true);
|
|
Convert.ToInt32(snaps[3].Value).ShouldBe(9);
|
|
Convert.ToInt32(snaps[4].Value).ShouldBe(33);
|
|
|
|
counting.Client.ShouldNotBeNull();
|
|
counting.Client.RangeReads.ShouldBe(1, "R200..R203 merge into one pmc_rdpmcrng");
|
|
counting.Client.SingleReads.ShouldBe(1, "R900 is outside the gap window and reads on its own");
|
|
}
|
|
}
|
|
|
|
private sealed class CountingFactory : IFocasClientFactory
|
|
{
|
|
private readonly WireFocasClientFactory _inner = new();
|
|
public CountingClient? Client { get; private set; }
|
|
public void EnsureUsable() => _inner.EnsureUsable();
|
|
public IFocasClient Create() => Client = new CountingClient(_inner.Create());
|
|
}
|
|
|
|
private sealed class CountingClient(IFocasClient inner) : IFocasClient
|
|
{
|
|
public int RangeReads { get; private set; }
|
|
public int SingleReads { get; private set; }
|
|
public bool IsConnected => inner.IsConnected;
|
|
public Task ConnectAsync(FocasHostAddress a, TimeSpan t, CancellationToken ct) => inner.ConnectAsync(a, t, ct);
|
|
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct)
|
|
{ SingleReads++; return inner.ReadAsync(a, t, ct); }
|
|
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string l, FocasDataType t, int s, int e, CancellationToken ct)
|
|
{ RangeReads++; return inner.ReadPmcSlotsAsync(l, t, s, e, ct); }
|
|
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => inner.WriteAsync(a, t, v, ct);
|
|
public Task<bool> ProbeAsync(CancellationToken ct) => inner.ProbeAsync(ct);
|
|
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) => inner.ReadAlarmsAsync(ct);
|
|
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => inner.GetSysInfoAsync(ct);
|
|
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) => inner.GetAxisNamesAsync(ct);
|
|
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) => inner.GetSpindleNamesAsync(ct);
|
|
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int i, CancellationToken ct) => inner.ReadDynamicAsync(i, ct);
|
|
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) => inner.GetProgramInfoAsync(ct);
|
|
public Task<FocasStatus> GetStatusAsync(CancellationToken ct) => inner.GetStatusAsync(ct);
|
|
public Task<FocasTimer> GetTimerAsync(FocasTimerKind k, CancellationToken ct) => inner.GetTimerAsync(k, ct);
|
|
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) => inner.GetServoLoadsAsync(ct);
|
|
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) => inner.GetSpindleLoadsAsync(ct);
|
|
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) => inner.GetSpindleMotorSpeedsAsync(ct);
|
|
public void Dispose() => inner.Dispose();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|