Closes #259 Adds Modal/ + Override/ fixed-tree subfolders per FOCAS device, mirroring the pattern established by Status/ (#257) and Production/ (#258): cached snapshots refreshed on the probe tick, served from cache on read, no extra wire traffic on top of user-driven tag reads. Modal/ surfaces the four universally-present aux modal codes M/S/T/B from cnc_modal(type=100..103) as Int16. **G-group decoding (groups 1..21) is deferred to a follow-up** — the FWLIB ODBMDL union differs per series + group and the issue body explicitly permits this scoping. Adds the cnc_modal P/Invoke + ODBMDL struct + a generic int16 cnc_rdparam helper so the follow-up can add G-groups without further wire-level scaffolding. Override/ surfaces Feed/Rapid/Spindle/Jog from cnc_rdparam at MTB-specific parameter numbers (FocasDeviceOptions.OverrideParameters; defaults to 30i: 6010/6011/6014/6015). Per-field nullable params let a deployment hide overrides their MTB doesn't wire up; passing OverrideParameters=null suppresses the entire Override/ subfolder for that device. 6 unit tests cover discovery shape, omitted Override folder when unconfigured, partial Override field selection, cached-snapshot reads (Modal + Override), BadCommunicationError before first refresh, and the FwlibFocasClient disconnected short-circuit.
232 lines
9.6 KiB
C#
232 lines
9.6 KiB
C#
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;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasModalOverrideFixedTreeTests
|
|
{
|
|
private const string Host = "focas://10.0.0.6:8193";
|
|
|
|
/// <summary>
|
|
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
|
/// <see cref="FocasModalInfo"/> + <see cref="FocasOverrideInfo"/> snapshots.
|
|
/// </summary>
|
|
private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
|
{
|
|
public FocasModalInfo? Modal { get; set; }
|
|
public FocasOverrideInfo? Override { get; set; }
|
|
public FocasOverrideParameters? LastOverrideParams { get; private set; }
|
|
|
|
Task<FocasModalInfo?> IFocasClient.GetModalAsync(CancellationToken ct) =>
|
|
Task.FromResult(Modal);
|
|
|
|
Task<FocasOverrideInfo?> IFocasClient.GetOverrideAsync(
|
|
FocasOverrideParameters parameters, CancellationToken ct)
|
|
{
|
|
LastOverrideParams = parameters;
|
|
return Task.FromResult(Override);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")],
|
|
Tags = [],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "drv-modal", new FakeFocasClientFactory());
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal");
|
|
var modalVars = builder.Variables.Where(v =>
|
|
v.Info.FullName.Contains("::Modal/")).ToList();
|
|
modalVars.Count.ShouldBe(4);
|
|
string[] expected = ["MCode", "SCode", "TCode", "BCode"];
|
|
foreach (var name in expected)
|
|
{
|
|
var node = modalVars.SingleOrDefault(v => v.BrowseName == name);
|
|
node.BrowseName.ShouldBe(name);
|
|
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
|
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
|
node.Info.FullName.ShouldBe($"{Host}::Modal/{name}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured()
|
|
{
|
|
var builder = new RecordingBuilder();
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null
|
|
Tags = [],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "drv-no-overrides", new FakeFocasClientFactory());
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Folders.ShouldNotContain(f => f.BrowseName == "Override");
|
|
builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscoverAsync_emits_only_configured_Override_fields()
|
|
{
|
|
// Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up.
|
|
var builder = new RecordingBuilder();
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices =
|
|
[
|
|
new FocasDeviceOptions(Host,
|
|
OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)),
|
|
],
|
|
Tags = [],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "drv-partial-overrides", new FakeFocasClientFactory());
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
|
|
|
builder.Folders.ShouldContain(f => f.BrowseName == "Override");
|
|
var overrideVars = builder.Variables.Where(v =>
|
|
v.Info.FullName.Contains("::Override/")).ToList();
|
|
overrideVars.Count.ShouldBe(2);
|
|
overrideVars.ShouldContain(v => v.BrowseName == "Feed");
|
|
overrideVars.ShouldContain(v => v.BrowseName == "Rapid");
|
|
overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle");
|
|
overrideVars.ShouldNotContain(v => v.BrowseName == "Jog");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot()
|
|
{
|
|
var fake = new ModalAwareFakeFocasClient
|
|
{
|
|
Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0),
|
|
Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25),
|
|
};
|
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices =
|
|
[
|
|
new FocasDeviceOptions(Host,
|
|
OverrideParameters: FocasOverrideParameters.Default),
|
|
],
|
|
Tags = [],
|
|
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
|
}, "drv-modal-read", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Wait for at least one probe tick to populate both caches.
|
|
await WaitForAsync(async () =>
|
|
{
|
|
var snap = (await drv.ReadAsync(
|
|
[$"{Host}::Modal/MCode"], CancellationToken.None)).Single();
|
|
return snap.StatusCode == FocasStatusMapper.Good;
|
|
}, TimeSpan.FromSeconds(3));
|
|
|
|
var refs = new[]
|
|
{
|
|
$"{Host}::Modal/MCode",
|
|
$"{Host}::Modal/SCode",
|
|
$"{Host}::Modal/TCode",
|
|
$"{Host}::Modal/BCode",
|
|
$"{Host}::Override/Feed",
|
|
$"{Host}::Override/Rapid",
|
|
$"{Host}::Override/Spindle",
|
|
$"{Host}::Override/Jog",
|
|
};
|
|
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
|
|
|
snaps[0].Value.ShouldBe((short)8);
|
|
snaps[1].Value.ShouldBe((short)1200);
|
|
snaps[2].Value.ShouldBe((short)101);
|
|
snaps[3].Value.ShouldBe((short)0);
|
|
snaps[4].Value.ShouldBe((short)100);
|
|
snaps[5].Value.ShouldBe((short)50);
|
|
snaps[6].Value.ShouldBe((short)110);
|
|
snaps[7].Value.ShouldBe((short)25);
|
|
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
|
// The driver hands the device's configured override parameters to the wire client
|
|
// verbatim — defaulting to 30i numbers.
|
|
fake.LastOverrideParams.ShouldNotBeNull();
|
|
fake.LastOverrideParams!.FeedParam.ShouldBe<ushort?>(6010);
|
|
fake.LastOverrideParams.RapidParam.ShouldBe<ushort?>(6011);
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
|
{
|
|
// Probe disabled — neither modal nor override caches populate; the nodes still
|
|
// resolve as known references but report Bad until the first successful poll.
|
|
var drv = new FocasDriver(new FocasDriverOptions
|
|
{
|
|
Devices =
|
|
[
|
|
new FocasDeviceOptions(Host,
|
|
OverrideParameters: FocasOverrideParameters.Default),
|
|
],
|
|
Tags = [],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
}, "drv-empty-cache", new FakeFocasClientFactory());
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var snaps = await drv.ReadAsync(
|
|
[$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None);
|
|
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
|
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FwlibFocasClient_GetModal_and_GetOverride_return_null_when_disconnected()
|
|
{
|
|
// Construction is licence-safe (no DLL load); the unconnected client must short-
|
|
// circuit before P/Invoke. Returns null → driver leaves the cache untouched.
|
|
var client = new FwlibFocasClient();
|
|
(await client.GetModalAsync(CancellationToken.None)).ShouldBeNull();
|
|
(await client.GetOverrideAsync(
|
|
FocasOverrideParameters.Default, CancellationToken.None)).ShouldBeNull();
|
|
}
|
|
|
|
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (!await condition() && DateTime.UtcNow < deadline)
|
|
await Task.Delay(20);
|
|
}
|
|
|
|
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) { } }
|
|
}
|
|
}
|