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).
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <c>cnc_rdopmode</c> — see <see cref="FocasOpMode"/>.
|
||||
/// <b>A null field means the CNC did not report it</b> (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 <c>0</c>, which is a genuine O-number, block count, and mode (MDI).
|
||||
/// </summary>
|
||||
public sealed record FocasProgramInfo(
|
||||
string Name,
|
||||
int ONumber,
|
||||
int BlockCount,
|
||||
int Mode);
|
||||
string? Name,
|
||||
int? ONumber,
|
||||
int? BlockCount,
|
||||
int? Mode);
|
||||
|
||||
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
|
||||
public static class FocasOpMode
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combine the three program sub-reads into a <see cref="FocasProgramInfo"/>, leaving any
|
||||
/// field the CNC did not report null. A refused read, or an <c>cnc_exeprgname2</c> payload
|
||||
/// too short to carry the trailing O-number, used to publish a fabricated <c>0</c> 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).
|
||||
/// </summary>
|
||||
/// <param name="name">The <c>cnc_exeprgname2</c> result.</param>
|
||||
/// <param name="blockCount">The <c>cnc_rdblkcount</c> result.</param>
|
||||
/// <param name="mode">The <c>cnc_rdopmode</c> result.</param>
|
||||
/// <returns>The snapshot, null where the CNC did not say.</returns>
|
||||
internal static FocasProgramInfo MapProgramInfo(
|
||||
FocasResult<WireProgramName> name, FocasResult<int> blockCount, FocasResult<short> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// "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 <c>cnc_exeprgname2</c> payload too
|
||||
/// short to carry the O-number published <c>0</c> 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 <c>cnc_rdopmode</c> published Mode <c>0</c> (= MDI, a real
|
||||
/// mode) and a refused <c>cnc_rdblkcount</c> a BlockCount of 0.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasProgramInfoQualityTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasResult<WireProgramName> 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<int>(1, 0), new FocasResult<short>(1, 0));
|
||||
|
||||
info.BlockCount.ShouldBeNull();
|
||||
info.Mode.ShouldBeNull("Mode 0 is MDI — a real mode, not 'unknown'");
|
||||
|
||||
var nameless = WireFocasClient.MapProgramInfo(new FocasResult<WireProgramName>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user