# AB CIP — Performance knobs

Phase 3 of the AB CIP driver plan introduces a small set of operator-tunable
performance knobs that change how the driver talks to the controller without
altering the address space or per-tag semantics. They consolidate decisions
that Kepware exposes as a slider / advanced page so deployments running into
high-latency PLCs, narrow-CPU CompactLogix parts, or legacy ControlLogix
firmware have an explicit lever to pull.

This document is the home for those knobs as PRs land. PR abcip-3.1 ships the
first knob: per-device **CIP Connection Size**.

## Connection Size

### What it is

CIP Connection Size — the byte ceiling on a single Forward Open response
fragment, set during the EtherNet/IP Forward Open handshake. Larger
connection sizes pack more tags into a single CIP RTT (higher request-packing
density, fewer round-trips for the same scan list); smaller connection sizes
stay compatible with legacy or narrow-buffer firmware that rejects oversized
Forward Open requests.

### Family defaults

The driver picks a Connection Size from the per-family profile when the
device-level override is unset:

| Family | Default | Rationale |
|---|---:|---|
| `ControlLogix` | `4002` | Large Forward Open — FW20+ |
| `GuardLogix` | `4002` | Same wire protocol as ControlLogix |
| `CompactLogix` | `504` | 5069-L1/L2/L3 narrow-buffer parts (5370 family) |
| `Micro800` | `488` | Hard cap on Micro800 firmware |

These map straight to libplctag's `connection_size` attribute and match the
defaults Kepware uses out of the box for the same families.

### Override knob

`AbCipDeviceOptions.ConnectionSize` (`int?`, default `null`) overrides the
family default for one device. Bind it through driver config JSON:

```json
{
  "Devices": [
    {
      "HostAddress": "ab://10.0.0.5/1,0",
      "PlcFamily": "ControlLogix",
      "ConnectionSize": 504
    }
  ]
}
```

The override threads through every libplctag handle the driver creates for
that device — read tags, write tags, probe tags, UDT-template reads, the
`@tags` walker, and BOOL-in-DINT parent runtimes. There is no per-tag
override; one Connection Size applies to the whole controller (matches CIP
session semantics).

### Valid range

`[500..4002]` bytes. This matches the slider Kepware exposes for the same
family. Values outside the range fail driver `InitializeAsync` with an
`InvalidOperationException` — there's no silent clamp; misconfigured devices
fail loudly so operators see the problem at deploy time.

| Value | Behaviour |
|---|---|
| `null` | Use family default (4002 / 504 / 488) |
| `499` or below | Driver init fault — out-of-range |
| `500..4002` | Threaded through to libplctag |
| `4003` or above | Driver init fault — out-of-range |

### Legacy-firmware caveat

ControlLogix firmware **v19 and earlier** caps the CIP buffer at **504
bytes** — Connection Sizes above that cause the controller to reject the
Forward Open with CIP error 0x01/0x113. The 5069-L1/L2/L3 CompactLogix narrow
parts are subject to the same cap.

The driver emits a warning via `AbCipDriverOptions.OnWarning` when the
configured Connection Size **exceeds 511** *and* the device's family profile
default is also at-or-below the legacy cap (i.e. CompactLogix with default
504, or Micro800 with default 488). Production hosting should wire
`OnWarning` to the application logger; the unit tests (`AbCipConnectionSizeTests`)
collect into a list to assert which warnings fired.

The warning fires once per device at `InitializeAsync`. It does not block
initialisation — operators may need the override anyway when running newer
CompactLogix firmware that does support the larger Forward Open. The
controller will reject the connection at runtime if it can't honour the size,
and that surfaces through the standard `IHostConnectivityProbe` channel.

### Performance trade-off

| Larger Connection Size | Smaller Connection Size |
|---|---|
| More tags per CIP RTT — higher throughput | Compatible with legacy / narrow firmware |
| Bigger buffers held by libplctag native (RSS impact) | Lower memory footprint |
| Forward Open rejected on FW19- ControlLogix | Always works (assuming ≥500) |
| Required for high-density scan lists | Forces more round-trips — higher latency |

For most FW20+ ControlLogix shops, the default `4002` is correct and the
override is unnecessary. The override is mainly useful when:

1. **Migrating off Kepware** with a controller-specific slider value already
   tuned in production — set Connection Size to match.
