Merge B1-twincat: v3 RawPath read-path
This commit is contained in:
@@ -45,7 +45,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Gateway, $"cli-{AmsNetId}:{AmsPort}")],
|
||||
Tags = [],
|
||||
RawTags = [],
|
||||
Timeout = Timeout,
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
|
||||
|
||||
@@ -20,8 +21,13 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="TwinCATDriverOptions"/> with the AMS target this base collected +
|
||||
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
|
||||
/// native notifications toggled by <see cref="PollOnly"/>.
|
||||
/// the tag list a subclass supplies. The CLI holds typed definitions (synthesised from
|
||||
/// operator flags), so each is serialised back to a <see cref="RawTagEntry"/> — the v3
|
||||
/// delivery unit — via <see cref="TwinCATTagDefinitionFactory.ToTagConfig"/>, keyed by the
|
||||
/// def's synthesised name as its RawPath, with the def's <c>DeviceHostAddress</c> carried as
|
||||
/// the entry's <see cref="RawTagEntry.DeviceName"/> so the driver routes it to the sole
|
||||
/// device (matched by host). Probe disabled, controller-browse disabled, native
|
||||
/// notifications toggled by <see cref="PollOnly"/>.
|
||||
/// </summary>
|
||||
/// <param name="tags">Tag definitions for the driver.</param>
|
||||
/// <returns>The built <see cref="TwinCATDriverOptions"/>.</returns>
|
||||
@@ -30,7 +36,8 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
|
||||
Devices = [new TwinCATDeviceOptions(
|
||||
HostAddress: Gateway,
|
||||
DeviceName: $"cli-{AmsNetId}:{AmsPort}")],
|
||||
Tags = tags,
|
||||
RawTags = [.. tags.Select(t => new RawTagEntry(
|
||||
t.Name, TwinCATTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, t.DeviceHostAddress))],
|
||||
Timeout = Timeout,
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = !PollOnly,
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// TwinCAT ADS driver configuration. One instance supports N targets (each identified by
|
||||
/// an AMS Net ID + port). Compiles + runs without a local AMS router but every wire call
|
||||
/// fails with <c>BadCommunicationError</c> until a router is reachable.
|
||||
/// fails with <c>BadCommunicationError</c> until a router is reachable. The tags the driver
|
||||
/// serves are delivered as authored raw tags in <see cref="RawTags"/>; the driver maps each
|
||||
/// through <see cref="TwinCATTagDefinitionFactory.FromTagConfig"/> to build its
|
||||
/// RawPath → definition table, routing each to its device by the entry's
|
||||
/// <see cref="RawTagEntry.DeviceName"/> (multi-device).
|
||||
/// </summary>
|
||||
public sealed class TwinCATDriverOptions
|
||||
{
|
||||
/// <summary>Gets the list of TwinCAT devices to connect to.</summary>
|
||||
public IReadOnlyList<TwinCATDeviceOptions> Devices { get; init; } = [];
|
||||
/// <summary>Gets the list of TwinCAT tag definitions.</summary>
|
||||
public IReadOnlyList<TwinCATTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Authored raw tags this driver serves. The deploy artifact hands each authored raw
|
||||
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
|
||||
/// WriteIdempotent flag + owning <see cref="RawTagEntry.DeviceName"/>); the driver maps each
|
||||
/// through <see cref="TwinCATTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition
|
||||
/// table and routes it to the matching <see cref="Devices"/> entry by DeviceName.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
/// <summary>Gets the probe options for TwinCAT connection probing.</summary>
|
||||
public TwinCATProbeOptions Probe { get; init; } = new();
|
||||
/// <summary>Gets the default communication timeout.</summary>
|
||||
@@ -33,8 +45,8 @@ public sealed class TwinCATDriverOptions
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's symbol table via the
|
||||
/// TwinCAT <c>SymbolLoaderFactory</c> (flat mode) + surfaces controller-resident
|
||||
/// globals / program locals under a <c>Discovered/</c> sub-folder. Pre-declared tags
|
||||
/// from <see cref="Tags"/> always emit regardless. Default <c>false</c> to preserve
|
||||
/// globals / program locals under a <c>Discovered/</c> sub-folder. Authored raw tags
|
||||
/// from <see cref="RawTags"/> always emit regardless. Default <c>false</c> to preserve
|
||||
/// the strict-config path for deployments where only declared tags should appear.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
|
||||
/// <c>TwinCATTagConfigModel</c>) into a transient <see cref="TwinCATTagDefinition"/> whose
|
||||
/// <see cref="TwinCATTagDefinition.Name"/> equals the reference string itself, so a value the
|
||||
/// driver publishes back keys the runtime's forward router correctly.</summary>
|
||||
public static class TwinCATEquipmentTagParser
|
||||
{
|
||||
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
|
||||
/// <param name="def">The transient definition when parsing succeeds.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is a TwinCAT symbol object.</returns>
|
||||
public static bool TryParse(string reference, out TwinCATTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
// Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob.
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(reference);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("symbolPath", out var symbol)
|
||||
|| symbol.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
var symbolPath = symbol.GetString();
|
||||
if (string.IsNullOrWhiteSpace(symbolPath)) return false;
|
||||
var deviceHostAddress = ReadString(root, "deviceHostAddress");
|
||||
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
|
||||
// instead of silently defaulting to a wrong-width Good.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", TwinCATDataType.DInt, out var dataType)) return false;
|
||||
// Array intent — same shape the runtime/OPC-UA foundation parses (camelCase
|
||||
// `isArray` bool + `arrayLength` uint). arrayLength is honoured ONLY when isArray
|
||||
// is true AND it is a positive JSON number, so a stale length behind a cleared
|
||||
// isArray never produces an orphan array tag (Phase 4c).
|
||||
var arrayLength = ReadArrayLength(root);
|
||||
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
|
||||
// authorable (UNDER-6).
|
||||
var writable = TagConfigJson.ReadWritable(root);
|
||||
def = new TwinCATTagDefinition(
|
||||
Name: reference, DeviceHostAddress: deviceHostAddress, SymbolPath: symbolPath,
|
||||
DataType: dataType, Writable: writable, ArrayLength: arrayLength);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
catch (FormatException) { return false; }
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
|
||||
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
|
||||
/// or not an equipment-tag object. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(reference);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("TwinCAT TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return warnings;
|
||||
}
|
||||
var w = TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(root, "dataType");
|
||||
if (w is not null) warnings.Add(w);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("TwinCAT TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static string ReadString(JsonElement o, string name)
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
|
||||
? e.GetString() ?? "" : "";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the optional 1-D array length: <c>arrayLength</c> (a positive int32; values above
|
||||
/// <see cref="int.MaxValue"/> are treated as absent/scalar) honoured ONLY when <c>isArray</c>
|
||||
/// is the JSON literal <c>true</c>. Returns <c>null</c> (scalar) when isArray is absent/false,
|
||||
/// when arrayLength is absent / non-numeric / zero / negative / greater than
|
||||
/// <see cref="int.MaxValue"/>.
|
||||
/// </summary>
|
||||
private static int? ReadArrayLength(JsonElement o)
|
||||
{
|
||||
if (!o.TryGetProperty("isArray", out var aEl) || aEl.ValueKind != JsonValueKind.True)
|
||||
return null;
|
||||
if (!o.TryGetProperty("arrayLength", out var lEl) || lEl.ValueKind != JsonValueKind.Number)
|
||||
return null;
|
||||
return lEl.TryGetInt32(out var len) && len > 0 ? len : null;
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
|
||||
/// AdminUI <c>TwinCATTagConfigModel</c>) into a <see cref="TwinCATTagDefinition"/>. Under v3 a tag's
|
||||
/// identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
|
||||
/// produced definition's <see cref="TwinCATTagDefinition.Name"/> is the RawPath the driver was handed,
|
||||
/// which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on. The driver
|
||||
/// builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact delivers through
|
||||
/// <see cref="FromTagConfig"/>; <see cref="ToTagConfig"/> is the inverse, used by authoring paths (the
|
||||
/// Client CLI) that hold a typed definition and need to synthesise the <c>TagConfig</c> blob for a
|
||||
/// <see cref="RawTagEntry"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Device routing is NOT read from the blob under v3.</b> The pre-v3 <c>deviceHostAddress</c> key is
|
||||
/// dropped: which device a tag lives under is carried on the <see cref="RawTagEntry.DeviceName"/> the
|
||||
/// deploy artifact populates (this driver is multi-device), threaded onto
|
||||
/// <see cref="TwinCATTagDefinition.DeviceHostAddress"/> by the driver at table-build time. The mapper
|
||||
/// therefore always produces a definition with an empty <see cref="TwinCATTagDefinition.DeviceHostAddress"/>.
|
||||
/// </remarks>
|
||||
public static class TwinCATTagDefinitionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
|
||||
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
|
||||
/// The <c>dataType</c> enum is read STRICTLY — a present-but-invalid (typo'd) value rejects the whole
|
||||
/// tag (returns <see langword="false"/> ⇒ the driver surfaces <c>BadNodeIdUnknown</c>) rather than
|
||||
/// silently defaulting to a wrong-width Good. <see cref="TwinCATTagDefinition.WriteIdempotent"/> is NOT
|
||||
/// read from the blob — it is a platform flag the caller threads in from the tag's
|
||||
/// <see cref="RawTagEntry.WriteIdempotent"/>; <see cref="TwinCATTagDefinition.DeviceHostAddress"/> is
|
||||
/// likewise threaded from the tag's <see cref="RawTagEntry.DeviceName"/> (multi-device routing), not
|
||||
/// the blob.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid TwinCAT symbol object.</returns>
|
||||
public static bool FromTagConfig(string tagConfig, string rawPath, out TwinCATTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("symbolPath", out var symbol)
|
||||
|| symbol.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
var symbolPath = symbol.GetString();
|
||||
if (string.IsNullOrWhiteSpace(symbolPath)) return false;
|
||||
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
|
||||
// instead of silently defaulting to a wrong-width Good.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", TwinCATDataType.DInt, out var dataType)) return false;
|
||||
// Guard (Driver.TwinCAT-003): the driver does not support UDT/FB-instance tags — a
|
||||
// Structure-typed tag would read as a garbage int blob or fail late on a write. Reject it
|
||||
// here so it never materialises (→ BadNodeIdUnknown); declare atomic-typed members instead,
|
||||
// or use EnableControllerBrowse to discover UDT members individually.
|
||||
if (dataType == TwinCATDataType.Structure) return false;
|
||||
// Array intent — same shape the runtime/OPC-UA foundation parses (camelCase
|
||||
// `isArray` bool + `arrayLength` uint). arrayLength is honoured ONLY when isArray
|
||||
// is true AND it is a positive JSON number, so a stale length behind a cleared
|
||||
// isArray never produces an orphan array tag (Phase 4c).
|
||||
var arrayLength = ReadArrayLength(root);
|
||||
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
|
||||
// authorable (UNDER-6).
|
||||
var writable = TagConfigJson.ReadWritable(root);
|
||||
def = new TwinCATTagDefinition(
|
||||
Name: rawPath, DeviceHostAddress: "", SymbolPath: symbolPath!,
|
||||
DataType: dataType, Writable: writable, WriteIdempotent: false, ArrayLength: arrayLength);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
catch (FormatException) { return false; }
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
|
||||
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
|
||||
/// <see cref="TwinCATTagDefinition"/> from operator flags and need the <c>TagConfig</c> string to hand
|
||||
/// the driver as a <see cref="RawTagEntry"/>. The <c>dataType</c> enum is written as its name string;
|
||||
/// the array keys are emitted only for an array tag so the blob stays minimal. Neither
|
||||
/// <c>WriteIdempotent</c> nor <c>DeviceHostAddress</c> is emitted — they travel on the
|
||||
/// <see cref="RawTagEntry"/> (<see cref="RawTagEntry.WriteIdempotent"/> /
|
||||
/// <see cref="RawTagEntry.DeviceName"/>), not inside the blob.
|
||||
/// </summary>
|
||||
/// <param name="def">The definition to serialise.</param>
|
||||
/// <returns>The camelCase TagConfig JSON string.</returns>
|
||||
public static string ToTagConfig(TwinCATTagDefinition def)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(def);
|
||||
var o = new JsonObject
|
||||
{
|
||||
["symbolPath"] = def.SymbolPath,
|
||||
["dataType"] = def.DataType.ToString(),
|
||||
["writable"] = def.Writable,
|
||||
};
|
||||
if (def.ArrayLength is > 0)
|
||||
{
|
||||
o["isArray"] = true;
|
||||
o["arrayLength"] = def.ArrayLength.Value;
|
||||
}
|
||||
return o.ToJsonString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
|
||||
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
|
||||
/// or not an equipment-tag object. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(reference);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("TwinCAT TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return warnings;
|
||||
}
|
||||
var w = TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(root, "dataType");
|
||||
if (w is not null) warnings.Add(w);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("TwinCAT TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the optional 1-D array length: <c>arrayLength</c> (a positive int32; values above
|
||||
/// <see cref="int.MaxValue"/> are treated as absent/scalar) honoured ONLY when <c>isArray</c>
|
||||
/// is the JSON literal <c>true</c>. Returns <c>null</c> (scalar) when isArray is absent/false,
|
||||
/// when arrayLength is absent / non-numeric / zero / negative / greater than
|
||||
/// <see cref="int.MaxValue"/>.
|
||||
/// </summary>
|
||||
private static int? ReadArrayLength(JsonElement o)
|
||||
{
|
||||
if (!o.TryGetProperty("isArray", out var aEl) || aEl.ValueKind != JsonValueKind.True)
|
||||
return null;
|
||||
if (!o.TryGetProperty("arrayLength", out var lEl) || lEl.ValueKind != JsonValueKind.Number)
|
||||
return null;
|
||||
return lEl.TryGetInt32(out var len) && len > 0 ? len : null;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user