From 26b512adbaf473c611173cb4f5276cd08e246667 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 14:08:26 -0400 Subject: [PATCH] fix(focas): a missing O-number or refused program sub-read goes Bad, not a Good 0 WireFocasClient.GetProgramInfoAsync defaulted every field the CNC did not report: ONumber ?? 0 when the cnc_exeprgname2 payload was too short to carry it, and Name '' / BlockCount 0 / Mode 0 when the sub-read was refused. 0 is a genuine O-number (a 31i reports 0 for a path-executed program -- live Z-34184), a genuine block count, and Mode 0 is MDI, a real operating mode. FocasProgramInfo fields are now nullable; MapProgramInfo leaves unreported fields null and the fixed tree publishes them BadNoData while the bundle's other fields stay Good. Ported from the sister ignitionoee FOCAS driver (101a3b1, 2026-08-12). --- .../FocasDriver.cs | 16 +++- .../IFocasClient.cs | 11 ++- .../Wire/WireFocasClient.cs | 27 +++++- .../FocasProgramInfoQualityTests.cs | 92 +++++++++++++++++++ 4 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProgramInfoQualityTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index a233f294..bbf4d0e9 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -1307,8 +1307,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, "Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber, "Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber, "OperationMode/Mode" => state.LastProgramInfo?.Mode, - "OperationMode/ModeText" => state.LastProgramInfo is { } pi - ? FocasOpMode.ToText(pi.Mode) : null, + "OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode + ? FocasOpMode.ToText(mode) : null, "Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn), "Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating), "Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting), @@ -1317,6 +1317,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, }; if (value is not null) return new DataValueSnapshot(value, status, sourceUtc, now); + + // The program bundle has been read, but the CNC did not report this field (a refused + // sub-read, or no O-number in the payload). That is Bad — never a fabricated 0, and + // not "no such node" either. + if (state.LastProgramInfo is not null + && path is "Program/Name" or "Program/ONumber" or "Program/BlockCount" + or "OperationMode/Mode" or "OperationMode/ModeText") + return new DataValueSnapshot( + null, + status == FocasStatusMapper.Good ? FocasStatusMapper.BadNoData : status, + sourceUtc, + now); } return null; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 0bac3114..2d5d8725 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -238,12 +238,15 @@ public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix /// Program + operation-mode snapshot. Name is the currently-executing /// program filename (e.g. "O0001.NC"); ONumber is its Fanuc O-number (1-9999). /// Mode is the numeric code from cnc_rdopmode — see . +/// A null field means the CNC did not report it (its sub-read was refused, or the +/// payload was too short to carry it); the driver publishes that node Bad. It is never +/// defaulted to 0, which is a genuine O-number, block count, and mode (MDI). /// public sealed record FocasProgramInfo( - string Name, - int ONumber, - int BlockCount, - int Mode); + string? Name, + int? ONumber, + int? BlockCount, + int? Mode); /// Human-readable text for the integer. public static class FocasOpMode diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs index 5f3f7c61..acc104c1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs @@ -217,12 +217,29 @@ public sealed class WireFocasClient : IFocasClient throw new InvalidOperationException( $"cnc_exeprgname2 failed EW_{nameResult.Rc} and cnc_rdopmode failed EW_{modeResult.Rc}."); - var wireName = nameResult.Value; + return MapProgramInfo(nameResult, blkResult, modeResult); + } + + /// + /// Combine the three program sub-reads into a , leaving any + /// field the CNC did not report null. A refused read, or an cnc_exeprgname2 payload + /// too short to carry the trailing O-number, used to publish a fabricated 0 at Good + /// quality — indistinguishable from a real 0 (a 31i reports O-number 0 for a program run + /// by path) and, for the mode, from MDI. Ported from the sister ignitionoee driver (101a3b1). + /// + /// The cnc_exeprgname2 result. + /// The cnc_rdblkcount result. + /// The cnc_rdopmode result. + /// The snapshot, null where the CNC did not say. + internal static FocasProgramInfo MapProgramInfo( + FocasResult name, FocasResult blockCount, FocasResult mode) + { + var wireName = name.IsOk ? name.Value : null; return new FocasProgramInfo( - Name: wireName?.Name ?? string.Empty, - ONumber: wireName?.ONumber ?? 0, - BlockCount: blkResult.IsOk ? blkResult.Value : 0, - Mode: modeResult.IsOk ? modeResult.Value : 0); + Name: wireName?.Name, + ONumber: wireName?.ONumber, + BlockCount: blockCount.IsOk ? blockCount.Value : null, + Mode: mode.IsOk ? mode.Value : null); } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProgramInfoQualityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProgramInfoQualityTests.cs new file mode 100644 index 00000000..ad2479b1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasProgramInfoQualityTests.cs @@ -0,0 +1,92 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// "The control did not say" must read Bad, never a fabricated Good 0. Ported from the sister +/// ignitionoee FOCAS driver (commit 101a3b1, 2026-08-12): a cnc_exeprgname2 payload too +/// short to carry the O-number published 0 at Good quality — indistinguishable from a +/// 31i's genuine 0 for a path-executed program. The same idiom covered every sub-read of the +/// program bundle here: a refused cnc_rdopmode published Mode 0 (= MDI, a real +/// mode) and a refused cnc_rdblkcount a BlockCount of 0. +/// +[Trait("Category", "Unit")] +public sealed class FocasProgramInfoQualityTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static FocasResult Name(string name, int? oNumber) => new(0, new WireProgramName(name, oNumber)); + + [Fact] + public void A_payload_without_an_O_number_maps_to_null_not_zero() + { + var info = WireFocasClient.MapProgramInfo(Name("O1111", null), new(0, 42), new(0, 1)); + + info.Name.ShouldBe("O1111"); + info.ONumber.ShouldBeNull(); + info.BlockCount.ShouldBe(42); + info.Mode.ShouldBe(1); + } + + [Fact] + public void A_genuine_zero_O_number_stays_zero() + { + // Live Z-34184: a 256-byte payload whose o_num is present and genuinely 0. + WireFocasClient.MapProgramInfo(Name("//CNC_MEM/USER/LIBRARY/O1111", 0), new(0, 0), new(0, 1)) + .ONumber.ShouldBe(0); + } + + [Fact] + public void Refused_sub_reads_map_to_null_not_a_fabricated_value() + { + var info = WireFocasClient.MapProgramInfo(Name("O1", 1), new FocasResult(1, 0), new FocasResult(1, 0)); + + info.BlockCount.ShouldBeNull(); + info.Mode.ShouldBeNull("Mode 0 is MDI — a real mode, not 'unknown'"); + + var nameless = WireFocasClient.MapProgramInfo(new FocasResult(1, null), new(0, 3), new(0, 1)); + nameless.Name.ShouldBeNull(); + nameless.ONumber.ShouldBeNull(); + } + + [Fact] + public async Task A_missing_field_reads_BadNoData_while_its_siblings_stay_good() + { + var client = new FakeFocasClient { ProgramInfo = new FocasProgramInfo("O1111", null, 12, null) }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + Probe = new FocasProbeOptions { Enabled = false }, + FixedTree = new FocasFixedTreeOptions + { + Enabled = true, + PollInterval = TimeSpan.FromMilliseconds(10), + ProgramPollInterval = TimeSpan.FromMilliseconds(10), + }, + }, "drv-1", new FakeFocasClientFactory { Customise = () => client }); + await drv.InitializeAsync("{}", CancellationToken.None); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline && drv.GetDeviceState(Host)?.LastProgramInfo is null) + await Task.Delay(15); + + var read = await drv.ReadAsync( + [$"{Host}/Program/Name", $"{Host}/Program/BlockCount", $"{Host}/Program/ONumber", + $"{Host}/OperationMode/Mode", $"{Host}/OperationMode/ModeText"], + CancellationToken.None); + + read[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + read[0].Value.ShouldBe("O1111"); + read[1].StatusCode.ShouldBe(FocasStatusMapper.Good); + read[1].Value.ShouldBe(12); + read[2].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + read[2].Value.ShouldBeNull(); + read[3].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + read[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); + + await drv.ShutdownAsync(CancellationToken.None); + } +}