v3(b1-twincat): apply Modbus exemplar pattern to TwinCAT (multi-device)

- Rename TwinCATEquipmentTagParser -> TwinCATTagDefinitionFactory; TryParse ->
  FromTagConfig(tagConfig, rawPath, out def). Drop leading-'{' heuristic + the
  deviceHostAddress key; def.Name = rawPath. Keep strict-enum + Inspect; add a
  Structure guard (sole tag path now) + inverse ToTagConfig.
- Options: Tags -> RawTags (RawTagEntry); keep Devices + protocol fields.
- Driver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
  _options.RawTags via FromTagConfig, threading WriteIdempotent + DeviceName.
  Multi-device: ResolveDeviceHost matches entry.DeviceName against options.Devices
  (by name, then host) -> def.DeviceHostAddress (TODO(v3 WaveC): live host from the
  Device row's DeviceConfig). DiscoverAsync now emits from the table.
- Factory: retire pre-declared tags[] DTO path; bind rawTags; keep Devices.
- Cli: BuildOptions serialises typed defs -> RawTagEntry via ToTagConfig.
- Tests: TwinCATRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
  FromTagConfig; equipment/resolve-host/array tests delivered via RawTags.

Gate: Contracts + Driver build green; Driver.TwinCAT.Tests 192 pass, Cli.Tests 70 pass.
This commit is contained in:
Joseph Doherty
2026-07-15 20:11:23 -04:00
parent c379e246d0
commit a53be2af65
23 changed files with 565 additions and 460 deletions
@@ -24,8 +24,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// (TryGetValue) don't race — plain Dictionary is not safe for concurrent read+write.
private readonly ConcurrentDictionary<string, DeviceState> _devices =
new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, TwinCATTagDefinition> _tagsByName =
new(StringComparer.OrdinalIgnoreCase);
// v3: the authored RawPath → definition table. Keyed case-sensitive ordinal (the RawPath is
// the exact wire reference). Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly ConcurrentDictionary<string, TwinCATTagDefinition> _tagsByRawPath =
new(StringComparer.Ordinal);
// Per-parent-word RMW gate for BOOL-within-word writes: a single-bit write is a
// read-modify-write of the parent word (TwinCAT's symbol table doesn't expose "Word.N"),
@@ -35,9 +37,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitRmwLocks =
new(StringComparer.OrdinalIgnoreCase);
// Resolves a read/write/subscribe fullReference to a tag definition, bridging the two
// authoring models: an authored tag-table entry (by name) OR an equipment tag whose
// reference is its raw TagConfig JSON (parsed once via TwinCATEquipmentTagParser, cached).
// v3: resolves a read/write/subscribe fullReference (always a RawPath) to a tag definition by a
// single hit on the authored _tagsByRawPath table. A miss is a miss — the driver surfaces
// BadNodeIdUnknown (no blob-parse fallback).
private readonly EquipmentTagRefResolver<TwinCATTagDefinition> _resolver;
private DriverHealth _health = new(DriverState.Unknown, null, null);
@@ -64,8 +66,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
_logger = logger ?? NullLogger<TwinCATDriver>.Instance;
_resolver = new EquipmentTagRefResolver<TwinCATTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => TwinCATEquipmentTagParser.TryParse(r, out var d) ? d : null);
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -108,7 +109,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = TwinCATDriverFactoryExtensions.ParseOptions(driverConfigJson, _driverInstanceId);
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
_options = parsed;
}
@@ -119,7 +120,26 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
$"TwinCAT device has invalid HostAddress '{device.HostAddress}' — expected 'ads://{{netId}}:{{port}}'.");
_devices[device.HostAddress] = new DeviceState(addr, device);
}
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag AND its owning
// DeviceName are threaded onto the def (both live on the RawTagEntry, not inside the blob).
// DeviceName is resolved to the target's live host (def.DeviceHostAddress) via ResolveDeviceHost
// so the existing device routing (_devices keyed by HostAddress) is unchanged. A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
foreach (var entry in _options.RawTags)
{
if (TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
_tagsByRawPath[entry.RawPath] = def with
{
WriteIdempotent = entry.WriteIdempotent,
DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
};
else
_logger.LogWarning(
"TwinCAT tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
if (_options.Probe.Enabled)
{
@@ -176,8 +196,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
state.DisposeGate();
}
_devices.Clear();
_tagsByName.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_tagsByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
// Dispose + clear the per-parent-word RMW gates so ReinitializeAsync cycles don't orphan
// their SemaphoreSlim instances (each leaks a wait handle once contended).
foreach (var sem in _bitRmwLocks.Values) sem.Dispose();
@@ -193,7 +213,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// footprint reflects live allocations only: ~256 bytes per pre-declared tag (tag-definition
// record + dictionary overhead) and ~512 bytes per active native subscription.
public long GetMemoryFootprint() =>
(_tagsByName.Count * 256L) + (_nativeSubs.Count * 512L);
(_tagsByRawPath.Count * 256L) + (_nativeSubs.Count * 512L);
/// <inheritdoc />
// No flushable cache exists in this driver — the symbol table is streamed fresh on every
@@ -394,8 +414,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var label = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, label);
// Pre-declared tags — always emitted as the authoritative config path.
var tagsForDevice = _options.Tags.Where(t =>
// Authored raw tags (mapped into the RawPath → def table at Initialize) — always emitted
// as the authoritative config path. The def's Name is the RawPath, used for both the browse
// name and the driver FullName; it is routed to this device by def.DeviceHostAddress.
var tagsForDevice = _tagsByRawPath.Values.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
@@ -747,6 +769,33 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// </summary>
public const string UnresolvedHostSentinel = "";
/// <summary>
/// Resolve a <see cref="RawTagEntry.DeviceName"/> to the device's live host address
/// (<see cref="TwinCATTagDefinition.DeviceHostAddress"/>) used to route reads/writes/subscriptions
/// to the right connection. <b>TODO(v3 WaveC):</b> the live device host must come from the
/// <c>Device</c> row's <c>DeviceConfig</c> (the deploy artifact will carry it); today we resolve
/// the DeviceName against this driver's own <see cref="TwinCATDriverOptions.Devices"/> list —
/// first by <see cref="TwinCATDeviceOptions.DeviceName"/>, then by
/// <see cref="TwinCATDeviceOptions.HostAddress"/> (callers that route by host, e.g. the Client
/// CLI, pass the host string as the DeviceName). An empty DeviceName routes to the sole device
/// on a single-device instance; an unmatched name leaves the def unrouted (→ BadNodeIdUnknown).
/// </summary>
/// <param name="deviceName">The owning device's name (or host) from the raw-tag entry.</param>
/// <returns>The resolved device host address, or empty when unresolved.</returns>
private string ResolveDeviceHost(string deviceName)
{
var devices = _options.Devices;
if (string.IsNullOrEmpty(deviceName))
return devices.Count == 1 ? devices[0].HostAddress : "";
foreach (var d in devices)
if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
foreach (var d in devices)
if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
return "";
}
/// <summary>
/// Resolve a reference to the device host that keys its per-host resilience
/// (bulkhead / circuit breaker). CONV-4: routes through
@@ -1054,8 +1103,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
state.DisposeGate();
}
_devices.Clear();
_tagsByName.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_tagsByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
// Dispose + clear the per-parent-word RMW gates (mirrors ShutdownAsync) so the
// SemaphoreSlim instances aren't orphaned on disposal.
foreach (var sem in _bitRmwLocks.Values) sem.Dispose();
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -57,9 +58,11 @@ public static class TwinCATDriverFactoryExtensions
$"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))]
: [],
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw TwinCAT
// tags (RawPath + TagConfig blob + WriteIdempotent + owning DeviceName). The driver maps each
// through TwinCATTagDefinitionFactory at Initialize; the legacy pre-declared DTO tag path
// (tags[] → BuildTag) is retired.
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
Probe = new TwinCATProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
@@ -85,43 +88,6 @@ public static class TwinCATDriverFactoryExtensions
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");
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,
ArrayLength: t.ArrayLength is > 0 ? t.ArrayLength : null);
}
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,
@@ -146,8 +112,10 @@ public static class TwinCATDriverFactoryExtensions
/// <summary>Gets or sets the list of configured devices.</summary>
public List<TwinCATDeviceDto>? Devices { get; init; }
/// <summary>Gets or sets the list of configured tags.</summary>
public List<TwinCATTagDto>? Tags { get; init; }
/// <summary>Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent +
/// owning DeviceName) the deploy artifact delivers. The driver maps each through the
/// tag-definition factory at Initialize; the legacy pre-declared <c>tags[]</c> path is retired.</summary>
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>Gets or sets the probe configuration.</summary>
public TwinCATProbeDto? Probe { get; init; }
@@ -162,35 +130,6 @@ public static class TwinCATDriverFactoryExtensions
public string? DeviceName { get; init; }
}
internal sealed class TwinCATTagDto
{
/// <summary>Gets or sets the tag name.</summary>
public string? Name { get; init; }
/// <summary>Gets or sets the device host address.</summary>
public string? DeviceHostAddress { get; init; }
/// <summary>Gets or sets the symbol path.</summary>
public string? SymbolPath { get; init; }
/// <summary>Gets or sets the data type.</summary>
public string? DataType { get; init; }
/// <summary>Gets or sets a value indicating whether the tag is writable.</summary>
public bool? Writable { get; init; }
/// <summary>Gets or sets a value indicating whether writes are idempotent.</summary>
public bool? WriteIdempotent { get; init; }
/// <summary>
/// Optional 1-D array element count. When positive, the tag is a 1-D array of this
/// many <see cref="DataType"/> elements — drives <c>IsArray</c>/<c>ArrayDim</c> at
/// discovery and a native ADS array read at runtime (Phase 4c).
/// <c>null</c> or non-positive = scalar (the default).
/// </summary>
public int? ArrayLength { get; init; }
}
internal sealed class TwinCATProbeDto
{
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>