fix(driver-focas): resolve High code-review findings (Driver.FOCAS-001, Driver.FOCAS-002)

Driver.FOCAS-001: FocasDriverConfigDto exposed no FixedTree / AlarmProjection /
HandleRecycle sections, so a deployment that opted into those features per
docs/drivers/FOCAS.md had the sections silently dropped by case-insensitive
JSON parsing and the features stayed at their disabled defaults. Added
FocasFixedTreeDto / FocasAlarmProjectionDto / FocasHandleRecycleDto and Build*
mappers in CreateInstance that populate the matching FocasDriverOptions
properties; a missing section or field keeps its existing default.

Driver.FOCAS-002: the fixed-tree bootstrap probe classified ProgramInfo as
"supported" whenever GetProgramInfoAsync returned non-null, but WireFocasClient
.GetProgramInfoAsync substituted defaults instead of throwing on a FOCAS error
return, so a CNC series answering EW_FUNC/EW_NOOPT for cnc_exeprgname2 /
cnc_rdopmode still got the Program/ and OperationMode/ subtrees. The method now
throws InvalidOperationException when neither the program-name nor the op-mode
read is IsOk, so SafeTryProbe correctly suppresses the capability.

Added FocasFactoryConfigTests covering the three opt-in config sections
round-tripping through CreateInstance and the fixed-tree bootstrap classifying
ProgramInfo as unsupported when the probe throws. Added an internal
FocasDriver.Options test seam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 06:34:03 -04:00
parent ebc0511c72
commit c9e446387a
5 changed files with 282 additions and 5 deletions

View File

@@ -161,6 +161,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
/// <summary>Test seam — the resolved options the factory built this driver from.</summary>
internal FocasDriverOptions Options => _options;
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(

View File

@@ -79,6 +79,9 @@ public static class FocasDriverFactoryExtensions
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
},
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
FixedTree = BuildFixedTree(dto.FixedTree),
AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
};
var clientFactory = BuildClientFactory(dto, driverInstanceId);
@@ -101,6 +104,55 @@ public static class FocasDriverFactoryExtensions
};
}
/// <summary>
/// Map the optional <c>FixedTree</c> config section onto <see cref="FocasFixedTreeOptions"/>.
/// A missing section keeps the hard-coded defaults (tree disabled); a present section
/// with omitted intervals keeps each interval's default. Interval fields are JSON
/// <see cref="TimeSpan"/> strings (e.g. <c>"00:00:00.250"</c>) per docs/drivers/FOCAS.md.
/// </summary>
private static FocasFixedTreeOptions BuildFixedTree(FocasFixedTreeDto? dto)
{
if (dto is null) return new FocasFixedTreeOptions();
var defaults = new FocasFixedTreeOptions();
return new FocasFixedTreeOptions
{
Enabled = dto.Enabled ?? defaults.Enabled,
PollInterval = dto.PollInterval ?? defaults.PollInterval,
ProgramPollInterval = dto.ProgramPollInterval ?? defaults.ProgramPollInterval,
TimerPollInterval = dto.TimerPollInterval ?? defaults.TimerPollInterval,
};
}
/// <summary>
/// Map the optional <c>AlarmProjection</c> config section onto
/// <see cref="FocasAlarmProjectionOptions"/>. Missing section keeps the disabled default.
/// </summary>
private static FocasAlarmProjectionOptions BuildAlarmProjection(FocasAlarmProjectionDto? dto)
{
if (dto is null) return new FocasAlarmProjectionOptions();
var defaults = new FocasAlarmProjectionOptions();
return new FocasAlarmProjectionOptions
{
Enabled = dto.Enabled ?? defaults.Enabled,
PollInterval = dto.PollInterval ?? defaults.PollInterval,
};
}
/// <summary>
/// Map the optional <c>HandleRecycle</c> config section onto
/// <see cref="FocasHandleRecycleOptions"/>. Missing section keeps the disabled default.
/// </summary>
private static FocasHandleRecycleOptions BuildHandleRecycle(FocasHandleRecycleDto? dto)
{
if (dto is null) return new FocasHandleRecycleOptions();
var defaults = new FocasHandleRecycleOptions();
return new FocasHandleRecycleOptions
{
Enabled = dto.Enabled ?? defaults.Enabled,
Interval = dto.Interval ?? defaults.Interval,
};
}
private static FocasCncSeries ParseSeries(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
@@ -137,6 +189,9 @@ public static class FocasDriverFactoryExtensions
public List<FocasDeviceDto>? Devices { get; init; }
public List<FocasTagDto>? Tags { get; init; }
public FocasProbeDto? Probe { get; init; }
public FocasFixedTreeDto? FixedTree { get; init; }
public FocasAlarmProjectionDto? AlarmProjection { get; init; }
public FocasHandleRecycleDto? HandleRecycle { get; init; }
}
internal sealed class FocasDeviceDto
@@ -162,4 +217,31 @@ public static class FocasDriverFactoryExtensions
public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; }
}
/// <summary>
/// Optional <c>FixedTree</c> config section. Interval fields are JSON
/// <see cref="TimeSpan"/> strings (<c>"hh:mm:ss[.fff]"</c>) — System.Text.Json
/// parses these natively.
/// </summary>
internal sealed class FocasFixedTreeDto
{
public bool? Enabled { get; init; }
public TimeSpan? PollInterval { get; init; }
public TimeSpan? ProgramPollInterval { get; init; }
public TimeSpan? TimerPollInterval { get; init; }
}
/// <summary>Optional <c>AlarmProjection</c> config section.</summary>
internal sealed class FocasAlarmProjectionDto
{
public bool? Enabled { get; init; }
public TimeSpan? PollInterval { get; init; }
}
/// <summary>Optional <c>HandleRecycle</c> config section.</summary>
internal sealed class FocasHandleRecycleDto
{
public bool? Enabled { get; init; }
public TimeSpan? Interval { get; init; }
}
}

View File

@@ -170,6 +170,16 @@ public sealed class WireFocasClient : IFocasClient
// managed ToText path in FocasOpMode can map it for display.
var modeResult = await _wire.ReadOperationModeCodeAsync(cancellationToken).ConfigureAwait(false);
// The fixed-tree bootstrap probe (FocasDriver.SafeTryProbe) classifies the
// ProgramInfo capability as "supported" iff this method returns non-null. A CNC
// series without cnc_exeprgname2 / cnc_rdopmode answers EW_FUNC / EW_NOOPT, so
// throw when neither the program-name nor the op-mode read succeeded — otherwise
// SafeTryProbe records a false-positive capability and the driver emits Program/
// OperationMode/ subtrees that only ever return BadDeviceFailure.
if (!nameResult.IsOk && !modeResult.IsOk)
throw new InvalidOperationException(
$"cnc_exeprgname2 failed EW_{nameResult.Rc} and cnc_rdopmode failed EW_{modeResult.Rc}.");
var wireName = nameResult.Value;
return new FocasProgramInfo(
Name: wireName?.Name ?? string.Empty,