Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverFactoryExtensions.cs
Joseph Doherty 3c75db7eb6 fix(driver-twincat): resolve Low code-review findings (Driver.TwinCAT-004,006,014,015,016)
- Driver.TwinCAT-004: corrected the IEC time-type inline comments;
  documented that the driver currently surfaces them as raw UInt32
  counters.
- Driver.TwinCAT-006: ResolveHost returns a documented UnresolvedHost
  sentinel when no devices are configured instead of returning the
  logical DriverInstanceId (which never matches GetHostStatuses).
- Driver.TwinCAT-014: wired Probe.Timeout into the probe-loop call and
  added a NotificationMaxDelayMs config knob threaded through
  AddNotificationAsync.
- Driver.TwinCAT-015: Dispose() runs a genuinely synchronous teardown
  with bounded waits (no sync-over-async deadlock pattern).
- Driver.TwinCAT-016: pinned the Structure-tag rejection and the
  probe-loop vs read disposal race with regression tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:17:42 -04:00

159 lines
7.4 KiB
C#

using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Static factory registration helper for <see cref="TwinCATDriver"/>. Server's Program.cs
/// calls <see cref="Register"/> once at startup; the bootstrapper materialises TwinCAT
/// DriverInstance rows from the central config DB into live driver instances. Mirrors
/// <c>S7DriverFactoryExtensions</c> / <c>AbCipDriverFactoryExtensions</c>.
/// </summary>
public static class TwinCATDriverFactoryExtensions
{
public const string DriverTypeName = "TwinCAT";
public static void Register(DriverFactoryRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, CreateInstance);
}
internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
var options = ParseOptions(driverConfigJson, driverInstanceId);
return new TwinCATDriver(options, driverInstanceId);
}
/// <summary>
/// Parse a TwinCAT driver-config JSON document into a <see cref="TwinCATDriverOptions"/>.
/// Shared by <see cref="CreateInstance"/> (constructor-time) and
/// <see cref="TwinCATDriver.InitializeAsync"/> / <see cref="TwinCATDriver.ReinitializeAsync"/>
/// so a config generation pushed via Reinitialize is actually applied (Driver.TwinCAT-001).
/// </summary>
internal static TwinCATDriverOptions ParseOptions(string driverConfigJson, string driverInstanceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = JsonSerializer.Deserialize<TwinCATDriverConfigDto>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"TwinCAT driver config for '{driverInstanceId}' deserialised to null");
return new TwinCATDriverOptions
{
Devices = dto.Devices is { Count: > 0 }
? [.. dto.Devices.Select(d => new TwinCATDeviceOptions(
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
$"TwinCAT config for '{driverInstanceId}' has a device missing HostAddress"),
DeviceName: d.DeviceName))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
: [],
Probe = new TwinCATProbeOptions
{
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),
UseNativeNotifications = dto.UseNativeNotifications ?? true,
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
NotificationMaxDelayMs = dto.NotificationMaxDelayMs ?? 0,
};
}
/// <summary>
/// Test-visible wrapper around <see cref="ParseOptions"/> for the regression suite —
/// keeps the public driver surface unchanged while letting tests assert that JSON
/// fields like <c>NotificationMaxDelayMs</c> and <c>Structure</c>-tag rejection are
/// honored end-to-end.
/// </summary>
public static TwinCATDriverOptions ParseOptionsForTests(string driverConfigJson, string driverInstanceId)
=> ParseOptions(driverConfigJson, driverInstanceId);
private static TwinCATTagDefinition BuildTag(TwinCATTagDto t, string driverInstanceId)
{
var dataType = ParseEnum<TwinCATDataType>(t.DataType, t.Name, driverInstanceId, "DataType");
// Driver.TwinCAT-003: Structure-typed pre-declared tags are not supported. The driver's
// atomic surface cannot read/write UDT blobs — MapToClrType falls through to typeof(int)
// and ConvertForWrite throws NotSupportedException, producing garbage reads or late
// runtime failures. BrowseSymbolsAsync already correctly yields DataType = null for
// Structure symbols so they never appear in the discovered address space. Reject here
// with a clear error so operators get a configuration-time failure, not a silent wrong value.
if (dataType == TwinCATDataType.Structure)
throw new InvalidOperationException(
$"TwinCAT tag '{t.Name ?? "<unnamed>"}' in '{driverInstanceId}' specifies " +
"DataType 'Structure'. The driver does not support UDT/FB-instance pre-declared tags. " +
"Use EnableControllerBrowse to discover UDT members individually, or declare " +
"individual atomic-typed tags for the fields you need.");
return new TwinCATTagDefinition(
Name: t.Name ?? throw new InvalidOperationException(
$"TwinCAT config for '{driverInstanceId}' has a tag missing Name"),
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
$"TwinCAT tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
SymbolPath: t.SymbolPath ?? throw new InvalidOperationException(
$"TwinCAT tag '{t.Name}' in '{driverInstanceId}' missing SymbolPath"),
DataType: dataType,
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false);
}
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field)
where T : struct, Enum
{
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException(
$"TwinCAT tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
? v
: throw new InvalidOperationException(
$"TwinCAT tag '{tagName}' has unknown {field} '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
internal sealed class TwinCATDriverConfigDto
{
public int? TimeoutMs { get; init; }
public bool? UseNativeNotifications { get; init; }
public bool? EnableControllerBrowse { get; init; }
public int? NotificationMaxDelayMs { get; init; }
public List<TwinCATDeviceDto>? Devices { get; init; }
public List<TwinCATTagDto>? Tags { get; init; }
public TwinCATProbeDto? Probe { get; init; }
}
internal sealed class TwinCATDeviceDto
{
public string? HostAddress { get; init; }
public string? DeviceName { get; init; }
}
internal sealed class TwinCATTagDto
{
public string? Name { get; init; }
public string? DeviceHostAddress { get; init; }
public string? SymbolPath { get; init; }
public string? DataType { get; init; }
public bool? Writable { get; init; }
public bool? WriteIdempotent { get; init; }
}
internal sealed class TwinCATProbeDto
{
public bool? Enabled { get; init; }
public int? IntervalMs { get; init; }
public int? TimeoutMs { get; init; }
}
}