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>
199 lines
9.3 KiB
C#
199 lines
9.3 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.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; }
|
|
}
|
|
}
|