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>
248 lines
11 KiB
C#
248 lines
11 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
/// <summary>
|
|
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's
|
|
/// Program.cs calls <see cref="Register"/> once at startup; the bootstrapper
|
|
/// then materialises FOCAS DriverInstance rows from the central config DB
|
|
/// into live driver instances.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
|
|
/// <list type="bullet">
|
|
/// <item><c>"Backend": "wire"</c> (default) — pure-managed FOCAS2 wire
|
|
/// client (<see cref="WireFocasClientFactory"/>) speaking directly to
|
|
/// the CNC on TCP:8193.</item>
|
|
/// <item><c>"Backend": "unimplemented"</c> / <c>"none"</c> / <c>"stub"</c>
|
|
/// — returns the no-op factory; useful for scaffolding DriverInstance
|
|
/// rows before the CNC endpoint is reachable.</item>
|
|
/// </list>
|
|
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and
|
|
/// feed directly into <see cref="FocasDriverOptions"/>.
|
|
/// </remarks>
|
|
public static class FocasDriverFactoryExtensions
|
|
{
|
|
public const string DriverTypeName = "FOCAS";
|
|
|
|
/// <summary>
|
|
/// Register the FOCAS driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
|
/// Throws if 'FOCAS' is already registered — single-instance per process.
|
|
/// </summary>
|
|
public static void Register(DriverFactoryRegistry registry)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(registry);
|
|
registry.Register(DriverTypeName, CreateInstance);
|
|
}
|
|
|
|
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
|
|
|
var dto = JsonSerializer.Deserialize<FocasDriverConfigDto>(driverConfigJson, JsonOptions)
|
|
?? throw new InvalidOperationException(
|
|
$"FOCAS driver config for '{driverInstanceId}' deserialised to null");
|
|
|
|
// Eager-validate top-level Series so a typo fails fast regardless of whether Devices
|
|
// are populated yet (common during rollout when rows are seeded before CNCs arrive).
|
|
_ = ParseSeries(dto.Series);
|
|
|
|
var options = new FocasDriverOptions
|
|
{
|
|
Devices = dto.Devices is { Count: > 0 }
|
|
? [.. dto.Devices.Select(d => new FocasDeviceOptions(
|
|
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
|
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
|
DeviceName: d.DeviceName,
|
|
Series: ParseSeries(d.Series ?? dto.Series)))]
|
|
: [],
|
|
Tags = dto.Tags is { Count: > 0 }
|
|
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
|
Name: t.Name ?? throw new InvalidOperationException(
|
|
$"FOCAS config for '{driverInstanceId}' has a tag missing Name"),
|
|
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
|
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
|
Address: t.Address ?? throw new InvalidOperationException(
|
|
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
|
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
|
|
Writable: t.Writable ?? true,
|
|
WriteIdempotent: t.WriteIdempotent ?? false))]
|
|
: [],
|
|
Probe = new FocasProbeOptions
|
|
{
|
|
Enabled = dto.Probe?.Enabled ?? true,
|
|
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
|
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);
|
|
return new FocasDriver(options, driverInstanceId, clientFactory);
|
|
}
|
|
|
|
internal static IFocasClientFactory BuildClientFactory(
|
|
FocasDriverConfigDto dto, string driverInstanceId)
|
|
{
|
|
var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant();
|
|
return backend switch
|
|
{
|
|
"wire" => new WireFocasClientFactory(),
|
|
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
|
|
_ => throw new InvalidOperationException(
|
|
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
|
|
"Expected one of: wire, unimplemented. " +
|
|
"(The legacy 'ipc' / 'fwlib' backends were retired in the Wire migration — " +
|
|
"see docs/drivers/FOCAS.md.)"),
|
|
};
|
|
}
|
|
|
|
/// <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;
|
|
return Enum.TryParse<FocasCncSeries>(raw, ignoreCase: true, out var s)
|
|
? s
|
|
: throw new InvalidOperationException(
|
|
$"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames<FocasCncSeries>())}");
|
|
}
|
|
|
|
private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw))
|
|
throw new InvalidOperationException(
|
|
$"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType");
|
|
return Enum.TryParse<FocasDataType>(raw, ignoreCase: true, out var dt)
|
|
? dt
|
|
: throw new InvalidOperationException(
|
|
$"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
|
|
$"Expected one of {string.Join(", ", Enum.GetNames<FocasDataType>())}");
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
AllowTrailingCommas = true,
|
|
};
|
|
|
|
internal sealed class FocasDriverConfigDto
|
|
{
|
|
public string? Backend { get; init; }
|
|
public string? Series { get; init; }
|
|
public int? TimeoutMs { get; init; }
|
|
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
|
|
{
|
|
public string? HostAddress { get; init; }
|
|
public string? DeviceName { get; init; }
|
|
public string? Series { get; init; }
|
|
}
|
|
|
|
internal sealed class FocasTagDto
|
|
{
|
|
public string? Name { get; init; }
|
|
public string? DeviceHostAddress { get; init; }
|
|
public string? Address { get; init; }
|
|
public string? DataType { get; init; }
|
|
public bool? Writable { get; init; }
|
|
public bool? WriteIdempotent { get; init; }
|
|
}
|
|
|
|
internal sealed class FocasProbeDto
|
|
{
|
|
public bool? Enabled { get; init; }
|
|
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; }
|
|
}
|
|
}
|