2. **Mixed-firmware fleets** where some controllers are still on FW19 — set
   the legacy controllers explicitly to `504`.
3. **CompactLogix L1/L2/L3** running newer firmware that supports a larger
   Forward Open than the family-default 504 — bump the override up.
4. **Micro800** never goes above `488`; the override is for documentation /
   discoverability rather than capability change.

### libplctag wrapper limitation

The libplctag .NET wrapper (1.5.x) does not expose `connection_size` as a
public `Tag` property. The driver propagates the value via reflection on the
wrapper's internal `NativeTagWrapper.SetIntAttribute("connection_size", N)`
after `InitializeAsync` — equivalent to libplctag's
`plc_tag_set_int_attribute`. Because libplctag native parses
`connection_size` only at create time, this is **best-effort** until either:

- the libplctag .NET wrapper exposes `ConnectionSize` directly (planned in
  the upstream backlog), in which case the reflection no-ops cleanly, or
- libplctag native gains post-create hot-update for `connection_size`, in
  which case the call lands as intended.

In the meantime the value is correctly stored on `DeviceState.ConnectionSize`
+ surfaces in every `AbCipTagCreateParams` the driver builds, so the override
is observable end-to-end through the public driver surface and unit tests
even if the underlying wrapper isn't yet honouring it on the wire.

Operators who need *guaranteed* Connection Size enforcement against FW19
controllers today can pin `libplctag` to a wrapper version that exposes
`ConnectionSize` once one is available, or run a libplctag native build
patched for runtime updates. Both paths are tracked in the AB CIP plan.

### See also

- [`docs/Driver.AbCip.Cli.md`](../Driver.AbCip.Cli.md) — AB CIP CLI uses the
  family default ConnectionSize on each invocation; per-device overrides only
  apply through the driver's device-config JSON, not the CLI's command-line.
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §5 —
  ab_server simulator does not enforce the narrow CompactLogix cap, so
  Connection Size correctness is verified by unit tests + Emulate-rig live
  smokes only.
