5184a2e107
The first #516 pass deliberately left these two out, because each derives more than options from config: Sql its ISqlDialect and resolved connection string, FOCAS the IFocasClientFactory its Backend key selects — all injected at construction. Adopting new options alone would poll a NEW tag set through the OLD database/backend, and a half-applied config change is worse than a discarded one because it looks like it worked. Rather than accept that exclusion, this fixes the blocker. Each factory now exposes a ParseBinding returning EVERY config-derived dependency as one value, and the driver adopts them atomically: - Sql: ApplyBinding takes (options, dialect, connectionString) and rebuilds everything downstream — provider factory, Endpoint, and the SqlPollReader that captures all three. It runs BEFORE BuildTagTable, so the tag table and the connection it is polled over always come from the same revision. A test-injected DbProviderFactory survives a rebind (_explicitFactory), so a re-derived dialect cannot displace what a test passed in. - FOCAS: options and backend move as a pair in InitializeAsync. Re-resolving Sql's connection string on reinit is a side benefit: a rotated credential is picked up without a process restart. Drivers constructed directly get no rebinder and keep their constructor binding, so every existing "{}"-passing lifecycle test is unaffected by construction rather than by luck. The tests pin ATOMICITY, not merely that a re-parse happened — a test checking only options would have passed against the broken version. Confirmed by simulating the half-fix (adopt options, skip the backend): 2 of 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts that a reinit which cannot resolve its new connection leaves the previous binding whole rather than half-adopting. All 12 drivers now honour a changed config in place. Sql.Tests 226, FOCAS.Tests 275 pass.
363 lines
18 KiB
C#
363 lines
18 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
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>
|
|
/// <param name="registry">The driver factory registry to register with.</param>
|
|
public static void Register(DriverFactoryRegistry registry)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(registry);
|
|
registry.Register(DriverTypeName, CreateInstance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new <see cref="FocasDriver"/> instance from the supplied configuration ID and JSON.
|
|
/// </summary>
|
|
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
|
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
|
|
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
|
|
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
|
{
|
|
var binding = ParseBinding(driverInstanceId, driverConfigJson);
|
|
return new FocasDriver(
|
|
binding.Options,
|
|
driverInstanceId,
|
|
binding.ClientFactory,
|
|
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY
|
|
// config-derived dependency on a reinitialize — options AND the backend client factory
|
|
// together (#516). Re-parsing options alone would run a new device/tag set against the old
|
|
// backend, which is worse than not re-parsing at all.
|
|
rebind: json => ParseBinding(driverInstanceId, json));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a FOCAS <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
|
|
/// from config: the typed options and the <see cref="IFocasClientFactory"/> the <c>Backend</c> key
|
|
/// selects. Returned together because they must be adopted together — see the <c>rebind</c> note in
|
|
/// <see cref="CreateInstance"/>.
|
|
/// </summary>
|
|
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
|
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
|
|
/// <returns>The parsed options + backend factory.</returns>
|
|
internal static FocasDriverBinding ParseBinding(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),
|
|
PositionDecimalPlaces: d.PositionDecimalPlaces ?? 0))]
|
|
: [],
|
|
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw FOCAS
|
|
// tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each
|
|
// RawTagEntry's TagConfig blob through FocasTagDefinitionFactory at Initialize and routes it to
|
|
// its device by DeviceName; the legacy pre-declared DTO tag path is retired.
|
|
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
|
Probe = new FocasProbeOptions
|
|
{
|
|
Enabled = dto.Probe?.Enabled ?? true,
|
|
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
|
Timeout = PositiveTimeoutOrDefault(dto.Probe?.TimeoutMs, 2_000),
|
|
},
|
|
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
|
FixedTree = BuildFixedTree(dto.FixedTree),
|
|
AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
|
|
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
|
|
};
|
|
|
|
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps an operator-supplied timeout (ms) to a positive <see cref="TimeSpan"/>, substituting
|
|
/// <paramref name="defaultMs"/> for a null / zero / negative value. A non-positive
|
|
/// <c>Timeout</c> would make <see cref="SynchronizedFocasClient"/> DISABLE its per-call
|
|
/// wall-clock ceiling (falling back to the caller's long-lived poll token), reintroducing the
|
|
/// exact unbounded-read-on-a-frozen-peer wedge the S7 R2-01 read-leg fix eliminated. Clamping
|
|
/// here at the config boundary guarantees the deadline can never be authored away.
|
|
/// </summary>
|
|
/// <param name="ms">The operator-supplied timeout in milliseconds, or null.</param>
|
|
/// <param name="defaultMs">The default applied when <paramref name="ms"/> is null or non-positive.</param>
|
|
/// <returns>A strictly positive timeout.</returns>
|
|
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
|
|
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
|
|
|
|
/// <summary>
|
|
/// Builds the appropriate <see cref="IFocasClientFactory"/> based on the config DTO's backend selection.
|
|
/// </summary>
|
|
/// <param name="dto">The driver configuration DTO.</param>
|
|
/// <param name="driverInstanceId">The driver instance identifier for error reporting.</param>
|
|
/// <returns>A configured <see cref="IFocasClientFactory"/> instance.</returns>
|
|
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 readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
AllowTrailingCommas = true,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Reads a JSON property as a string, tolerating a JSON <b>number</b> token as well. The
|
|
/// AdminUI persists the FOCAS <c>Series</c> enum as its integer value (e.g. <c>"series":6</c>),
|
|
/// while this DTO models <c>Series</c> as a string handed to <see cref="ParseSeries"/>
|
|
/// (Enum.TryParse accepts the numeric form). Without this, System.Text.Json throws
|
|
/// "Cannot get the value of a token type 'Number' as a string" on the bare number and the
|
|
/// driver falls back to a stub. Accepts string / number / null and emits a string.
|
|
/// </summary>
|
|
internal sealed class FlexibleStringConverter : JsonConverter<string?>
|
|
{
|
|
/// <inheritdoc />
|
|
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
|
reader.TokenType switch
|
|
{
|
|
JsonTokenType.String => reader.GetString(),
|
|
JsonTokenType.Number => reader.TryGetInt64(out var n)
|
|
? n.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
|
: reader.GetDouble().ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
JsonTokenType.Null => null,
|
|
_ => throw new JsonException($"Expected string, number, or null but got {reader.TokenType}."),
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
|
|
{
|
|
if (value is null) writer.WriteNullValue();
|
|
else writer.WriteStringValue(value);
|
|
}
|
|
}
|
|
|
|
internal sealed class FocasDriverConfigDto
|
|
{
|
|
/// <summary>Gets or sets the FOCAS client factory backend name (e.g. "wire" or "stub").</summary>
|
|
public string? Backend { get; init; }
|
|
|
|
/// <summary>Gets or sets the CNC series for this driver.</summary>
|
|
[JsonConverter(typeof(FlexibleStringConverter))]
|
|
public string? Series { get; init; }
|
|
|
|
/// <summary>Gets or sets the operation timeout in milliseconds.</summary>
|
|
public int? TimeoutMs { get; init; }
|
|
|
|
/// <summary>Gets or sets the list of CNC devices to manage.</summary>
|
|
public List<FocasDeviceDto>? Devices { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName)
|
|
/// the driver serves. Bound verbatim into <see cref="FocasDriverOptions.RawTags"/>.
|
|
/// </summary>
|
|
public List<RawTagEntry>? RawTags { get; init; }
|
|
|
|
/// <summary>Gets or sets the probe configuration options.</summary>
|
|
public FocasProbeDto? Probe { get; init; }
|
|
|
|
/// <summary>Gets or sets the fixed-tree configuration options.</summary>
|
|
public FocasFixedTreeDto? FixedTree { get; init; }
|
|
|
|
/// <summary>Gets or sets the alarm projection configuration options.</summary>
|
|
public FocasAlarmProjectionDto? AlarmProjection { get; init; }
|
|
|
|
/// <summary>Gets or sets the handle recycle configuration options.</summary>
|
|
public FocasHandleRecycleDto? HandleRecycle { get; init; }
|
|
}
|
|
|
|
internal sealed class FocasDeviceDto
|
|
{
|
|
/// <summary>Gets or sets the hostname or IP address of the CNC.</summary>
|
|
public string? HostAddress { get; init; }
|
|
|
|
/// <summary>Gets or sets the logical device name.</summary>
|
|
public string? DeviceName { get; init; }
|
|
|
|
/// <summary>Gets or sets the CNC series for this device (overrides top-level series if provided).</summary>
|
|
[JsonConverter(typeof(FlexibleStringConverter))]
|
|
public string? Series { get; init; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the axis-position decimal places. <c>cnc_rddynamic2</c> returns
|
|
/// positions as scaled integers; the driver divides by <c>10^PositionDecimalPlaces</c>
|
|
/// so they surface in engineering units. Omitted / <c>0</c> = no scaling (legacy).
|
|
/// </summary>
|
|
public int? PositionDecimalPlaces { get; init; }
|
|
}
|
|
|
|
internal sealed class FocasProbeDto
|
|
{
|
|
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
|
|
public bool? Enabled { get; init; }
|
|
|
|
/// <summary>Gets or sets the probe interval in milliseconds.</summary>
|
|
public int? IntervalMs { get; init; }
|
|
|
|
/// <summary>Gets or sets the probe timeout in milliseconds.</summary>
|
|
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
|
|
{
|
|
/// <summary>Gets or sets a value indicating whether fixed-tree discovery is enabled.</summary>
|
|
public bool? Enabled { get; init; }
|
|
|
|
/// <summary>Gets or sets the poll interval for discovering fixed-tree items.</summary>
|
|
public TimeSpan? PollInterval { get; init; }
|
|
|
|
/// <summary>Gets or sets the poll interval for discovering program-related items.</summary>
|
|
public TimeSpan? ProgramPollInterval { get; init; }
|
|
|
|
/// <summary>Gets or sets the poll interval for discovering timer-related items.</summary>
|
|
public TimeSpan? TimerPollInterval { get; init; }
|
|
}
|
|
|
|
/// <summary>Optional <c>AlarmProjection</c> config section.</summary>
|
|
internal sealed class FocasAlarmProjectionDto
|
|
{
|
|
/// <summary>Gets or sets a value indicating whether alarm projection is enabled.</summary>
|
|
public bool? Enabled { get; init; }
|
|
|
|
/// <summary>Gets or sets the alarm poll interval.</summary>
|
|
public TimeSpan? PollInterval { get; init; }
|
|
}
|
|
|
|
/// <summary>Optional <c>HandleRecycle</c> config section.</summary>
|
|
internal sealed class FocasHandleRecycleDto
|
|
{
|
|
/// <summary>Gets or sets a value indicating whether handle recycling is enabled.</summary>
|
|
public bool? Enabled { get; init; }
|
|
|
|
/// <summary>Gets or sets the handle recycle interval.</summary>
|
|
public TimeSpan? Interval { get; init; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Everything <c>FocasDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
|
|
/// reinitialize adopts all of it atomically.
|
|
/// <para>This exists because a partial adoption is a real hazard, not a theoretical one: the driver's
|
|
/// <see cref="IFocasClientFactory"/> is chosen by the config's <c>Backend</c> key, so re-parsing
|
|
/// <see cref="Options"/> alone would poll a NEW device/tag set through the OLD backend (Gitea #516).</para>
|
|
/// </summary>
|
|
/// <param name="Options">The typed driver options.</param>
|
|
/// <param name="ClientFactory">The backend client factory the <c>Backend</c> key selects.</param>
|
|
public sealed record FocasDriverBinding(FocasDriverOptions Options, IFocasClientFactory ClientFactory);
|