69e0a622f3
Every 0x0026 position block is self-describing: [i32 raw][u16 base][u16 exponent],
value = raw / base^exponent. The live 31i-B capture (ref-dynamic2-axis1.bin)
reports base 10 / exponent 4 on every block, so axis 1 is 280.1574 mm, not
2801574. The driver ignored the envelope and instead sent 'cnc_getfigure'
(0x00D3) -- a command that does not exist in the FOCAS wire protocol, served only
by the in-tree mock; the real control refuses it with rc=1, so on hardware every
position fell back to PositionDecimalPlaces (default 0) and published raw counts.
- ParsePositionBlock decodes the envelope into FocasPosition; the driver applies
raw / base^exponent at publish. PositionDecimalPlaces is now only the fallback
for a scale-less (4-byte) block.
- A block describing no position (0xFFFF vacant marker, base not 2/10, exponent
> 18 -- raw / 10^400 would read as a Good 0.0) publishes BadNoData on that node
alone; siblings are untouched.
- ReadPositionFiguresAsync / IFocasClient.GetPositionFiguresAsync and the
per-axis figure cache are deleted.
Ported from the sister ignitionoee FOCAS driver v0.17.0 (+ Focas.Wire-014);
live axis-scoped captures live-dynamic2-axis{1,2}-0x0026.bin (Z-34184 X 0.0374,
Y 370.177) added as fixtures.
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 fallback axis-position decimal places, applied only to a
|
|
/// <c>cnc_rddynamic2</c> position block that carries no scale of its own (a real CNC's
|
|
/// block does, and its scale wins). Omitted / <c>0</c> = no scaling.
|
|
/// </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);
|