- [`PlcFamilies/AbCipPlcFamilyProfile.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
  per-family default values.
- [`AbCipConnectionSize`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs) —
  range bounds + legacy-firmware threshold constants.

## Addressing mode

### What it is

CIP exposes two equivalent ways to address a Logix tag on the wire:

1. **Symbolic** — the request carries the tag's ASCII name and the controller
   parses + resolves the path on every read. This is the libplctag default
   and what every previous driver build has used.
2. **Logical** — the request carries a CIP Symbol Object instance ID (a small
   integer assigned by the controller when the project was downloaded). The
   controller skips ASCII parsing entirely; the lookup is a single
   instance-table dereference.

Logical addressing is faster on the controller side and produces smaller
request frames. The trade-off is that the driver has to learn the
name → instance-id mapping once, by reading the `@tags` pseudo-tag at
startup, and the resolution step has to repeat after a controller program
download (instance IDs are re-assigned).

### Enum values

`AbCipDeviceOptions.AddressingMode` (`AddressingMode` enum, default
`Auto`) takes one of three values:

| Value | Behaviour |
|---|---|
| `Auto` | Driver picks. **Currently resolves to `Symbolic`** — a future PR will plumb a real auto-detection heuristic (firmware version + symbol-table size). |
| `Symbolic` | Force ASCII symbolic addressing on the wire. The historical default. |
| `Logical` | Use CIP logical-segment / instance-ID addressing. Triggers a one-time `@tags` walk at the first read; subsequent reads consult the cached map. |

`Auto` is documented as "Symbolic-for-now" so deployments setting `Auto`
explicitly today will silently flip to a real heuristic when one ships,
matching the spirit of the toggle. Operators who want to pin the wire
behaviour should set `Symbolic` or `Logical` directly.

### Family compatibility

Logical addressing depends on the controller implementing CIP Symbol Object
class 0x6B with stable instance IDs. Older AB families don't:

| Family | Logical addressing supported? | Why |
|---|---|---|
| `ControlLogix` | yes | Native class 0x6B support, FW10+ |
| `CompactLogix` | yes | Same wire protocol as ControlLogix |
| `GuardLogix` | yes | Same wire protocol; safety partition is tag-level, not addressing-level |
| `Micro800` | **no** | Firmware does not implement class 0x6B; instance-ID reads trip CIP "Path Segment Error" 0x04 |
| `SLC500` / `PLC5` | **no** | Pre-CIP families; PCCC bridging only — no Symbol Object at all |

When `AddressingMode = Logical` is set on an unsupported family, the driver
**falls back to Symbolic with a warning** (via `OnWarning`) instead of
faulting. This keeps mixed-firmware deployments working — operators can ship
a uniform "Logical" config across the fleet and let the driver downgrade
the families that can't honour it.

The driver-level decision is exposed via
`PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing` and resolved at
`AbCipDriver.InitializeAsync` time; the resolved mode is stored on
`DeviceState.AddressingMode` and threaded through every
`AbCipTagCreateParams` from then on.

### One-time symbol-table walk

The first read on a Logical-mode device triggers a one-time `@tags` walk via
`LibplctagTagEnumerator` (the same component used for opt-in controller
browse). The driver caches the resulting name → instance-id map on
`DeviceState.LogicalInstanceMap`; subsequent reads consult the cache without
issuing another walk. The walk is gated by a per-device `SemaphoreSlim` so
parallel first-reads serialise on a single dispatch.

The walk happens in `AbCipDriver.EnsureLogicalMappingsAsync` and runs only
for devices that have actually resolved to `Logical`. Symbolic-mode devices
skip the walk entirely. Walk failures are non-fatal: the
`LogicalWalkComplete` flag still flips to `true` so the driver does not
re-attempt indefinitely, and per-tag handles fall back to Symbolic addressing
on the wire (libplctag's default).

A controller program download invalidates the instance IDs. There is no
auto-invalidation today — operators trigger a fresh walk by either
restarting the driver or calling `RebrowseAsync` (the same surface that
clears the UDT template cache) with logic-mode plumbing extended in a
future PR. For now, restart-on-download is the recommended workflow.

### libplctag wrapper limitation

The libplctag .NET wrapper (1.5.x) does **not** expose a public knob for
instance-ID addressing. The driver translates Logical-mode params into
libplctag attributes via reflection on
`NativeTagWrapper.SetAttributeString("use_connected_msg", "1")` +
`SetAttributeString("cip_addr", "0x6B,N")` — same best-effort fallback
pattern as the Connection Size knob.

This means **Logical mode is observable end-to-end through the public
driver surface and unit tests today**, but the actual wire behaviour
remains Symbolic until either:

- the upstream libplctag .NET wrapper exposes the
  `UseConnectedMessaging` + `CipAddr` properties on `Tag` directly
  (planned in the upstream backlog), in which case the reflection no-ops
  cleanly, or
- libplctag native gains post-create hot-update for `cip_addr`, in which
  case the call lands as intended.

The driver-level bookkeeping (resolved mode, instance-id map, family
compatibility, fall-back warning) is fully wired so the upgrade path is
purely a wrapper-version bump.

### Performance trade-off

| Symbolic addressing | Logical addressing |
|---|---|
| Works everywhere | Requires Symbol Object class 0x6B |
| ASCII parse on every read (controller-side cost) | One-time walk; instance-id lookup thereafter |
| No first-read latency | First read on a device pays the `@tags` walk |
| Smaller code surface | Stale on program download — restart driver to re-walk |
| Best for small / sparse tag sets | Best for >500-tag scans with stable controller |

For scan lists in the **single-digit-tag** range, the per-poll ASCII parse
cost is invisible. For **medium** scan lists (~100 tags) the gain is real
but small — typically 5–10% per CIP RTT depending on tag-name length. The
break-even point is where the ASCII-parse overhead starts dominating,
roughly **>500 tags** in a tight scan loop, which is also where libplctag's
own request-packing benefits compound. Large MES / batch projects with
many UDT instances are the canonical case.

### Driver config JSON

Bind the toggle through the driver-config JSON:

```json
{
  "Devices": [
    {
      "HostAddress": "ab://10.0.0.5/1,0",
      "PlcFamily": "ControlLogix",
      "AddressingMode": "Logical"
    }
  ]
}
```

`"Auto"`, `"Symbolic"`, and `"Logical"` parse case-insensitively. Omitting
the field defaults to `"Auto"`.

### See also

- [`AbCipDriverOptions.AddressingMode`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
  enum definition + per-value docstrings.
- [`AbCipPlcFamilyProfile.SupportsLogicalAddressing`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
  family compatibility table source-of-truth.
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §
  "What it actually covers" — Logical-mode fixture coverage status.
- [`AbCipAddressingModeBenchTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs) —
  scaffold for the wall-clock comparison; gated on `[AbServerFact]`.

