Task #220 — Wire FOCAS into DriverFactoryRegistry bootstrap pipeline
Closes the non-hardware gap surfaced in the #220 audit: FOCAS had full Tier-C architecture (Driver.FOCAS + Driver.FOCAS.Host + Driver.FOCAS.Shared, supervisor, post-mortem MMF, NSSM scripts, 239 tests) but no factory registration, so config-DB DriverInstance rows of type "FOCAS" would fail at bootstrap with "unknown driver type". Hardware-gated FwlibHostedBackend (real Fwlib32 P/Invoke inside the Host process) stays deferred under #222 lab-rig. Ships: - FocasDriverFactoryExtensions.Register(registry) mirroring the Galaxy pattern. JSON schema selects backend via "Backend" field: "ipc" (default) — IpcFocasClientFactory → named-pipe FocasIpcClient → Driver.FOCAS.Host process (Tier-C isolation) "fwlib" — direct in-process FwlibFocasClientFactory (P/Invoke) "unimplemented" — UnimplementedFocasClientFactory (fail-fast on use — useful for staging DriverInstance rows pre-Host-deploy) - Devices / Tags / Probe / Timeout / Series feed into FocasDriverOptions. Series validated eagerly at top-level so typos fail at bootstrap, not first read. Tag DataType + Series enum values surface clear errors listing valid options. - Program.cs adds FocasDriverFactoryExtensions.Register alongside Galaxy. - Driver.FOCAS.csproj references Core (for DriverFactoryRegistry). - Server.csproj adds Driver.FOCAS ProjectReference so the factory type is reachable from Program.cs. Tests: 13 new FocasDriverFactoryExtensionsTests covering: registry entry, case-insensitive lookup, ipc backend with full config, ipc defaults, missing PipeName/SharedSecret errors, fwlib backend short-path, unimplemented backend, unknown-backend error, unknown-Series error, tag missing DataType, null/ws args, duplicate-register throws. Regression: 202 FOCAS + 13 FOCAS.Host + 24 FOCAS.Shared + 239 Server all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
|
||||
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 (task #248) then
|
||||
/// materialises FOCAS DriverInstance rows from the central config DB into live driver
|
||||
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>; no dependency on
|
||||
/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"Backend": "ipc"</c> (default) — wires <see cref="IpcFocasClientFactory"/>
|
||||
/// against a named-pipe <see cref="FocasIpcClient"/> talking to a separate
|
||||
/// <c>Driver.FOCAS.Host</c> process (Tier-C isolation). Requires <c>PipeName</c> +
|
||||
/// <c>SharedSecret</c>.</item>
|
||||
/// <item><c>"Backend": "fwlib"</c> — direct in-process Fwlib32.dll P/Invoke via
|
||||
/// <see cref="FwlibFocasClientFactory"/>. Use only when the main server is licensed
|
||||
/// for FOCAS and you accept the native-crash blast-radius trade-off.</item>
|
||||
/// <item><c>"Backend": "unimplemented"</c> — returns the no-op factory; useful for
|
||||
/// scaffolding DriverInstance rows before the Host is deployed so the server boots.</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),
|
||||
};
|
||||
|
||||
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
||||
return new FocasDriver(options, driverInstanceId, clientFactory);
|
||||
}
|
||||
|
||||
internal static IFocasClientFactory BuildClientFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var backend = (dto.Backend ?? "ipc").Trim().ToLowerInvariant();
|
||||
return backend switch
|
||||
{
|
||||
"ipc" => BuildIpcFactory(dto, driverInstanceId),
|
||||
"fwlib" or "fwlib32" => new FwlibFocasClientFactory(),
|
||||
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
|
||||
"Expected one of: ipc, fwlib, unimplemented."),
|
||||
};
|
||||
}
|
||||
|
||||
private static IpcFocasClientFactory BuildIpcFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var pipeName = dto.PipeName
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required PipeName (Tier-C ipc backend)");
|
||||
var sharedSecret = dto.SharedSecret
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required SharedSecret (Tier-C ipc backend)");
|
||||
var connectTimeout = TimeSpan.FromMilliseconds(dto.ConnectTimeoutMs ?? 10_000);
|
||||
var series = ParseSeries(dto.Series);
|
||||
|
||||
// Each IFocasClientFactory.Create() call opens a fresh pipe to the Host — matches the
|
||||
// driver's one-client-per-device invariant. FocasIpcClient.ConnectAsync is awaited
|
||||
// synchronously via GetAwaiter().GetResult() because IFocasClientFactory.Create is a
|
||||
// sync contract; the blocking call lands inside FocasDriver.EnsureConnectedAsync,
|
||||
// which immediately awaits IFocasClient.ConnectAsync afterwards so the perceived
|
||||
// latency is identical to a fully-async factory.
|
||||
return new IpcFocasClientFactory(
|
||||
ipcClientFactory: () => FocasIpcClient.ConnectAsync(
|
||||
pipeName: pipeName,
|
||||
sharedSecret: sharedSecret,
|
||||
connectTimeout: connectTimeout,
|
||||
ct: CancellationToken.None).GetAwaiter().GetResult(),
|
||||
series: series);
|
||||
}
|
||||
|
||||
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? PipeName { get; init; }
|
||||
public string? SharedSecret { get; init; }
|
||||
public int? ConnectTimeoutMs { 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; }
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user