- 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>
159 lines
7.4 KiB
C#
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; }
|
|
}
|
|
}
|