## Read strategy (PR abcip-3.3)

A per-device toggle that controls how multi-member UDT batches are read.
The default `Auto` value matches every previous build's behaviour for dense
reads but switches to per-member bundling when only a handful of members of
a large UDT are subscribed — the canonical "5 of 50" sparse-subscription
case where reading the whole UDT buffer just to extract a few fields wastes
wire bandwidth.

### Three modes

| Mode | When to use |
|---|---|
| `WholeUdt` | Most members of every subscribed UDT are read together. One libplctag read per parent UDT, members decoded in-memory at their byte offsets. The task #194 default. |
| `MultiPacket` | A few members of a large UDT are subscribed at a time. One read per subscribed member, bundled per parent into one CIP Multi-Service Packet. |
| `Auto` (default) | Planner picks per-batch from the subscribed-member fraction (see *Sparsity threshold*). |

### Sparsity threshold

Auto mode divides `subscribedMembers / totalMembers` for each parent UDT and
picks `MultiPacket` when the fraction is **strictly less than** the
threshold, else `WholeUdt`. Default threshold `0.25` — a 1/4 subscription is
the rough break-even where the wire-cost of one whole-UDT read still beats
N member reads on a ControlLogix 4002-byte connection-size buffer; above
1/4, the per-member overhead dominates.

Tune via `AbCipDeviceOptions.MultiPacketSparsityThreshold` (clamped to
`[0..1]`). Threshold `0.0` = "never MultiPacket"; `1.0` = "always MultiPacket
when any member is subscribed."

### Family compatibility

`MultiPacket` requires CIP service `0x0A` (Multi-Service Packet) on the
controller. Source of truth is
[`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs):

| Family | `SupportsRequestPacking` |
|---|---|
| ControlLogix | yes |
| CompactLogix | yes |
| GuardLogix | yes (wire identical to ControlLogix) |
| Micro800 | **no** |
| SLC500 / PLC5 (when those profiles ship) | **no** |

User-forced `MultiPacket` against a non-packing family logs a warning at
device init and falls back to `WholeUdt`. `Auto` against a non-packing
family stays `Auto` at the device level — the per-batch heuristic caps the
strategy to `WholeUdt` so the wire never sees a Multi-Service-Packet against
a controller that can't decode it.

### libplctag wrapper limitation

The libplctag .NET wrapper (1.5.x) does not expose the `0x0A` service as a
public knob — same wrapper-version constraint that gates PR abcip-3.1's
`connection_size` and PR abcip-3.2's instance-ID addressing. Today's
MultiPacket runtime therefore issues N libplctag reads sequentially when
the planner picks the strategy; the wire-level bundling lands cleanly when
an upstream wrapper release exposes the primitive.

The driver-level bookkeeping (resolved strategy, per-batch heuristic,
family-compat fall-back, per-device dispatch counters) is fully wired so
the upgrade path is a wrapper-version bump only — the planner already
produces the right plan, and `AbCipMultiPacketReadPlanner.Build` is
covered by unit tests that pin the plan shape rather than wire bytes.

### Driver config JSON

```json
{
  "Devices": [
    {
      "HostAddress": "ab://10.0.0.5/1,0",
      "PlcFamily": "ControlLogix",
      "ReadStrategy": "Auto",
      "MultiPacketSparsityThreshold": 0.25
    }
  ]
}
```

`"Auto"`, `"WholeUdt"`, and `"MultiPacket"` parse case-insensitively.
Omitting the field defaults to `"Auto"`. Omitting
`MultiPacketSparsityThreshold` defaults to `0.25`.

### See also

- [`AbCipDriverOptions.ReadStrategy`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
  enum definition + per-value docstrings.
- [`AbCipMultiPacketReadPlanner`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs) —
  plan shape + Auto-mode heuristic.
- [`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
  family compatibility table source-of-truth.
- [`AbCipReadStrategyTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadStrategyTests.cs) —
  device-init resolution, heuristic edges, dispatch counters, DTO round-trip.
- [`AbCipEmulateMultiPacketReadTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs) —
  golden-box-tier wire-level coverage scaffold; gated on `AB_SERVER_PROFILE=emulate`.
