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:
Joseph Doherty
2026-04-25 14:26:48 -04:00
parent ae7cc15178
commit 3c2c4f29ea
6 changed files with 563 additions and 1 deletions

View File

@@ -28,6 +28,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _modalNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
new(StringComparer.OrdinalIgnoreCase);
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>
@@ -50,6 +54,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
];
/// <summary>
/// Names of the active modal aux-code child nodes per device — M/S/T/B from
/// <c>cnc_modal(type=100..103)</c> (issue #259). G-group decoding is a deferred
/// follow-up because the FWLIB <c>ODBMDL</c> union varies per series + group.
/// </summary>
private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"];
/// <summary>
/// Names of the four operator-override child nodes per device — Feed / Rapid /
/// Spindle / Jog from <c>cnc_rdparam</c> with MTB-specific parameter numbers
/// (issue #259). A device whose <c>FocasOverrideParameters</c> entry is null for a
/// given field has the matching node omitted from the address space.
/// </summary>
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -110,6 +129,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
foreach (var field in ProductionFieldNames)
_productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
foreach (var field in ModalFieldNames)
_modalNodesByName[ModalReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
if (device.Options.OverrideParameters is { } op)
{
foreach (var field in OverrideFieldNames)
{
if (OverrideParamFor(op, field) is null) continue;
_overrideNodesByName[OverrideReferenceFor(device.Options.HostAddress, field)] =
(device.Options.HostAddress, field);
}
}
}
if (_options.Probe.Enabled)
@@ -151,6 +182,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_tagsByName.Clear();
_statusNodesByName.Clear();
_productionNodesByName.Clear();
_modalNodesByName.Clear();
_overrideNodesByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -191,6 +224,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue;
}
// Fixed-tree Modal/ + Override/ nodes — served from per-device cached snapshots
// refreshed on the probe tick (issue #259). Same cache-or-Bad policy as Status/.
if (_modalNodesByName.TryGetValue(reference, out var modalKey))
{
results[i] = ReadModalField(modalKey.Host, modalKey.Field, now);
continue;
}
if (_overrideNodesByName.TryGetValue(reference, out var overrideKey))
{
results[i] = ReadOverrideField(overrideKey.Host, overrideKey.Field, now);
continue;
}
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
@@ -347,6 +393,48 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Modal/ subfolder — 4 read-only Int16 nodes for the universally-
// present aux modal codes M/S/T/B from cnc_modal(type=100..103). G-group
// surfaces are deferred to a follow-up because the FWLIB ODBMDL union varies
// per series + group (issue #259, plan PR F1-c).
var modalFolder = deviceFolder.Folder("Modal", "Modal");
foreach (var field in ModalFieldNames)
{
var fullRef = ModalReferenceFor(device.HostAddress, field);
modalFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Fixed-tree Override/ subfolder — Feed / Rapid / Spindle / Jog from
// cnc_rdparam at MTB-specific parameter numbers (issue #259). Suppressed when
// OverrideParameters is null; per-field nodes whose parameter is null are
// omitted so a deployment can hide overrides their MTB doesn't wire up.
if (device.OverrideParameters is { } overrideParams)
{
var overrideFolder = deviceFolder.Folder("Override", "Override");
foreach (var field in OverrideFieldNames)
{
if (OverrideParamFor(overrideParams, field) is null) continue;
var fullRef = OverrideReferenceFor(device.HostAddress, field);
overrideFolder.Variable(field, field, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.Int16,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
}
return Task.CompletedTask;
}
@@ -357,6 +445,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private static string ProductionReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Production/{field}";
private static string ModalReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Modal/{field}";
private static string OverrideReferenceFor(string hostAddress, string field) =>
$"{hostAddress}::Override/{field}";
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
{
"Feed" => p.FeedParam,
"Rapid" => p.RapidParam,
"Spindle" => p.SpindleParam,
"Jog" => p.JogParam,
_ => null,
};
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
{
"Tmmode" => s.Tmmode,
@@ -380,6 +483,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_ => null,
};
private static short? PickModalField(FocasModalInfo m, string field) => field switch
{
"MCode" => m.MCode,
"SCode" => m.SCode,
"TCode" => m.TCode,
"BCode" => m.BCode,
_ => null,
};
private static short? PickOverrideField(FocasOverrideInfo o, string field) => field switch
{
"Feed" => o.Feed,
"Rapid" => o.Rapid,
"Spindle" => o.Spindle,
"Jog" => o.Jog,
_ => null,
};
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
@@ -427,6 +548,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.LastProduction = production;
state.LastProductionUtc = DateTime.UtcNow;
}
// Modal aux M/S/T/B + per-device operator overrides — same best-effort
// policy as Status/ + Production/. Override snapshot is suppressed when
// the device has no OverrideParameters configured (issue #259).
var modal = await client.GetModalAsync(ct).ConfigureAwait(false);
if (modal is not null)
{
state.LastModal = modal;
state.LastModalUtc = DateTime.UtcNow;
}
if (state.Options.OverrideParameters is { } overrideParams)
{
var ov = await client.GetOverrideAsync(overrideParams, ct).ConfigureAwait(false);
if (ov is not null)
{
state.LastOverride = ov;
state.LastOverrideUtc = DateTime.UtcNow;
}
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
@@ -465,6 +604,32 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
device.LastProductionUtc, now);
}
private DataValueSnapshot ReadModalField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastModal is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickModalField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastModalUtc, now);
}
private DataValueSnapshot ReadOverrideField(string hostAddress, string field, DateTime now)
{
if (!_devices.TryGetValue(hostAddress, out var device))
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
if (device.LastOverride is not { } snap)
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
var value = PickOverrideField(snap, field);
if (value is null)
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
device.LastOverrideUtc, now);
}
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
@@ -536,6 +701,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public FocasProductionInfo? LastProduction { get; set; }
public DateTime LastProductionUtc { get; set; }
/// <summary>
/// Cached <c>cnc_modal</c> M/S/T/B snapshot, refreshed on every probe tick.
/// Reads of the per-device <c>Modal/&lt;field&gt;</c> nodes serve from this cache
/// so they don't pile extra wire traffic on top of user-driven reads (issue #259).
/// </summary>
public FocasModalInfo? LastModal { get; set; }
public DateTime LastModalUtc { get; set; }
/// <summary>
/// Cached <c>cnc_rdparam</c> override snapshot, refreshed on every probe tick.
/// Suppressed when the device's <see cref="FocasDeviceOptions.OverrideParameters"/>
/// is null (no <c>Override/</c> nodes are exposed in that case — issue #259).
/// </summary>
public FocasOverrideInfo? LastOverride { get; set; }
public DateTime LastOverrideUtc { get; set; }
public void DisposeClient()
{
Client?.Dispose();

View File

@@ -17,11 +17,15 @@ public sealed class FocasDriverOptions
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
/// </summary>
public sealed record FocasDeviceOptions(
string HostAddress,
string? DeviceName = null,
FocasCncSeries Series = FocasCncSeries.Unknown);
FocasCncSeries Series = FocasCncSeries.Unknown,
FocasOverrideParameters? OverrideParameters = null);
/// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS

