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

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