namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
///
/// AB CIP / EtherNet-IP driver configuration, bound from the driver's DriverConfig
/// JSON at DriverHost.RegisterAsync. One instance supports N devices (PLCs) behind
/// the same driver; per-device routing is keyed on
/// via IPerCallHostResolver.
///
///
/// Per v2 plan decisions #11 (libplctag), #41 (AbCip vs AbLegacy split), #143–144 (per-call
/// host resolver + resilience keys), #144 (bulkhead keyed on (DriverInstanceId, HostName)).
///
public sealed class AbCipDriverOptions
{
///
/// PLCs this driver instance talks to. Each device contributes its own
/// string as the hostName key used by resilience pipelines and the Admin UI.
///
public IReadOnlyList Devices { get; init; } = [];
/// Pre-declared tag map across all devices — AB discovery lands in PR 5.
public IReadOnlyList Tags { get; init; } = [];
///
/// L5K (Studio 5000 controller export) imports merged into at
/// InitializeAsync. Each entry points at one L5K file + the device whose tags it
/// describes; the parser extracts TAG + DATATYPE blocks and produces
/// records (alias tags + ExternalAccess=None tags
/// skipped — see ). Pre-declared entries
/// win on Name conflicts so operators can override import results without
/// editing the L5K source.
///
public IReadOnlyList L5kImports { get; init; } = [];
///
/// L5X (Studio 5000 XML controller export) imports merged into at
/// InitializeAsync. Same shape and merge semantics as —
/// the entries differ only in source format. Pre-declared entries win
/// on Name conflicts; entries already produced by also win
/// so an L5X re-export of the same controller doesn't double-emit. See
/// for the format-specific mechanics.
///
public IReadOnlyList L5xImports { get; init; } = [];
///
/// Kepware-format CSV imports merged into at InitializeAsync.
/// Same merge semantics as / —
/// pre-declared entries win on Name 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
/// for the column layout + parse rules.
///
public IReadOnlyList CsvImports { get; init; } = [];
/// Per-device probe settings. Falls back to defaults when omitted.
public AbCipProbeOptions Probe { get; init; } = new();
///
/// 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.
///
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
///
/// When true, DiscoverAsync walks each device's Logix symbol table via
/// the @tags pseudo-tag + surfaces controller-resident globals under a
/// Discovered/ sub-folder. Pre-declared tags always emit regardless. Default
/// false to keep the strict-config path for deployments where only declared tags
/// should appear in the address space.
///
public bool EnableControllerBrowse { get; init; }
///
/// Task #177 — when true, declared ALMD tags are surfaced as alarm conditions
/// via ; the driver polls each subscribed
/// alarm's InFaulted + Severity members + fires OnAlarmEvent on
/// state transitions. Default false — 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.
///
public bool EnableAlarmProjection { get; init; }
///
/// 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.
///
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
///
/// PR abcip-3.1 — optional sink for non-fatal driver warnings (legacy-firmware
/// ConnectionSize mis-match, etc.). Production hosting wires this to Serilog;
/// unit tests pin a list-collecting lambda to assert which warnings fired. null
/// swallows warnings — convenient for back-compat deployments that don't care.
///
public Action? OnWarning { get; init; }
}
///
/// One PLC endpoint. must parse via
/// ; misconfigured devices fail driver
/// initialization rather than silently connecting to nothing.
///
/// Canonical ab://gateway[:port]/cip-path string.
/// Which per-family profile to apply. Determines ConnectionSize,
/// request-packing support, unconnected-only hint, and other quirks.
/// Optional display label for Admin UI. Falls back to .
/// PR abcip-3.1 — optional override for the family-default
/// . Threads through to
/// libplctag's connection_size attribute on the underlying tag handle so operators can
/// dial the CIP Forward Open buffer down for legacy firmware (v19-and-earlier ControlLogix
/// caps at 504) or up for high-throughput shops on FW20+. Validated against the Kepware
/// supported range [500..4002] at InitializeAsync; out-of-range values fault the
/// driver. null uses the family default — back-compat with deployments that haven't
/// touched the knob.
/// PR abcip-3.2 — controls whether the driver addresses tags by
/// ASCII symbolic path (the default), by CIP logical-segment instance ID, or asks the driver
/// to pick. Logical addressing skips per-poll ASCII parsing on every read and unlocks
/// symbol-table-cached scans for 500+-tag projects, but requires a one-time symbol-table
/// walk at first read + is unsupported on Micro800 / SLC500 / PLC5 (their CIP firmware does
/// not honour Symbol Object instance IDs). When the user picks
/// against an unsupported family the driver logs a warning + falls back to symbolic so
/// misconfiguration does not fault the driver. currently
/// resolves to symbolic — a future PR will plumb a real auto-detection heuristic; the docs
/// in docs/drivers/AbCip-Performance.md §"Addressing mode" call this out.
/// PR abcip-3.3 — picks how a multi-member UDT batch is read on this
/// device. issues one read per parent UDT and decodes
/// each subscribed member from the buffer in-memory (the historical behaviour that ships in
/// task #194 — best when a large fraction of a UDT's members are subscribed).
/// bundles per-member reads into one CIP
/// Multi-Service Packet — best for sparse UDT subscriptions where reading the whole UDT
/// buffer just to extract one or two fields wastes wire bandwidth.
/// (the default) lets the planner pick per-batch using
/// : if the subscribed-member fraction is below
/// the threshold MultiPacket wins, otherwise WholeUdt wins. Family compatibility — Micro800 /
/// SLC500 / PLC5 lack Multi-Service-Packet support per
/// ; user-forced
/// against those families logs a warning + falls
/// back to at device-init time. The libplctag .NET
/// wrapper (1.5.x) does not expose a public knob for explicit Multi-Service-Packet bundling,
/// so today's MultiPacket runtime issues one libplctag read per member; the planner's grouping
/// is still load-bearing because it gives the runtime the right plan to execute when an
/// upstream wrapper release exposes wire-level bundling.
/// PR abcip-3.3 — sparsity-threshold knob the planner
/// uses when is . The
/// planner divides subscribedMembers / totalMembers for each parent UDT in a batch;
/// a fraction strictly less than the threshold picks
/// , else .
/// Default 0.25 — picked because reading 1/4 of a UDT's members is the rough break-even
/// where the wire-cost of one whole-UDT read still beats N member reads on ControlLogix's
/// 4002-byte connection size; see docs/drivers/AbCip-Performance.md §"Read strategy".
/// Clamped to [0..1] at planner time; values outside the range silently saturate.
/// PR abcip-5.1 — optional canonical AB CIP gateway URI of the
/// partner chassis in a ControlLogix HSBY (Hot-Standby) pair. When set together with
/// .Enabled = true, the driver runs a second probe loop against
/// this partner address + uses the configured role tag (default
/// WallClockTime.SyncStatus, fall-back S:34 for PLC-5 / SLC-style fronts) to
/// determine which chassis is currently Active. PR abcip-5.1 only **discovers + reports**
/// the active chassis through driver diagnostics; PR abcip-5.2 is the follow-up that wires
/// the resolved active address into for live read /
/// write routing. null = no HSBY partner; the driver behaves exactly like every
/// pre-5.1 build.
/// PR abcip-5.1 — HSBY (Hot-Standby) sub-options. Defaults to
/// Enabled = false so back-compat deployments that don't set
/// see no behaviour change.
/// gates the second probe loop + role-tag read;
/// picks WallClockTime.SyncStatus (v20+ ControlLogix) vs S:34 (legacy
/// SLC500 / PLC-5 status byte fallback);
/// controls the role-tag poll cadence.
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
string? DeviceName = null,
int? ConnectionSize = null,
AddressingMode AddressingMode = AddressingMode.Auto,
ReadStrategy ReadStrategy = ReadStrategy.Auto,
double MultiPacketSparsityThreshold = 0.25,
string? PartnerHostAddress = null,
AbCipHsbyOptions? Hsby = null);
///
/// PR abcip-5.1 — HSBY (Hot-Standby) per-device options. Off by default. When
/// = true + the device sets
/// , the driver runs two probe loops
/// concurrently — primary + the partner —
/// reads the configured role tag on each, and reports which chassis is Active through
/// driver diagnostics (AbCip.HsbyActive, AbCip.HsbyPrimaryRole,
/// AbCip.HsbyPartnerRole). PR abcip-5.2 is the follow-up that wires the resolved
/// active address back into for live read / write
/// routing — 5.1 just gathers the role.
///
///
/// Role-tag detection matrix:
///
/// - v20 / v24 / v32+ ControlLogix HSBY — WallClockTime.SyncStatus
/// (DINT). Values: 0 = Standby (Synchronized but not Active),
/// 1 = Synchronized / Active (active chassis), 2 = Disqualified.
/// - PLC-5 / SLC500 fallback — S:34 Module Status word (PLC-5 has a
/// role bit in word 34 of the status file). Bit 0 = "this chassis is Active". This
/// is the legacy fallback for sites that haven't migrated to ControlLogix HSBY.
///
///
public sealed record AbCipHsbyOptions
{
/// Master switch. Default false — no role probing, no second probe loop.
public bool Enabled { get; init; }
///
/// Address of the role tag the driver reads on each probe tick. Default
/// WallClockTime.SyncStatus matches v20+ ControlLogix HSBY firmware. Legacy
/// PLC-5 / SLC500 fronts that expose a status-file role bit pass S:34 here +
/// the role prober applies the bit-mask interpretation automatically.
///
public string RoleTagAddress { get; init; } = "WallClockTime.SyncStatus";
///
/// Cadence the HSBY role probe ticks at. Default 2 seconds — tight enough to detect
/// a manual switch-over within one Admin-UI refresh, loose enough to leave headroom
/// for the regular probe loop on the same gateway.
///
public TimeSpan ProbeInterval { get; init; } = TimeSpan.FromSeconds(2);
}
///
/// PR abcip-3.3 — per-device strategy for reading multi-member UDT batches.
/// mirrors the task #194 behaviour: one libplctag read on the parent tag, each subscribed member
/// decoded from the buffer at its computed offset. bundles per-member
/// reads into one CIP Multi-Service Packet so sparse UDT subscriptions don't pay for the whole
/// UDT buffer. lets the planner pick per-batch using
/// .
///
///
/// Strategy resolution lives at two layers:
///
/// - Device init — user-forced against a family whose
/// profile sets
/// = false (Micro800, SLC500, PLC5) falls back to with a
/// warning. stays as-is (the planner re-evaluates per batch).
/// - Per-batch (Auto only) — for each parent UDT in the request set, the planner
/// computes subscribedMembers / totalMembers and routes the group through
/// when the fraction is below the threshold, else
/// .
///
/// libplctag .NET wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling,
/// so today's runtime issues one libplctag read per member when the planner picks MultiPacket —
/// the same wrapper limitation called out in PR abcip-3.1 (ConnectionSize) and PR abcip-3.2
/// (instance-ID addressing). The planner's grouping is still observable from tests + future-proofs
/// the driver for when an upstream wrapper release exposes wire-level bundling.
///
public enum ReadStrategy
{
/// Driver picks per-batch based on
/// . Default.
Auto = 0,
/// One read per parent UDT; members decoded from the buffer in-memory. Best when a
/// large fraction of the UDT's members are subscribed (dense reads).
WholeUdt = 1,
/// Bundle per-member reads into one CIP Multi-Service Packet. Best when only a few
/// members of a large UDT are subscribed (sparse reads). Unsupported on Micro800 / SLC500 /
/// PLC5; the driver warns + falls back to at device init.
MultiPacket = 2,
}
///
/// PR abcip-3.2 — how the AB CIP driver addresses tags on a given device.
/// is the historical default + matches every previous driver build: each read carries the tag
/// name as ASCII bytes + the controller parses the path on every request.
/// uses CIP logical-segment instance IDs (Symbol Object class 0x6B) — the controller looks the
/// tag up in its own symbol table once + the driver caches the resolved instance ID for
/// subsequent reads, eliminating the per-poll ASCII parse step. lets the
/// driver pick (today: always Symbolic; a future PR fingerprints the controller and switches
/// to Logical when supported).
///
///
/// Logical addressing requires a one-time symbol-table walk at the first read on the device
/// (the driver issues an @tags read via and stores
/// the name → instance-id map on the per-device DeviceState). It is unsupported on
/// Micro800 / SLC500 / PLC5 — see .
/// The libplctag .NET wrapper (1.5.x) does not expose a public knob for instance-ID
/// addressing, so the driver translates Logical → libplctag attribute via reflection on
/// NativeTagWrapper.SetAttributeString — same best-effort fallback pattern as
/// PR abcip-3.1's ConnectionSize plumbing.
///
public enum AddressingMode
{
/// Driver picks. Currently resolves to ; future PR may
/// auto-detect based on family + firmware + symbol-table size.
Auto = 0,
/// ASCII symbolic-path addressing — the libplctag default. Per-poll ASCII parse on
/// the controller; works on every CIP family.
Symbolic = 1,
/// CIP logical-segment / instance-ID addressing. Requires a one-time
/// symbol-table walk at first read; subsequent reads skip ASCII parsing on the
/// controller. Unsupported on Micro800 / SLC500 / PLC5.
Logical = 2,
}
///
/// One AB-backed OPC UA variable. Mirrors the ModbusTagDefinition shape.
///
/// Tag name; becomes the OPC UA browse name and full reference.
/// Which device () this tag lives on.
/// Logix symbolic path (controller or program scope).
/// Logix atomic type, or for UDT-typed tags.
/// When true and the tag's ExternalAccess permits writes, IWritable routes writes here.
/// Per plan decisions #44–#45, #143 — safe to replay on write timeout. Default false.
/// For -typed tags, the declared UDT
/// member layout. When supplied, discovery fans out the UDT into a folder + one Variable per
/// member (member TagPath = {tag.TagPath}.{member.Name}). When null on a Structure
/// tag, the driver treats it as a black-box and relies on downstream configuration to address
/// members individually via dotted syntax. Ignored for atomic types.
/// GuardLogix safety-partition tag hint. When true, the driver
/// forces SecurityClassification.ViewOnly on discovery regardless of
/// — 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.
/// Capacity of the DATA character array on a Logix STRING / STRINGnn
/// UDT — 82 for the stock STRING, 20/40/80/etc for user-defined STRING_20,
/// STRING_40, STRING_80 variants. Threads through libplctag's
/// str_max_capacity attribute so the wrapper allocates the correct backing buffer
/// and GetString / SetString truncate at the right boundary. null
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
/// non- types.
/// Tag description carried from the L5K/L5X export (or set explicitly
/// in pre-declared config). Surfaces as the OPC UA Description attribute on the
/// produced Variable node so SCADA / engineering clients see the comment from the source
/// project. null leaves Description unset, matching pre-2.3 behaviour.
/// PR abcip-4.1 — optional per-tag publish rate (in milliseconds) that
/// overrides the subscription's default publishingInterval for this tag. Mirrors
/// Kepware's "scan classes" + Siemens / Mitsubishi per-tag scan groups; the driver buckets
/// tags by resolved interval at time + runs one
/// loop per distinct interval so a fast HMI
/// tag is not delayed behind a slow batch tag's 10 s tick. null = use the subscription
/// default (legacy behaviour). The 100 ms floor enforced by the engine still applies — a
/// ScanRateMs < 100 is clamped up. UDT member tags inherit the parent tag's
/// ScanRateMs at member-fan-out time. See
/// docs/drivers/AbCip-Operability.md §"Per-tag scan rate".
/// PR abcip-4.2 — optional numeric write deadband. When set and both
/// the previous successfully-written value and the new write are numeric, the driver suppresses
/// the next write if |new - last| < WriteDeadband. Suppressed writes still return
/// Good so the OPC UA write semantics observed by clients are unchanged — the driver
/// simply skips the wire round-trip. Mirrors Kepware's "Deadband (write)" knob and is the
/// write-side companion to the read-side deadband already shipped at the OPC UA monitored-item
/// layer. NaN / Infinity values bypass suppression (let the wire decide). See
/// docs/drivers/AbCip-Operability.md §"Write deadband / write-on-change".
/// PR abcip-4.2 — optional write-on-change gate. When true and
/// the new write equals the previous successfully-written value, the driver suppresses the
/// write (returns Good without hitting the wire). Combines with
/// for numeric tags — the deadband test takes priority for numerics, equality is the fallback
/// for non-numeric types (BOOL setpoints, STRING constants, etc.). Default false —
/// legacy behaviour where every write goes to the wire.
public sealed record AbCipTagDefinition(
string Name,
string DeviceHostAddress,
string TagPath,
AbCipDataType DataType,
bool Writable = true,
bool WriteIdempotent = false,
IReadOnlyList? Members = null,
bool SafetyTag = false,
int? StringLength = null,
string? Description = null,
int? ScanRateMs = null,
double? WriteDeadband = null,
bool WriteOnChange = false);
///
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. Speed,
/// Status), DataType is the atomic Logix type, Writable/WriteIdempotent mirror
/// . Declaration-driven — the real CIP Template Object reader
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
///
///
/// 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.
/// PR abcip-2.6 — tags AOI parameters as Input / Output /
/// InOut / Local. Plain UDT members default to . Discovery
/// groups Input / Output / InOut members under sub-folders so an AOI-typed tag fans out as
/// Tag/Inputs/..., Tag/Outputs/..., Tag/InOut/... while Local stays at the
/// UDT root — matching how AOIs visually present in Studio 5000.
///
public sealed record AbCipStructureMember(
string Name,
AbCipDataType DataType,
bool Writable = true,
bool WriteIdempotent = false,
int? StringLength = null,
string? Description = null,
AoiQualifier AoiQualifier = AoiQualifier.Local);
///
/// PR abcip-2.6 — directional qualifier for AOI parameters. Surfaces the Studio 5000
/// Usage attribute (Input / Output / InOut) 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 , which keeps them at the
/// UDT root + indicates they are internal storage rather than a directional parameter.
///
public enum AoiQualifier
{
/// UDT member or AOI local tag — non-directional, browsed at the parent's root.
Local,
/// AOI input parameter — written by the caller, read by the AOI body.
Input,
/// AOI output parameter — written by the AOI body, read by the caller.
Output,
/// AOI bidirectional parameter — passed by reference, both sides may read/write.
InOut,
}
///
/// One L5K-import entry. Either or must be
/// set (FilePath wins when both supplied — useful for tests that pre-load fixtures into
/// options without touching disk).
///
/// Target device HostAddress tags from this file are bound to.
/// On-disk path to a *.L5K export. Loaded eagerly at InitializeAsync.
/// Pre-loaded L5K body — used by tests + Admin UI uploads.
/// Optional prefix prepended to imported tag names to avoid collisions
/// when ingesting multiple files into one driver instance.
public sealed record AbCipL5kImportOptions(
string DeviceHostAddress,
string? FilePath = null,
string? InlineText = null,
string NamePrefix = "");
///
/// One L5X-import entry. Mirrors field-for-field — the
/// two are kept as distinct types so configuration JSON makes the source format explicit
/// (an L5X file under an L5kImports entry would parse-fail confusingly otherwise).
///
/// Target device HostAddress tags from this file are bound to.
/// On-disk path to a *.L5X XML export. Loaded eagerly at InitializeAsync.
/// Pre-loaded L5X body — used by tests + Admin UI uploads.
/// Optional prefix prepended to imported tag names to avoid collisions
/// when ingesting multiple files into one driver instance.
public sealed record AbCipL5xImportOptions(
string DeviceHostAddress,
string? FilePath = null,
string? InlineText = null,
string NamePrefix = "");
///
/// One Kepware-format CSV import entry. Field shape mirrors
/// so configuration JSON stays consistent across the three import sources.
///
/// Target device HostAddress tags from this file are bound to.
/// On-disk path to a Kepware-format *.csv. Loaded eagerly at InitializeAsync.
/// Pre-loaded CSV body — used by tests + Admin UI uploads.
/// Optional prefix prepended to imported tag names to avoid collisions.
public sealed record AbCipCsvImportOptions(
string DeviceHostAddress,
string? FilePath = null,
string? InlineText = null,
string NamePrefix = "");
/// Which AB PLC family the device is — selects the profile applied to connection params.
public enum AbCipPlcFamily
{
ControlLogix,
CompactLogix,
Micro800,
GuardLogix,
}
///
/// Background connectivity-probe settings. Enabled by default; the probe reads a cheap tag
/// on the PLC at the configured interval to drive
/// state transitions + Admin UI health status.
///
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);
///
/// 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.
/// @raw_cpu_type on ControlLogix or a user-configured probe tag on Micro800).
///
public string? ProbeTagPath { get; init; }
}