View File

@@ -187,6 +187,61 @@ internal sealed class FwlibFocasClient : IFocasClient
return true;
}
private bool TryReadInt16Param(ushort number, out short value)
{
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf);
if (ret != 0) { value = 0; return false; }
value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
return true;
}
public Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasModalInfo?>(null);
// type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0).
// Best-effort — if any single read fails we still surface the others as 0; the
// probe loop only updates the cache on a non-null return so a partial snapshot
// is preferable to throwing away every successful field.
return Task.FromResult<FocasModalInfo?>(new FocasModalInfo(
MCode: ReadModalAux(type: 100),
SCode: ReadModalAux(type: 101),
TCode: ReadModalAux(type: 102),
BCode: ReadModalAux(type: 103)));
}
private short ReadModalAux(short type)
{
var buf = new FwlibNative.ODBMDL { Data = new byte[8] };
var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf);
if (ret != 0) return 0;
// For aux types (100..103) the union holds the code at offset 0 as a 2-byte
// value (<c>aux_data</c>). Reading as Int16 keeps the surface identical to the
// record contract; oversized values would have been truncated by FWLIB anyway.
return BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
}
public Task<FocasOverrideInfo?> GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken cancellationToken)
{
if (!_connected) return Task.FromResult<FocasOverrideInfo?>(null);
// Each parameter is independently nullable — a null parameter number keeps the
// corresponding field at null + skips the wire call. A successful read on at
// least one parameter is enough to publish a snapshot; this matches the
// best-effort policy used by GetProductionAsync (issue #259).
var feed = TryReadOverride(parameters.FeedParam);
var rapid = TryReadOverride(parameters.RapidParam);
var spindle = TryReadOverride(parameters.SpindleParam);
var jog = TryReadOverride(parameters.JogParam);
return Task.FromResult<FocasOverrideInfo?>(new FocasOverrideInfo(feed, rapid, spindle, jog));
}
private short? TryReadOverride(ushort? param)
{
if (param is null) return null;
return TryReadInt16Param(param.Value, out var v) ? v : null;
}
// ---- PMC ----
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)

