Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs
T
Joseph Doherty c0379742bc 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.
2026-07-15 20:18:28 -04:00

221 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 the tag's device routing key (the RawPath's
/// device segment, delivered on <see cref="RawTagEntry.DeviceName"/>).
/// </summary>
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>
/// <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>
/// 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<RawTagEntry> RawTags { 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>
/// When <c>true</c>, declared ALMD tags are surfaced as alarm conditions
/// via <c>IAlarmSource</c>; 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 &amp; 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>
/// Opt-in for the declaration-only whole-UDT read fast path. When <c>false</c> (the
/// default) a batch of UDT members is always read per-member, because the byte offsets
/// computed by <see cref="AbCipUdtMemberLayout"/> assume the controller lays members
/// out in declaration order — and the Studio 5000 compiler does NOT guarantee that
/// (it reorders for largest-first packing, BOOL host bytes, nested-struct padding).
/// Decoding at declaration-order offsets against a reordered controller layout yields
/// silently-plausible wrong numbers. Set <c>true</c> only when the operator has
/// hand-verified that every configured UDT's member declaration order matches the
/// controller's compiled layout; in that case whole-UDT grouping collapses N member
/// reads into one. The richer CIP Template Object path remains the long-term fix.
/// </summary>
public bool EnableDeclarationOnlyUdtGrouping { get; init; }
/// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
/// </summary>
/// <remarks>Valid range: 160 seconds; the AdminUI clamps to 60s server-side.</remarks>
public int ProbeTimeoutSeconds { get; init; } = 5;
}
/// <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 the family
/// <c>AllowPacking</c> default, <c>ConnectionSize</c> default, unconnected-only hint, and
/// other quirks; per-device overrides via <see cref="AllowPacking"/> and
/// <see cref="ConnectionSize"/> take precedence when set.</param>
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
/// <param name="AllowPacking">Per-device override for CIP request-packing
/// (firmware 20+). <c>null</c> (the default) inherits the family profile's
/// <c>SupportsRequestPacking</c>; set explicitly to opt a single device in or out without
/// touching every other device on the same family.</param>
/// <param name="ConnectionSize">Per-device override for the Forward Open
/// ConnectionSize (Large Forward Open packet size in bytes). <c>null</c> inherits the family
/// profile's <c>DefaultConnectionSize</c>. Honoured by the driver layer; the underlying
/// libplctag 1.5.2 wrapper has no direct <c>ConnectionSize</c> property, so the value is
/// plumbed through <see cref="AbCipTagCreateParams"/> for forward-compat with future wrapper
/// versions or a custom tag-attribute path; current builds use the family profile default at
/// the wire layer regardless.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
string? DeviceName = null,
bool? AllowPacking = null,
int? ConnectionSize = 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 (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>
/// <param name="WriteIdempotent">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="ElementCount">Phase 4c — number of array elements for a 1-D array tag. Reads via
/// libplctag's <c>elem_count</c> into an element-typed CLR array when <paramref name="IsArray"/>
/// is set; <c>1</c> for a scalar. Ignored for <see cref="AbCipDataType.Structure"/>.</param>
/// <param name="IsArray">Review I-1 — the EXPLICIT array signal. <c>true</c> ⟺ the source TagConfig
/// had <c>isArray:true</c> (with <c>arrayLength &gt;= 1</c>); the tag discovers as an OPC UA
/// array node (<c>IsArray</c> + <c>ArrayDim</c>) and reads as a typed CLR array — even when
/// <paramref name="ElementCount"/> is 1 (a valid 1-element array). <c>ElementCount</c> alone
/// cannot carry this because a scalar and a 1-element array both have a count of 1.
/// Ignored for <see cref="AbCipDataType.Structure"/>.</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 ElementCount = 1,
bool IsArray = false);
/// <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>
/// <param name="Name">The member identifier on the PLC.</param>
/// <param name="DataType">The atomic Logix type of the member.</param>
/// <param name="Writable">Whether the member is writable.</param>
/// <param name="WriteIdempotent">Whether writes to the member are idempotent.</param>
/// <param name="ElementCount">Number of array elements for a 1-D array member; <c>1</c> for scalar.</param>
/// <param name="IsArray">Review I-1 — the EXPLICIT array signal for a member: <c>true</c> ⟺ the
/// member is a 1-D array (even of length 1). Discovers as an OPC UA array node when set.</param>
public sealed record AbCipStructureMember(
string Name,
AbCipDataType DataType,
bool Writable = true,
bool WriteIdempotent = false,
int ElementCount = 1,
bool IsArray = false);
/// <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 <c>IHostConnectivityProbe</c>
/// state transitions + Admin UI health status.
/// </summary>
public sealed class AbCipProbeOptions
{
/// <summary>Gets a value indicating whether the probe is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Gets the interval at which the probe reads the probe tag.</summary>
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Gets the timeout for each probe read operation.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Tag path used for the probe. When <see cref="Enabled"/> is <c>true</c> but this is
/// <c>null</c>/blank, the driver logs a warning and runs no probe loops;
/// <c>GetHostStatuses()</c> will then report every device as <c>Unknown</c>. A family-default
/// system-tag fallback (e.g. <c>@raw_cpu_type</c> on ControlLogix) is a deferred follow-up;
/// today an operator opting into the probe must supply a tag path explicitly.
/// </summary>
public string? ProbeTagPath { get; init; }
}