Pure-text parser for Studio 5000 L5K controller exports. Recognises TAG/END_TAG, DATATYPE/END_DATATYPE, and PROGRAM/END_PROGRAM blocks, strips (* ... *) comments, and tolerates multi-line entries + unknown sections (CONFIG, MOTION_GROUP, etc.). Output records — L5kTag, L5kDataType, L5kMember — feed L5kIngest which converts to AbCipTagDefinition + AbCipStructureMember. Alias tags and ExternalAccess=None tags are skipped per Kepware precedent. AbCipDriverOptions gains an L5kImports collection (AbCipL5kImportOptions records — file path or inline text + per-import device + name prefix). InitializeAsync merges the imports into the declared Tags map, with declared tags winning on Name conflicts so operators can override import results without editing the L5K source. Tests cover controller-scope TAG, program-scope TAG, alias-tag flag, DATATYPE with member array dims, comment stripping, unknown-section skipping, multi-line entries, and the full ingest path including ExternalAccess=None / ReadOnly / UDT-typed tag fanout. Closes #229 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
9.8 KiB
C#
180 lines
9.8 KiB
C#
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>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Per v2 plan decisions #11 (libplctag), #41 (AbCip vs AbLegacy split), #143–144 (per-call
|
||
/// host resolver + resilience keys), #144 (bulkhead keyed on <c>(DriverInstanceId, HostName)</c>).
|
||
/// </remarks>
|
||
public sealed class AbCipDriverOptions
|
||
{
|
||
/// <summary>
|
||
/// 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>
|
||
public IReadOnlyList<AbCipDeviceOptions> Devices { get; init; } = [];
|
||
|
||
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
||
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
||
|
||
/// <summary>
|
||
/// L5K (Studio 5000 controller export) imports merged into <see cref="Tags"/> at
|
||
/// <c>InitializeAsync</c>. Each entry points at one L5K file + the device whose tags it
|
||
/// describes; the parser extracts <c>TAG</c> + <c>DATATYPE</c> blocks and produces
|
||
/// <see cref="AbCipTagDefinition"/> records (alias tags + ExternalAccess=None tags
|
||
/// skipped — see <see cref="Import.L5kIngest"/>). Pre-declared <see cref="Tags"/> entries
|
||
/// win on <c>Name</c> conflicts so operators can override import results without
|
||
/// editing the L5K source.
|
||
/// </summary>
|
||
public IReadOnlyList<AbCipL5kImportOptions> L5kImports { get; init; } = [];
|
||
|
||
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||
public AbCipProbeOptions Probe { get; init; } = new();
|
||
|
||
/// <summary>
|
||
/// Default libplctag call timeout applied to reads/writes/discovery when the caller does
|
||
/// not pass a more specific value. Matches the Modbus driver's 2-second default.
|
||
/// </summary>
|
||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||
|
||
/// <summary>
|
||
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's Logix symbol table via
|
||
/// the <c>@tags</c> pseudo-tag + surfaces controller-resident globals under a
|
||
/// <c>Discovered/</c> sub-folder. Pre-declared tags always emit regardless. Default
|
||
/// <c>false</c> to keep the strict-config path for deployments where only declared tags
|
||
/// should appear in the address space.
|
||
/// </summary>
|
||
public bool EnableControllerBrowse { get; init; }
|
||
|
||
/// <summary>
|
||
/// Task #177 — when <c>true</c>, declared ALMD tags are surfaced as alarm conditions
|
||
/// via <see cref="Core.Abstractions.IAlarmSource"/>; the driver polls each subscribed
|
||
/// alarm's <c>InFaulted</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
|
||
/// state transitions. Default <c>false</c> — operators explicitly opt in because
|
||
/// projection semantics don't exactly mirror Rockwell FT Alarm & Events; shops
|
||
/// running FT Live should keep this off + take alarms through the native route.
|
||
/// </summary>
|
||
public bool EnableAlarmProjection { get; init; }
|
||
|
||
/// <summary>
|
||
/// Poll interval for the ALMD projection loop. Shorter intervals catch faster edges
|
||
/// at the cost of PLC round-trips; edges shorter than this interval are invisible to
|
||
/// the projection (a 0→1→0 transition within one tick collapses to no event). Default
|
||
/// 1 second — matches typical SCADA alarm-refresh conventions.
|
||
/// </summary>
|
||
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||
}
|
||
|
||
/// <summary>
|
||
/// One PLC endpoint. <see cref="HostAddress"/> must parse via
|
||
/// <see cref="AbCipHostAddress.TryParse"/>; misconfigured devices fail driver
|
||
/// initialization rather than silently connecting to nothing.
|
||
/// </summary>
|
||
/// <param name="HostAddress">Canonical <c>ab://gateway[:port]/cip-path</c> string.</param>
|
||
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
|
||
/// request-packing support, unconnected-only hint, and other quirks.</param>
|
||
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
|
||
public sealed record AbCipDeviceOptions(
|
||
string HostAddress,
|
||
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
|
||
string? DeviceName = null);
|
||
|
||
/// <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="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>
|
||
/// <param name="WriteIdempotent">Per plan decisions #44–#45, #143 — safe to replay on write timeout. Default <c>false</c>.</param>
|
||
/// <param name="Members">For <see cref="AbCipDataType.Structure"/>-typed tags, the declared UDT
|
||
/// member layout. When supplied, discovery fans out the UDT into a folder + one Variable per
|
||
/// member (member TagPath = <c>{tag.TagPath}.{member.Name}</c>). When <c>null</c> on a Structure
|
||
/// tag, the driver treats it as a black-box and relies on downstream configuration to address
|
||
/// members individually via dotted <see cref="AbCipTagPath"/> syntax. Ignored for atomic types.</param>
|
||
/// <param name="SafetyTag">GuardLogix safety-partition tag hint. When <c>true</c>, the driver
|
||
/// forces <c>SecurityClassification.ViewOnly</c> on discovery regardless of
|
||
/// <paramref name="Writable"/> — safety tags can only be written from the safety task of a
|
||
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
|
||
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
||
/// write attempt failing at runtime.</param>
|
||
/// <param name="StringLength">Capacity of the DATA character array on a Logix STRING / STRINGnn
|
||
/// UDT — 82 for the stock <c>STRING</c>, 20/40/80/etc for user-defined <c>STRING_20</c>,
|
||
/// <c>STRING_40</c>, <c>STRING_80</c> variants. Threads through libplctag's
|
||
/// <c>str_max_capacity</c> attribute so the wrapper allocates the correct backing buffer
|
||
/// and <c>GetString</c> / <c>SetString</c> truncate at the right boundary. <c>null</c>
|
||
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
|
||
/// non-<see cref="AbCipDataType.String"/> types.</param>
|
||
public sealed record AbCipTagDefinition(
|
||
string Name,
|
||
string DeviceHostAddress,
|
||
string TagPath,
|
||
AbCipDataType DataType,
|
||
bool Writable = true,
|
||
bool WriteIdempotent = false,
|
||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||
bool SafetyTag = false,
|
||
int? StringLength = null);
|
||
|
||
/// <summary>
|
||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||
/// <c>Status</c>), DataType is the atomic Logix type, Writable/WriteIdempotent mirror
|
||
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
|
||
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
||
/// </summary>
|
||
public sealed record AbCipStructureMember(
|
||
string Name,
|
||
AbCipDataType DataType,
|
||
bool Writable = true,
|
||
bool WriteIdempotent = false,
|
||
int? StringLength = null);
|
||
|
||
/// <summary>
|
||
/// One L5K-import entry. Either <see cref="FilePath"/> or <see cref="InlineText"/> must be
|
||
/// set (FilePath wins when both supplied — useful for tests that pre-load fixtures into
|
||
/// options without touching disk).
|
||
/// </summary>
|
||
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||
/// <param name="FilePath">On-disk path to a <c>*.L5K</c> export. Loaded eagerly at InitializeAsync.</param>
|
||
/// <param name="InlineText">Pre-loaded L5K body — used by tests + Admin UI uploads.</param>
|
||
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
|
||
/// when ingesting multiple files into one driver instance.</param>
|
||
public sealed record AbCipL5kImportOptions(
|
||
string DeviceHostAddress,
|
||
string? FilePath = null,
|
||
string? InlineText = null,
|
||
string NamePrefix = "");
|
||
|
||
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||
public enum AbCipPlcFamily
|
||
{
|
||
ControlLogix,
|
||
CompactLogix,
|
||
Micro800,
|
||
GuardLogix,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Background connectivity-probe settings. Enabled by default; the probe reads a cheap tag
|
||
/// on the PLC at the configured interval to drive <see cref="Core.Abstractions.IHostConnectivityProbe"/>
|
||
/// state transitions + Admin UI health status.
|
||
/// </summary>
|
||
public sealed class AbCipProbeOptions
|
||
{
|
||
public bool Enabled { get; init; } = true;
|
||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||
|
||
/// <summary>
|
||
/// Tag path used for the probe. If null, the driver attempts to read a default
|
||
/// system tag (PR 8 wires this up — the choice is family-dependent, e.g.
|
||
/// <c>@raw_cpu_type</c> on ControlLogix or a user-configured probe tag on Micro800).
|
||
/// </summary>
|
||
public string? ProbeTagPath { get; init; }
|
||
}
|