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;
///
/// Static factory registration helper for . Server's
/// Program.cs calls once at startup; the bootstrapper
/// then materialises FOCAS DriverInstance rows from the central config DB
/// into live driver instances.
///
///
/// The DriverConfig JSON selects the backend:
///
/// - "Backend": "wire" (default) — pure-managed FOCAS2 wire
/// client () speaking directly to
/// the CNC on TCP:8193.
/// - "Backend": "unimplemented" / "none" / "stub"
/// — returns the no-op factory; useful for scaffolding DriverInstance
/// rows before the CNC endpoint is reachable.
///
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and
/// feed directly into .
///
public static class FocasDriverFactoryExtensions
{
public const string DriverTypeName = "FOCAS";
///
/// Register the FOCAS driver factory in the supplied .
/// Throws if 'FOCAS' is already registered — single-instance per process.
///
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(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.)"),
};
}
///
/// Map the optional FixedTree config section onto .
/// 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
/// strings (e.g. "00:00:00.250") per docs/drivers/FOCAS.md.
///
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,
};
}
///
/// Map the optional AlarmProjection config section onto
/// . Missing section keeps the disabled default.
///
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,
};
}
///
/// Map the optional HandleRecycle config section onto
/// . Missing section keeps the disabled default.
///
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(raw, ignoreCase: true, out var s)
? s
: throw new InvalidOperationException(
$"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames())}");
}
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(raw, ignoreCase: true, out var dt)
? dt
: throw new InvalidOperationException(
$"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames())}");
}
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? Devices { get; init; }
public List? 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; }
}
///
/// Optional FixedTree config section. Interval fields are JSON
/// strings ("hh:mm:ss[.fff]") — System.Text.Json
/// parses these natively.
///
internal sealed class FocasFixedTreeDto
{
public bool? Enabled { get; init; }
public TimeSpan? PollInterval { get; init; }
public TimeSpan? ProgramPollInterval { get; init; }
public TimeSpan? TimerPollInterval { get; init; }
}
/// Optional AlarmProjection config section.
internal sealed class FocasAlarmProjectionDto
{
public bool? Enabled { get; init; }
public TimeSpan? PollInterval { get; init; }
}
/// Optional HandleRecycle config section.
internal sealed class FocasHandleRecycleDto
{
public bool? Enabled { get; init; }
public TimeSpan? Interval { get; init; }
}
}