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; }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
@@ -98,6 +99,7 @@ builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
GalaxyProxyDriverFactoryExtensions.Register(registry);
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
return registry;
|
||||
});
|
||||
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #220 — covers the DriverConfig JSON contract that
|
||||
/// <see cref="FocasDriverFactoryExtensions.CreateInstance"/> parses when the bootstrap
|
||||
/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
|
||||
/// or CNC required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDriverFactoryExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_adds_FOCAS_entry_to_registry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
registry.TryGet("FOCAS").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_is_case_insensitive_via_registry()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
registry.TryGet("focas").ShouldNotBeNull();
|
||||
registry.TryGet("Focas").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "ipc",
|
||||
"PipeName": "OtOpcUaFocasHost",
|
||||
"SharedSecret": "secret-for-test",
|
||||
"ConnectTimeoutMs": 5000,
|
||||
"Series": "Thirty_i",
|
||||
"TimeoutMs": 3000,
|
||||
"Devices": [
|
||||
{ "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
|
||||
],
|
||||
"Tags": [
|
||||
{ "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "R100", "DataType": "Int32", "Writable": true }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
|
||||
|
||||
driver.ShouldNotBeNull();
|
||||
driver.DriverInstanceId.ShouldBe("focas-0");
|
||||
driver.DriverType.ShouldBe("FOCAS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
|
||||
{
|
||||
// No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
|
||||
const string json = """
|
||||
{ "PipeName": "p", "SharedSecret": "s" }
|
||||
""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
|
||||
driver.DriverType.ShouldBe("FOCAS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_ipc_backend_missing_PipeName_throws()
|
||||
{
|
||||
const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
|
||||
.Message.ShouldContain("PipeName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
|
||||
{
|
||||
const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
|
||||
.Message.ShouldContain("SharedSecret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
|
||||
{
|
||||
// Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
|
||||
// natively on first use.
|
||||
const string json = """{ "Backend": "fwlib" }""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
|
||||
driver.DriverInstanceId.ShouldBe("focas-fwlib");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
|
||||
{
|
||||
// Useful for staging DriverInstance rows in the config DB before the Host is
|
||||
// actually deployed — the server boots but reads/writes surface clear errors.
|
||||
const string json = """{ "Backend": "unimplemented" }""";
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
|
||||
driver.DriverInstanceId.ShouldBe("focas-unimpl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_unknown_backend_throws_with_expected_list()
|
||||
{
|
||||
const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
|
||||
.Message.ShouldContain("gibberish");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_rejects_unknown_Series()
|
||||
{
|
||||
const string json = """
|
||||
{ "Backend": "fwlib", "Series": "NotARealSeries" }
|
||||
""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
|
||||
.Message.ShouldContain("NotARealSeries");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_rejects_tag_with_missing_DataType()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "fwlib",
|
||||
"Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
|
||||
"Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
|
||||
}
|
||||
""";
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
|
||||
.Message.ShouldContain("DataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_null_or_whitespace_args_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
|
||||
Should.Throw<ArgumentException>(
|
||||
() => FocasDriverFactoryExtensions.CreateInstance("id", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_twice_throws()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
FocasDriverFactoryExtensions.Register(registry);
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => FocasDriverFactoryExtensions.Register(registry));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user