AOI-aware browse paths: AOI instances now fan out under directional sub-folders (Inputs/, Outputs/, InOut/) instead of a flat layout. The sub-folders only appear when at least one member carries a non-Local AoiQualifier, so plain UDT tags keep the pre-2.6 flat structure. - Add AoiQualifier enum (Local / Input / Output / InOut) + new property on AbCipStructureMember (defaults to Local). - L5K parser learns ADD_ON_INSTRUCTION_DEFINITION blocks; PARAMETER entries' Usage attribute flows through L5kMember.Usage. - L5X parser captures the Usage attribute on <Parameter> elements. - L5kIngest maps Usage strings (Input/Output/InOut) to AoiQualifier; null + unknown values map to Local. - AbCipDriver.DiscoverAsync groups directional members under Inputs / Outputs / InOut sub-folders when any member is non-Local. - Tests for L5K AOI block parsing, L5X Usage capture, ingest mapping (both formats), and AOI-vs-plain UDT discovery fan-out. Closes #234
269 lines
15 KiB
C#
269 lines
15 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>
|
||
/// L5X (Studio 5000 XML controller export) imports merged into <see cref="Tags"/> at
|
||
/// <c>InitializeAsync</c>. Same shape and merge semantics as <see cref="L5kImports"/> —
|
||
/// the entries differ only in source format. Pre-declared <see cref="Tags"/> entries win
|
||
/// on <c>Name</c> conflicts; entries already produced by <see cref="L5kImports"/> also win
|
||
/// so an L5X re-export of the same controller doesn't double-emit. See
|
||
/// <see cref="Import.L5xParser"/> for the format-specific mechanics.
|
||
/// </summary>
|
||
public IReadOnlyList<AbCipL5xImportOptions> L5xImports { get; init; } = [];
|
||
|
||
/// <summary>
|
||
/// Kepware-format CSV imports merged into <see cref="Tags"/> at <c>InitializeAsync</c>.
|
||
/// Same merge semantics as <see cref="L5kImports"/> / <see cref="L5xImports"/> —
|
||
/// pre-declared <see cref="Tags"/> entries win on <c>Name</c> conflicts, and tags
|
||
/// produced by earlier import collections (L5K → L5X → CSV in call order) also win
|
||
/// so an Excel-edited copy of the same controller does not double-emit. See
|
||
/// <see cref="Import.CsvTagImporter"/> for the column layout + parse rules.
|
||
/// </summary>
|
||
public IReadOnlyList<AbCipCsvImportOptions> CsvImports { 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>
|
||
/// <param name="Description">Tag description carried from the L5K/L5X export (or set explicitly
|
||
/// in pre-declared config). Surfaces as the OPC UA <c>Description</c> attribute on the
|
||
/// produced Variable node so SCADA / engineering clients see the comment from the source
|
||
/// project. <c>null</c> leaves Description unset, matching pre-2.3 behaviour.</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,
|
||
string? Description = 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>
|
||
/// <remarks>
|
||
/// <para><see cref="Description"/> carries the per-member comment from L5K/L5X UDT definitions so
|
||
/// the OPC UA Variable nodes produced for individual members surface their descriptions too,
|
||
/// not just the top-level tag.</para>
|
||
/// <para>PR abcip-2.6 — <see cref="AoiQualifier"/> tags AOI parameters as Input / Output /
|
||
/// InOut / Local. Plain UDT members default to <see cref="AoiQualifier.Local"/>. Discovery
|
||
/// groups Input / Output / InOut members under sub-folders so an AOI-typed tag fans out as
|
||
/// <c>Tag/Inputs/...</c>, <c>Tag/Outputs/...</c>, <c>Tag/InOut/...</c> while Local stays at the
|
||
/// UDT root — matching how AOIs visually present in Studio 5000.</para>
|
||
/// </remarks>
|
||
public sealed record AbCipStructureMember(
|
||
string Name,
|
||
AbCipDataType DataType,
|
||
bool Writable = true,
|
||
bool WriteIdempotent = false,
|
||
int? StringLength = null,
|
||
string? Description = null,
|
||
AoiQualifier AoiQualifier = AoiQualifier.Local);
|
||
|
||
/// <summary>
|
||
/// PR abcip-2.6 — directional qualifier for AOI parameters. Surfaces the Studio 5000
|
||
/// <c>Usage</c> attribute (<c>Input</c> / <c>Output</c> / <c>InOut</c>) so discovery can group
|
||
/// AOI members into sub-folders and downstream consumers can reason about parameter direction.
|
||
/// Plain UDT members (non-AOI types) default to <see cref="Local"/>, which keeps them at the
|
||
/// UDT root + indicates they are internal storage rather than a directional parameter.
|
||
/// </summary>
|
||
public enum AoiQualifier
|
||
{
|
||
/// <summary>UDT member or AOI local tag — non-directional, browsed at the parent's root.</summary>
|
||
Local,
|
||
|
||
/// <summary>AOI input parameter — written by the caller, read by the AOI body.</summary>
|
||
Input,
|
||
|
||
/// <summary>AOI output parameter — written by the AOI body, read by the caller.</summary>
|
||
Output,
|
||
|
||
/// <summary>AOI bidirectional parameter — passed by reference, both sides may read/write.</summary>
|
||
InOut,
|
||
}
|
||
|
||
/// <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>
|
||
/// One L5X-import entry. Mirrors <see cref="AbCipL5kImportOptions"/> field-for-field — the
|
||
/// two are kept as distinct types so configuration JSON makes the source format explicit
|
||
/// (an L5X file under an <c>L5kImports</c> entry would parse-fail confusingly otherwise).
|
||
/// </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>*.L5X</c> XML export. Loaded eagerly at InitializeAsync.</param>
|
||
/// <param name="InlineText">Pre-loaded L5X 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 AbCipL5xImportOptions(
|
||
string DeviceHostAddress,
|
||
string? FilePath = null,
|
||
string? InlineText = null,
|
||
string NamePrefix = "");
|
||
|
||
/// <summary>
|
||
/// One Kepware-format CSV import entry. Field shape mirrors <see cref="AbCipL5kImportOptions"/>
|
||
/// so configuration JSON stays consistent across the three import sources.
|
||
/// </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 Kepware-format <c>*.csv</c>. Loaded eagerly at InitializeAsync.</param>
|
||
/// <param name="InlineText">Pre-loaded CSV body — used by tests + Admin UI uploads.</param>
|
||
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions.</param>
|
||
public sealed record AbCipCsvImportOptions(
|
||
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; }
|
||
}
|