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);
+ }
+}