v3(b1-abcip): RawPath read-path for AbCip (multi-device)
Apply the reviewed Modbus exemplar to the AbCip multi-device driver:
- Mapper AbCipEquipmentTagParser -> AbCipTagDefinitionFactory; TryParse -> FromTagConfig(tagConfig, rawPath, out def); drop leading-{ heuristic + the deviceHostAddress key; add inverse ToTagConfig; round-trip arrays/members/safety for coverage.
- Options: Tags (typed) -> RawTags (RawTagEntry list); Devices list kept.
- Driver: byName-only resolver over _tagsByRawPath (Ordinal); table built from RawTags via FromTagConfig, threading entry.DeviceName (device routing key) + entry.WriteIdempotent onto the def. Multi-device routing keys on DeviceName-or-host via a _devicesByRoutingKey index; discovery groups _declaredTags by RoutesToDevice.
- Factory: retire the pre-declared Tags DTO/BuildTag; bind List<RawTagEntry> RawTags.
- Probe: derive probe tag path from RawTags via the mapper.
- Cli BuildOptions projects typed tags -> RawTagEntry (ToTagConfig).
- Tests: AbCipRawTags helper (typed def -> RawTagEntry); parser tests -> FromTagConfig; blob-fallback driver tests rewritten to author via RawTags + read by RawPath. Driver.AbCip.Tests 342/342, Cli.Tests 42/42.
TODO(v3 WaveC): DeviceName becomes a pure logical name once the device connection host moves to the Device row's DeviceConfig.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using CliFx.Attributes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
|
||||
@@ -48,7 +49,10 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
/// <summary>
|
||||
/// Build an <see cref="AbCipDriverOptions"/> with the device + tag list a subclass
|
||||
/// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the
|
||||
/// probe loop would race the operator's own reads.
|
||||
/// probe loop would race the operator's own reads. Each typed tag is serialised to the v3
|
||||
/// <see cref="RawTagEntry"/> shape (RawPath = tag name, TagConfig blob via
|
||||
/// <see cref="AbCipTagDefinitionFactory.ToTagConfig"/>, DeviceName = the tag's device routing key)
|
||||
/// — the same seam the deploy artifact uses.
|
||||
/// </summary>
|
||||
/// <param name="tags">The list of tag definitions to include in the options.</param>
|
||||
/// <returns>The constructed <see cref="AbCipDriverOptions"/>.</returns>
|
||||
@@ -58,7 +62,11 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
HostAddress: Gateway,
|
||||
PlcFamily: Family,
|
||||
DeviceName: $"cli-{Family}")],
|
||||
Tags = tags,
|
||||
RawTags = [.. tags.Select(t => new RawTagEntry(
|
||||
RawPath: t.Name,
|
||||
TagConfig: AbCipTagDefinitionFactory.ToTagConfig(t),
|
||||
WriteIdempotent: t.WriteIdempotent,
|
||||
DeviceName: t.DeviceHostAddress))],
|
||||
Timeout = Timeout,
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = false,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// AB CIP / EtherNet-IP driver configuration, bound from the driver's <c>DriverConfig</c>
|
||||
/// JSON at <c>DriverHost.RegisterAsync</c>. One instance supports N devices (PLCs) behind
|
||||
/// the same driver; per-device routing is keyed on <see cref="AbCipDeviceOptions.HostAddress"/>
|
||||
/// via <c>IPerCallHostResolver</c>.
|
||||
/// the same driver; per-device routing is keyed on the tag's device routing key (the RawPath's
|
||||
/// device segment, delivered on <see cref="RawTagEntry.DeviceName"/>).
|
||||
/// </summary>
|
||||
public sealed class AbCipDriverOptions
|
||||
{
|
||||
@@ -12,14 +14,23 @@ public sealed class AbCipDriverOptions
|
||||
/// PLCs this driver instance talks to. Each device contributes its own <see cref="AbCipHostAddress"/>
|
||||
/// string as the <c>hostName</c> key used by resilience pipelines and the Admin UI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wave C moves the device's live connection host into the <c>Device</c> row's <c>DeviceConfig</c>;
|
||||
/// for Wave B the list stays bound from <c>DriverConfig</c> as today, and a tag routes to its device
|
||||
/// by matching <see cref="RawTagEntry.DeviceName"/> against the device's name (<see cref="AbCipDeviceOptions.DeviceName"/>)
|
||||
/// or its <see cref="AbCipDeviceOptions.HostAddress"/>.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<AbCipDeviceOptions> Devices { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Pre-declared tag map across all devices. Pre-declared tags always emit during
|
||||
/// discovery; opt in to controller-side discovery via
|
||||
/// 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 + DeviceName routing key); the driver maps each through
|
||||
/// <see cref="AbCipTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition table.
|
||||
/// Pre-declared tags always emit during discovery; opt in to controller-side discovery via
|
||||
/// <see cref="EnableControllerBrowse"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
|
||||
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||||
public AbCipProbeOptions Probe { get; init; } = new();
|
||||
@@ -111,8 +122,13 @@ public sealed record AbCipDeviceOptions(
|
||||
/// <summary>
|
||||
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
|
||||
/// </summary>
|
||||
/// <param name="Name">Tag name; becomes the OPC UA browse name and full reference.</param>
|
||||
/// <param name="DeviceHostAddress">Which device (<see cref="AbCipDeviceOptions.HostAddress"/>) this tag lives on.</param>
|
||||
/// <param name="Name">Tag name; becomes the OPC UA browse name and full reference (the RawPath in v3).</param>
|
||||
/// <param name="DeviceHostAddress">The tag's <b>device routing key</b> — the RawPath's device segment,
|
||||
/// threaded in from <see cref="RawTagEntry.DeviceName"/> by the driver at table-build time (the mapper
|
||||
/// leaves it empty; device placement is no longer in the TagConfig blob). Resolved against the driver's
|
||||
/// devices by matching <see cref="AbCipDeviceOptions.DeviceName"/> or <see cref="AbCipDeviceOptions.HostAddress"/>.
|
||||
/// TODO(v3 WaveC): once the connection host moves to the Device row's DeviceConfig this is a pure logical
|
||||
/// device name; the name still reads <c>DeviceHostAddress</c> to avoid churn across the driver + CLI.</param>
|
||||
/// <param name="TagPath">Logix symbolic path (controller or program scope).</param>
|
||||
/// <param name="DataType">Logix atomic type, or <see cref="AbCipDataType.Structure"/> for UDT-typed tags.</param>
|
||||
/// <param name="Writable">When <c>true</c> and the tag's ExternalAccess permits writes, IWritable routes writes here.</param>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
|
||||
/// <c>AbCipTagConfigModel</c>) into a transient <see cref="AbCipTagDefinition"/> whose
|
||||
/// <see cref="AbCipTagDefinition.Name"/> equals the reference string itself, so a value the
|
||||
/// driver publishes back keys the runtime's forward router correctly.</summary>
|
||||
public static class AbCipEquipmentTagParser
|
||||
{
|
||||
/// <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 an AbCip TagConfig object.</returns>
|
||||
/// <remarks>
|
||||
/// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c>
|
||||
/// boolean field in the TagConfig JSON; it defaults to <c>true</c> when the field is absent,
|
||||
/// matching the record's documented default and the behaviour of pre-declared tags. Operators
|
||||
/// who need a read-only OPC UA surface can author <c>"writable":false</c> in the TagConfig;
|
||||
/// the PLC's ExternalAccess attribute remains the effective write gate at the wire level.
|
||||
/// </remarks>
|
||||
public static bool TryParse(string reference, out AbCipTagDefinition 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;
|
||||
// AbCip is a symbolic driver: the mandatory addressing field is the Logix tag path.
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("tagPath", out var tagPathEl)
|
||||
|| tagPathEl.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
var tagPath = tagPathEl.GetString();
|
||||
if (string.IsNullOrWhiteSpace(tagPath)) return false;
|
||||
|
||||
var deviceHostAddress = ReadString(root, "deviceHostAddress");
|
||||
// A "dataType":"Structure" input is accepted and produces a Structure-typed definition
|
||||
// with Members:null. The driver treats this as a black-box dotted-path read: libplctag
|
||||
// resolves the full tag path (e.g. "Motor.Speed") without enumerating UDT members.
|
||||
// The address space emits a placeholder String variable; UDT member declarations are
|
||||
// not supported in the equipment-tag flow.
|
||||
// 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", AbCipDataType.DInt, out var dataType)) return false;
|
||||
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A
|
||||
// 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the
|
||||
// foundation materialises a [1] OPC UA array node — so it must read as an array, not a
|
||||
// scalar. ElementCount can't carry the signal (a scalar and a 1-element array both
|
||||
// have a count of 1), so the explicit IsArray flag does.
|
||||
var (isArray, elementCount) = ReadArrayShape(root);
|
||||
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
|
||||
// Now via the shared TagConfigJson.ReadWritable (explicit-false-only), byte-identical to the
|
||||
// hand-rolled read it replaces.
|
||||
var writable = TagConfigJson.ReadWritable(root);
|
||||
def = new AbCipTagDefinition(
|
||||
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
|
||||
DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
catch (FormatException) { return false; }
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the 1-D array shape from an <c>isArray</c> / <c>arrayLength</c> pair (the foundation
|
||||
/// contract carrier). The canonical rule: the tag is an ARRAY ⟺ <c>isArray</c> is truthy AND
|
||||
/// <c>arrayLength</c> is a number <c>>= 1</c>. Any other combination (isArray absent/false,
|
||||
/// or isArray:true with arrayLength missing/invalid/<1) is a SCALAR and returns
|
||||
/// <c>(IsArray: false, ElementCount: 1)</c>. This matches every other driver (Modbus, S7,
|
||||
/// TwinCAT, AbLegacy) which also return scalar for that degenerate input.
|
||||
/// </summary>
|
||||
private static (bool IsArray, int ElementCount) ReadArrayShape(JsonElement o)
|
||||
{
|
||||
var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True;
|
||||
if (!isArray) return (false, 1);
|
||||
if (o.TryGetProperty("arrayLength", out var len)
|
||||
&& len.ValueKind == JsonValueKind.Number
|
||||
&& len.TryGetInt32(out var n)
|
||||
&& n >= 1)
|
||||
return (true, n);
|
||||
// isArray:true but arrayLength missing/invalid/<1 — canonical rule: scalar (matches all other drivers).
|
||||
return (false, 1);
|
||||
}
|
||||
|
||||
/// <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("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return warnings;
|
||||
}
|
||||
var w = TagConfigJson.DescribeInvalidEnum<AbCipDataType>(root, "dataType");
|
||||
if (w is not null) warnings.Add(w);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("AbCip 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() ?? "" : "";
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
|
||||
/// AdminUI <c>AbCipTagConfigModel</c>) into an <see cref="AbCipTagDefinition"/>. 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="AbCipTagDefinition.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 + the driver-test <c>AbCipRawTags</c> helper) that hold a typed definition and need to
|
||||
/// synthesise the <c>TagConfig</c> blob for a <see cref="RawTagEntry"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Device placement is NOT in the blob.</b> The pre-v3 parser read a <c>deviceHostAddress</c>
|
||||
/// key off the TagConfig; v3 drops it — a tag's device is the RawPath's device segment, delivered
|
||||
/// out-of-band on <see cref="RawTagEntry.DeviceName"/> and threaded onto the definition's
|
||||
/// <see cref="AbCipTagDefinition.DeviceHostAddress"/> (the device routing key) by the driver at
|
||||
/// table-build time. <see cref="FromTagConfig"/> therefore leaves that field empty.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Array / member / safety round-trip.</b> Today's production authoring surface
|
||||
/// (<c>AbCipTagConfigModel</c>) emits only <c>tagPath</c> / <c>dataType</c> / <c>writable</c>, so a
|
||||
/// real equipment tag maps to a scalar atomic definition. The additional <c>isArray</c> /
|
||||
/// <c>arrayLength</c> / <c>safetyTag</c> / <c>members</c> keys are read (and emitted by
|
||||
/// <see cref="ToTagConfig"/>) so the retired pre-declared-tag coverage — arrays and declared UDT
|
||||
/// member fan-out — survives the migration to the RawPath seam. They are additive: absent keys map
|
||||
/// to the scalar / no-member / non-safety defaults.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class AbCipTagDefinitionFactory
|
||||
{
|
||||
/// <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 mandatory addressing field is the Logix <c>tagPath</c>; <c>dataType</c> 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="AbCipTagDefinition.DeviceHostAddress"/> and <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
||||
/// are NOT read from the blob — the driver threads them in from the tag's <see cref="RawTagEntry"/>.
|
||||
/// </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 AbCip address object.</returns>
|
||||
/// <remarks>
|
||||
/// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c> boolean;
|
||||
/// it defaults to <c>true</c> when absent, matching the record's documented default. Operators who
|
||||
/// need a read-only OPC UA surface can author <c>"writable":false</c>; the PLC's ExternalAccess
|
||||
/// attribute remains the effective write gate at the wire level.
|
||||
/// </remarks>
|
||||
public static bool FromTagConfig(string tagConfig, string rawPath, out AbCipTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
// AbCip is a symbolic driver: the mandatory addressing field is the Logix tag path.
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("tagPath", out var tagPathEl)
|
||||
|| tagPathEl.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
var tagPath = tagPathEl.GetString();
|
||||
if (string.IsNullOrWhiteSpace(tagPath)) 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", AbCipDataType.DInt, out var dataType)) return false;
|
||||
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A 1-element
|
||||
// array is a VALID 1-element array (the foundation materialises a [1] OPC UA array node).
|
||||
var (isArray, elementCount) = ReadArrayShape(root);
|
||||
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
|
||||
var writable = TagConfigJson.ReadWritable(root);
|
||||
var safetyTag = ReadBool(root, "safetyTag");
|
||||
|
||||
// Optional declared UDT member fan-out (round-trips the retired pre-declared coverage). A
|
||||
// malformed member (missing name / typo'd dataType) rejects the whole tag, mirroring the
|
||||
// strict top-level contract.
|
||||
IReadOnlyList<AbCipStructureMember>? members = null;
|
||||
if (root.TryGetProperty("members", out var membersEl) && membersEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var list = new List<AbCipStructureMember>();
|
||||
foreach (var m in membersEl.EnumerateArray())
|
||||
{
|
||||
if (m.ValueKind != JsonValueKind.Object
|
||||
|| !m.TryGetProperty("name", out var nameEl)
|
||||
|| nameEl.ValueKind != JsonValueKind.String
|
||||
|| string.IsNullOrWhiteSpace(nameEl.GetString()))
|
||||
return false;
|
||||
if (!TagConfigJson.TryReadEnumStrict(m, "dataType", AbCipDataType.DInt, out var memberType)) return false;
|
||||
var (memberIsArray, memberCount) = ReadArrayShape(m);
|
||||
list.Add(new AbCipStructureMember(
|
||||
Name: nameEl.GetString()!,
|
||||
DataType: memberType,
|
||||
Writable: ReadBoolDefaultTrue(m, "writable"),
|
||||
WriteIdempotent: ReadBool(m, "writeIdempotent"),
|
||||
ElementCount: memberCount,
|
||||
IsArray: memberIsArray));
|
||||
}
|
||||
if (list.Count > 0) members = list;
|
||||
}
|
||||
|
||||
def = new AbCipTagDefinition(
|
||||
Name: rawPath, DeviceHostAddress: "", TagPath: tagPath!,
|
||||
DataType: dataType, Writable: writable, WriteIdempotent: false,
|
||||
Members: members, SafetyTag: safetyTag, ElementCount: elementCount, IsArray: isArray);
|
||||
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, the driver-test
|
||||
/// <c>AbCipRawTags</c> helper) that construct an <see cref="AbCipTagDefinition"/> and need the
|
||||
/// <c>TagConfig</c> string to hand the driver as a <see cref="RawTagEntry"/>. Enum values are written
|
||||
/// as their name strings; optional fields are emitted only when non-default so the blob stays minimal.
|
||||
/// <see cref="AbCipTagDefinition.DeviceHostAddress"/> (device routing key) and
|
||||
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> are NOT emitted — they travel on the
|
||||
/// <see cref="RawTagEntry"/>, not inside the blob.
|
||||
/// </summary>
|
||||
/// <param name="def">The definition to serialise.</param>
|
||||
/// <returns>The camelCase TagConfig JSON string.</returns>
|
||||
public static string ToTagConfig(AbCipTagDefinition def)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(def);
|
||||
var o = new JsonObject
|
||||
{
|
||||
["tagPath"] = def.TagPath,
|
||||
["dataType"] = def.DataType.ToString(),
|
||||
["writable"] = def.Writable,
|
||||
};
|
||||
if (def.IsArray)
|
||||
{
|
||||
o["isArray"] = true;
|
||||
o["arrayLength"] = Math.Max(1, def.ElementCount);
|
||||
}
|
||||
if (def.SafetyTag) o["safetyTag"] = true;
|
||||
if (def.Members is { Count: > 0 } members)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
foreach (var m in members)
|
||||
{
|
||||
var mo = new JsonObject
|
||||
{
|
||||
["name"] = m.Name,
|
||||
["dataType"] = m.DataType.ToString(),
|
||||
["writable"] = m.Writable,
|
||||
};
|
||||
if (m.WriteIdempotent) mo["writeIdempotent"] = true;
|
||||
if (m.IsArray)
|
||||
{
|
||||
mo["isArray"] = true;
|
||||
mo["arrayLength"] = Math.Max(1, m.ElementCount);
|
||||
}
|
||||
arr.Add(mo);
|
||||
}
|
||||
o["members"] = arr;
|
||||
}
|
||||
return o.ToJsonString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the 1-D array shape from an <c>isArray</c> / <c>arrayLength</c> pair (the foundation
|
||||
/// contract carrier). The canonical rule: the tag is an ARRAY ⟺ <c>isArray</c> is truthy AND
|
||||
/// <c>arrayLength</c> is a number <c>>= 1</c>. Any other combination is a SCALAR and returns
|
||||
/// <c>(IsArray: false, ElementCount: 1)</c> — matching every other driver.
|
||||
/// </summary>
|
||||
private static (bool IsArray, int ElementCount) ReadArrayShape(JsonElement o)
|
||||
{
|
||||
var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True;
|
||||
if (!isArray) return (false, 1);
|
||||
if (o.TryGetProperty("arrayLength", out var len)
|
||||
&& len.ValueKind == JsonValueKind.Number
|
||||
&& len.TryGetInt32(out var n)
|
||||
&& n >= 1)
|
||||
return (true, n);
|
||||
// isArray:true but arrayLength missing/invalid/<1 — canonical rule: scalar (matches all other drivers).
|
||||
return (false, 1);
|
||||
}
|
||||
|
||||
/// <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("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return warnings;
|
||||
}
|
||||
var w = TagConfigJson.DescribeInvalidEnum<AbCipDataType>(root, "dataType");
|
||||
if (w is not null) warnings.Add(w);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static bool ReadBool(JsonElement o, string name)
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static bool ReadBoolDefaultTrue(JsonElement o, string name)
|
||||
=> !o.TryGetProperty(name, out var e) || e.ValueKind != JsonValueKind.False;
|
||||
}
|
||||
@@ -82,12 +82,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_discoveredUdtShapes[UdtShapeKey(deviceHostAddress, structName)] = shape;
|
||||
|
||||
private readonly PollGroupEngine _poll;
|
||||
// Devices keyed by host address — the primary registry (GetDeviceState, discovery, probe, diagnostics).
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
// Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias)
|
||||
// → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on
|
||||
// the RawPath's device segment. TODO(v3 WaveC): collapses to a pure DeviceName index once the connection
|
||||
// host moves into the Device row's DeviceConfig.
|
||||
private readonly Dictionary<string, DeviceState> _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
|
||||
// v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive
|
||||
// ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||
// Top-level authored definitions (parents + flat tags, NOT synthesised members), in author order —
|
||||
// the source DiscoverAsync groups by device. Members are synthesised inside the discovery/read paths.
|
||||
private readonly List<AbCipTagDefinition> _declaredTags = new();
|
||||
|
||||
// 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 AbCipEquipmentTagParser, cached).
|
||||
// Resolves a read/write/subscribe fullReference (always a RawPath in v3) to a tag definition by a
|
||||
// single hit on the authored _tagsByRawPath table; a miss is a miss (the pre-v3 blob-parse fallback
|
||||
// is retired — see EquipmentTagRefResolver).
|
||||
private readonly EquipmentTagRefResolver<AbCipTagDefinition> _resolver;
|
||||
|
||||
private readonly ILogger<AbCipDriver> _logger;
|
||||
@@ -128,8 +139,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
|
||||
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
|
||||
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
|
||||
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
|
||||
r => AbCipEquipmentTagParser.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) =>
|
||||
@@ -248,7 +258,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
||||
{
|
||||
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
|
||||
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
|
||||
{
|
||||
_options = parsed;
|
||||
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
|
||||
@@ -261,18 +271,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
|
||||
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||
var state = new DeviceState(addr, device, profile);
|
||||
_devices[device.HostAddress] = state;
|
||||
// Route index: a tag reaches this device by matching its DeviceName routing key OR its
|
||||
// host address. Devices with no explicit DeviceName are identified purely by host today.
|
||||
_devicesByRoutingKey[device.HostAddress] = state;
|
||||
if (!string.IsNullOrEmpty(device.DeviceName))
|
||||
_devicesByRoutingKey[device.DeviceName] = state;
|
||||
}
|
||||
foreach (var tag in _options.Tags)
|
||||
|
||||
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig blob
|
||||
// is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are
|
||||
// threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is
|
||||
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
|
||||
foreach (var entry in _options.RawTags)
|
||||
{
|
||||
// Duplicate-key check: a collision means two configured tags have the same name.
|
||||
if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AbCip tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
continue;
|
||||
}
|
||||
var tag = mapped with { DeviceHostAddress = entry.DeviceName, WriteIdempotent = entry.WriteIdempotent };
|
||||
|
||||
// Duplicate-key check: a collision means two authored tags share the same RawPath.
|
||||
// Fail fast at init time with a diagnostic rather than silently clobbering.
|
||||
if (_tagsByName.TryGetValue(tag.Name, out var existingTag))
|
||||
if (_tagsByRawPath.TryGetValue(tag.Name, out var existingTag))
|
||||
throw new InvalidOperationException(
|
||||
$"AbCip tag name collision: '{tag.Name}' is declared more than once. " +
|
||||
$"Existing entry DeviceHostAddress='{existingTag.DeviceHostAddress}', " +
|
||||
$"TagPath='{existingTag.TagPath}'. Rename or remove the duplicate.");
|
||||
_tagsByName[tag.Name] = tag;
|
||||
_tagsByRawPath[tag.Name] = tag;
|
||||
_declaredTags.Add(tag);
|
||||
|
||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||
{
|
||||
@@ -295,12 +326,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Member fan-out duplicate check: a member-path collision means two
|
||||
// configured structure tags produce the same member path, or a member
|
||||
// name collides with an independently-declared tag.
|
||||
if (_tagsByName.TryGetValue(memberTag.Name, out var existingMember))
|
||||
if (_tagsByRawPath.TryGetValue(memberTag.Name, out var existingMember))
|
||||
throw new InvalidOperationException(
|
||||
$"AbCip tag name collision: '{memberTag.Name}' is produced by both " +
|
||||
$"'{tag.Name}.{member.Name}' (member fan-out) and an existing tag " +
|
||||
$"'{existingMember.Name}'. Rename one of the configured tags to resolve.");
|
||||
_tagsByName[memberTag.Name] = memberTag;
|
||||
_tagsByRawPath[memberTag.Name] = memberTag;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,8 +414,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
state.DisposeHandles();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
|
||||
_devicesByRoutingKey.Clear();
|
||||
_tagsByRawPath.Clear();
|
||||
_declaredTags.Clear();
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
@@ -502,19 +535,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a read/write/subscribe reference to the device host that keys its per-host
|
||||
/// resilience (bulkhead / circuit breaker). CONV-4: routes through
|
||||
/// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
|
||||
/// JSON) resolves to its OWN authored device rather than the first-device fallback — else a
|
||||
/// broken device B would trip device A's breaker. The resolver caches parses, so this adds
|
||||
/// no per-call cost after first use.
|
||||
/// Resolve a read/write/subscribe reference (a RawPath in v3) to the device host that keys its
|
||||
/// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through the authored table so a
|
||||
/// tag resolves to its OWN device's host rather than the first-device fallback — else a broken
|
||||
/// device B would trip device A's breaker. The tag's device routing key is resolved to the device's
|
||||
/// actual host address (the resilience key), matching by DeviceName-or-host.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
|
||||
/// <param name="fullReference">The tag RawPath reference.</param>
|
||||
/// <returns>The device host key.</returns>
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress))
|
||||
return def.DeviceHostAddress;
|
||||
if (_resolver.TryResolve(fullReference, out var def)
|
||||
&& _devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var state))
|
||||
return state.Options.HostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
}
|
||||
|
||||
@@ -536,7 +569,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
|
||||
// reorder UDT members vs declaration order, so the fast path is opt-in only.
|
||||
var plan = AbCipUdtReadPlanner.Build(
|
||||
fullReferences, _tagsByName, _options.EnableDeclarationOnlyUdtGrouping);
|
||||
fullReferences, _tagsByRawPath, _options.EnableDeclarationOnlyUdtGrouping);
|
||||
|
||||
foreach (var group in plan.Groups)
|
||||
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
|
||||
@@ -564,7 +597,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
return;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return;
|
||||
@@ -646,7 +679,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
var parent = group.ParentDefinition;
|
||||
|
||||
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
|
||||
if (!_devicesByRoutingKey.TryGetValue(parent.DeviceHostAddress, out var device))
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
|
||||
return;
|
||||
@@ -736,7 +769,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
@@ -1017,9 +1050,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Pre-declared tags — always emitted; the primary config path. UDT tags with declared
|
||||
// Members fan out into a sub-folder + one Variable per member instead of a single
|
||||
// Structure Variable (Structure has no useful scalar value + member-addressable paths
|
||||
// are what downstream consumers actually want).
|
||||
var preDeclared = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
// are what downstream consumers actually want). A tag reaches this device when its
|
||||
// device routing key matches the device's DeviceName or its host address.
|
||||
var preDeclared = _declaredTags.Where(t => RoutesToDevice(t, device));
|
||||
foreach (var tag in preDeclared)
|
||||
{
|
||||
if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;
|
||||
@@ -1235,6 +1268,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// a legacy definition carrying only ElementCount > 1 stays an array for back-compat.
|
||||
private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1;
|
||||
|
||||
// A tag routes to a device when its device routing key (DeviceHostAddress, threaded from the
|
||||
// RawTagEntry.DeviceName) matches the device's DeviceName or its host address. TODO(v3 WaveC):
|
||||
// collapses to a DeviceName-only match once the connection host lives on the Device row's DeviceConfig.
|
||||
private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) =>
|
||||
string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)
|
||||
|| (!string.IsNullOrEmpty(device.DeviceName)
|
||||
&& string.Equals(tag.DeviceHostAddress, device.DeviceName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
@@ -68,9 +68,11 @@ public static class AbCipDriverFactoryExtensions
|
||||
AllowPacking: d.AllowPacking,
|
||||
ConnectionSize: d.ConnectionSize))]
|
||||
: [],
|
||||
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 AbCip
|
||||
// tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each entry
|
||||
// through AbCipTagDefinitionFactory.FromTagConfig at Initialize; the legacy pre-declared DTO
|
||||
// tag path (structured Tags[] → BuildTag) is retired.
|
||||
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
@@ -100,32 +102,6 @@ public static class AbCipDriverFactoryExtensions
|
||||
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
|
||||
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
|
||||
|
||||
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
|
||||
new(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"AB CIP config for '{driverInstanceId}' has a tag missing Name"),
|
||||
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||
TagPath: t.TagPath ?? throw new InvalidOperationException(
|
||||
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing TagPath"),
|
||||
DataType: ParseEnum<AbCipDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
|
||||
Writable: t.Writable ?? true,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false,
|
||||
Members: t.Members is { Count: > 0 }
|
||||
? [.. t.Members.Select(m => new AbCipStructureMember(
|
||||
Name: m.Name ?? throw new InvalidOperationException(
|
||||
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' has a member missing Name"),
|
||||
DataType: ParseEnum<AbCipDataType>(m.DataType, t.Name, driverInstanceId,
|
||||
$"Members[{m.Name}].DataType"),
|
||||
Writable: m.Writable ?? true,
|
||||
WriteIdempotent: m.WriteIdempotent ?? false,
|
||||
ElementCount: m.ElementCount is > 0 ? m.ElementCount.Value : 1,
|
||||
IsArray: m.IsArray))]
|
||||
: null,
|
||||
SafetyTag: t.SafetyTag ?? false,
|
||||
ElementCount: t.ElementCount is > 0 ? t.ElementCount.Value : 1,
|
||||
IsArray: t.IsArray);
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
||||
T? fallback = null) where T : struct, Enum
|
||||
{
|
||||
@@ -182,9 +158,10 @@ public static class AbCipDriverFactoryExtensions
|
||||
public List<AbCipDeviceDto>? Devices { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of tags to monitor.
|
||||
/// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName)
|
||||
/// the deploy artifact delivers. The driver maps each through the tag-definition factory at Initialize.
|
||||
/// </summary>
|
||||
public List<AbCipTagDto>? Tags { get; init; }
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe configuration.
|
||||
@@ -220,100 +197,6 @@ public static class AbCipDriverFactoryExtensions
|
||||
public int? ConnectionSize { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipTagDto
|
||||
{
|
||||
/// <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 tag path.
|
||||
/// </summary>
|
||||
public string? TagPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the data type.
|
||||
/// </summary>
|
||||
public string? DataType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the tag is writable.
|
||||
/// </summary>
|
||||
public bool? Writable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether write is idempotent.
|
||||
/// </summary>
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of structure members.
|
||||
/// </summary>
|
||||
public List<AbCipMemberDto>? Members { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this is a safety tag.
|
||||
/// </summary>
|
||||
public bool? SafetyTag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the explicit 1-D array signal — <c>true</c> ⟺ the tag is an array (even a
|
||||
/// 1-element one). Mirrors <c>AbCipTagDefinition.IsArray</c>; optional (absent ⇒ scalar).
|
||||
/// </summary>
|
||||
[JsonPropertyName("isArray")]
|
||||
public bool IsArray { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of elements for a 1-D array tag; <c>1</c> (or absent) for a scalar.
|
||||
/// Mirrors <c>AbCipTagDefinition.ElementCount</c>; only positive values are honoured.
|
||||
/// </summary>
|
||||
[JsonPropertyName("elementCount")]
|
||||
public int? ElementCount { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipMemberDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the member name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the data type.
|
||||
/// </summary>
|
||||
public string? DataType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the member is writable.
|
||||
/// </summary>
|
||||
public bool? Writable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether write is idempotent.
|
||||
/// </summary>
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the explicit 1-D array signal for the member — <c>true</c> ⟺ the member is
|
||||
/// an array (even a 1-element one). Mirrors <c>AbCipStructureMember.IsArray</c>; optional.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isArray")]
|
||||
public bool IsArray { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of elements for a 1-D array member; <c>1</c> (or absent) for a
|
||||
/// scalar. Mirrors <c>AbCipStructureMember.ElementCount</c>; only positive values are honoured.
|
||||
/// </summary>
|
||||
[JsonPropertyName("elementCount")]
|
||||
public int? ElementCount { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipProbeDto
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -89,14 +89,22 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
// Forward Open to confirm the remote endpoint speaks CIP, not just TCP.
|
||||
var profile = AbCipPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbCipPlcFamily.ControlLogix);
|
||||
|
||||
// Prefer the first declared tag path as the probe tag name (it must exist on the PLC).
|
||||
// Fall back to a benign system attribute that is present on all ControlLogix/CompactLogix
|
||||
// controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix, which
|
||||
// is sufficient to confirm CIP reachability.
|
||||
var tagName = opts.Tags.FirstOrDefault(
|
||||
t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
?.TagPath
|
||||
?? opts.Tags.FirstOrDefault()?.TagPath
|
||||
// Prefer the first authored tag path as the probe tag name (it must exist on the PLC). v3: the
|
||||
// tags arrive as RawTagEntry blobs — map each through the pure factory to recover its TagPath +
|
||||
// device routing key. Fall back to a benign system attribute present on all ControlLogix /
|
||||
// CompactLogix controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix,
|
||||
// which is sufficient to confirm CIP reachability.
|
||||
var mappedTags = opts.RawTags
|
||||
.Select(e => AbCipTagDefinitionFactory.FromTagConfig(e.TagConfig, e.RawPath, out var d)
|
||||
? (Ok: true, Device: e.DeviceName, d.TagPath)
|
||||
: (Ok: false, Device: e.DeviceName, TagPath: null!))
|
||||
.Where(x => x.Ok)
|
||||
.ToList();
|
||||
var tagName = mappedTags.FirstOrDefault(
|
||||
x => string.Equals(x.Device, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase)
|
||||
|| (firstDevice?.DeviceName is { } dn && string.Equals(x.Device, dn, StringComparison.OrdinalIgnoreCase)))
|
||||
.TagPath
|
||||
?? mappedTags.FirstOrDefault().TagPath
|
||||
?? "@raw_cpu_type";
|
||||
|
||||
var p = new AbCipTagCreateParams(
|
||||
|
||||
@@ -18,9 +18,10 @@ public static class AbCipUdtReadPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
|
||||
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
|
||||
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
|
||||
/// to match the driver's dictionary semantics. When
|
||||
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByRawPath</c> map (RawPath → def) —
|
||||
/// both parent UDT rows and their fanned-out member rows live there. Tag lookup honours the
|
||||
/// passed dictionary's comparer (v3: case-sensitive Ordinal RawPaths); the internal
|
||||
/// parent-name grouping is OrdinalIgnoreCase. When
|
||||
/// <paramref name="enableDeclarationOnlyGrouping"/> is <c>false</c> no groups are
|
||||
/// formed — every reference goes to the per-tag fallback path so member decoding never
|
||||
/// relies on declaration-order offsets that may not match the controller layout.
|
||||
|
||||
Reference in New Issue
Block a user