View File

@@ -99,6 +99,19 @@ internal static class FwlibNative
[DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)]
public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer);
// ---- Modal codes ----
/// <summary>
/// <c>cnc_modal</c> — read modal information for one G-group or auxiliary code.
/// <paramref name="type"/>: 1..21 = G-group N (single group), 100 = M, 101 = S,
/// 102 = T, 103 = B (per Fanuc FOCAS reference). <paramref name="block"/>: 0 =
/// active modal commands. We only consume types 100..103 today (M/S/T/B); the
/// G-group decode is deferred to a follow-up because the <c>ODBMDL</c> union
/// varies by group + series (issue #259).
/// </summary>
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
// ---- Structs ----
/// <summary>
@@ -151,6 +164,22 @@ internal static class FwlibNative
public int Msec;
}
/// <summary>
/// ODBMDL — single-group modal read buffer. 4-byte header + a 4-byte union which we
/// marshal as a fixed byte array. For type=100..103 (M/S/T/B) the union holds an
/// <c>int aux_data</c> at offset 0; we read the first <c>short</c> for symmetry with
/// the FWLIB <c>g_modal.aux_data</c> width on G-group reads. The G-group decode
/// (type=1..21) is deferred — see <see cref="Modal"/> for context (issue #259).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBMDL
{
public short Datano;
public short Type;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
public byte[] Data;
}
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBST

View File

@@ -70,6 +70,27 @@ public interface IFocasClient : IDisposable
/// </summary>
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasProductionInfo?>(null);
/// <summary>
/// Read the active modal M/S/T/B codes via <c>cnc_modal</c>. G-group decoding is
/// deferred — the FWLIB <c>ODBMDL</c> union differs per series + group and the
/// issue body permits surfacing only the universally-present M/S/T/B fields in
/// the first cut (issue #259). Returns <c>null</c> when the wire client cannot
/// supply the snapshot.
/// </summary>
Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasModalInfo?>(null);
/// <summary>
/// Read the four operator override values (feed / rapid / spindle / jog) via
/// <c>cnc_rdparam</c>. The parameter numbers are MTB-specific so the caller passes
/// them in via <paramref name="parameters"/>; a <c>null</c> entry suppresses that
/// field's read (the corresponding node is also omitted from the address space).
/// Returns <c>null</c> when the wire client cannot supply the snapshot (issue #259).
/// </summary>
Task<FocasOverrideInfo?> GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken cancellationToken)
=> Task.FromResult<FocasOverrideInfo?>(null);
}
/// <summary>
@@ -102,6 +123,47 @@ public sealed record FocasProductionInfo(
int PartsTotal,
int CycleTimeSeconds);
/// <summary>
/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a
/// deferred follow-up — the FWLIB <c>ODBMDL</c> union differs per series + group, and
/// the issue body permits the first cut to surface only the universally-present
/// M/S/T/B fields. <c>short</c> matches the FWLIB <c>aux_data</c> width.
/// </summary>
public sealed record FocasModalInfo(
short MCode,
short SCode,
short TCode,
short BCode);
/// <summary>
/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259).
/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A
/// <c>null</c> entry suppresses that field's read on the wire and removes the matching
/// node from the address space; this lets a deployment hide overrides their MTB doesn't
/// wire up rather than always serving Bad.
/// </summary>
public sealed record FocasOverrideParameters(
ushort? FeedParam,
ushort? RapidParam,
ushort? SpindleParam,
ushort? JogParam)
{
/// <summary>Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015.</summary>
public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015);
}
/// <summary>
/// Snapshot of the four operator overrides (issue #259). Each value is a percentage
/// surfaced as <c>Int16</c>; a value of <c>null</c> means the corresponding parameter
/// was not configured (suppressed at <see cref="FocasOverrideParameters"/>). All four
/// fields nullable so the driver can omit nodes whose MTB parameter is unset.
/// </summary>
public sealed record FocasOverrideInfo(
short? Feed,
short? Rapid,
short? Spindle,
short? Jog);
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{

View File

@@ -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) { } }
}
}