Auto: focas-f1c — Modal codes + overrides
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.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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) { } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user