407 lines
18 KiB
Markdown
407 lines
18 KiB
Markdown
# AB CIP — Operability knobs
|
||
|
||
Phase 4 of the AB CIP driver plan introduces operator-tunable behaviour that
|
||
changes how the driver schedules per-tag traffic, deduplicates updates, and
|
||
surfaces health — knobs that an operator typically reaches for *after* the
|
||
address space is in place and the deployment is past the green-field phase.
|
||
The Phase 3 doc (`AbCip-Performance.md`) covers connection-shape and
|
||
read-strategy knobs; this doc is the home for the per-tag scheduling and
|
||
operability levers as PRs land.
|
||
|
||
PR abcip-4.1 ships the first knob: per-tag **Scan Rate** (Kepware-parity scan
|
||
classes).
|
||
|
||
## Per-tag scan rate
|
||
|
||
### What it is
|
||
|
||
A per-tag override of the OPC UA subscription's `publishingInterval`. The AB
|
||
CIP driver mirrors the Galaxy hierarchy as a single OPC UA address space, so
|
||
every tag served from one driver normally ticks at the publishing interval the
|
||
client requested when it created the Subscription. This knob lets specific
|
||
tags publish at a different cadence — fast HMI tags at 100 ms, batch /
|
||
historian tags at 1–10 s — without forcing the operator to split tags into
|
||
separate subscriptions or driver instances.
|
||
|
||
It is the Kepware "scan classes" model expressed per-tag. The same shape is
|
||
already shipped in the S7 driver (`S7TagDefinition.ScanGroup`) and the AB
|
||
Legacy / TwinCAT drivers; AB CIP adopts a leaner per-tag-only form because the
|
||
CIP single-connection model means the practical knob a deployment reaches for
|
||
is "this one tag, faster", not "every tag in this group".
|
||
|
||
### How it interacts with OPC UA publishingInterval
|
||
|
||
OPC UA semantics:
|
||
|
||
- The Subscription's `publishingInterval` is the *upper bound* on how often
|
||
the server publishes a NotificationMessage. Each MonitoredItem also has its
|
||
own `samplingInterval`; that's where this knob lands.
|
||
- A per-tag `samplingInterval` shorter than the Subscription's
|
||
`publishingInterval` means the server samples faster but only publishes at
|
||
the next Subscription tick — clients may receive multiple values for one
|
||
tag in a single Publish response.
|
||
- A per-tag `samplingInterval` longer than the Subscription's
|
||
`publishingInterval` is legal too — the server simply skips ticks for that
|
||
tag.
|
||
|
||
AB CIP-side: the driver's `SubscribeAsync` receives one `publishingInterval`
|
||
plus a list of tag references. With per-tag `ScanRateMs` it buckets the input
|
||
list by resolved interval and registers one `PollGroupEngine` subscription per
|
||
bucket. Each bucket runs an independent timer, so a 100 ms tag never waits
|
||
for a 1000 ms tag's `Task.Delay` to expire.
|
||
|
||
### Override knob
|
||
|
||
`AbCipTagDefinition.ScanRateMs` (`int?`, default `null`). `null` = use the
|
||
subscription's default `publishingInterval` (legacy behaviour). Bind via
|
||
driver config JSON:
|
||
|
||
```json
|
||
{
|
||
"Tags": [
|
||
{
|
||
"Name": "Motor1.Speed",
|
||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||
"TagPath": "Motor1.Speed",
|
||
"DataType": "DInt",
|
||
"ScanRateMs": 100
|
||
},
|
||
{
|
||
"Name": "Motor1.RunHours",
|
||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||
"TagPath": "Motor1.RunHours",
|
||
"DataType": "DInt",
|
||
"ScanRateMs": 5000
|
||
},
|
||
{
|
||
"Name": "Motor1.NamePlate",
|
||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||
"TagPath": "Motor1.NamePlate",
|
||
"DataType": "String"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Result: three buckets — 100 ms, 5000 ms, and the subscription default for
|
||
`NamePlate`. UDT members inherit the parent tag's `ScanRateMs` at fan-out
|
||
time, so a UDT declared at 100 ms publishes every member at 100 ms without
|
||
the operator having to repeat the override on each member.
|
||
|
||
### Floor and degenerate cases
|
||
|
||
- `PollGroupEngine` floors every bucket at **100 ms** — a `ScanRateMs: 25`
|
||
is clamped up. The floor matches the Modbus / S7 / TwinCAT floors and
|
||
protects the wire from sub-mailbox-scan polling.
|
||
- `ScanRateMs: 0` and negative values are treated as unset — the tag falls
|
||
back to the subscription default. Mis-typed config degrades, doesn't fault.
|
||
- A `ScanRateMs` equal to the subscription default collapses into the same
|
||
bucket as plain tags. The driver doesn't fragment poll loops when the
|
||
override is redundant.
|
||
- Tags whose names don't appear in the driver's tag map (typo / discovery
|
||
miss) fall through to the subscription default — same "config typo
|
||
degrades" stance as the rest of the driver.
|
||
|
||
### Wire impact
|
||
|
||
Per-bucket independent timers do **not** parallelise CIP traffic. The driver
|
||
serializes wire-side reads through its per-device libplctag handles, so a
|
||
fast bucket and a slow bucket trade off against each other on the wire — the
|
||
multi-rate split decouples *cadence* (the 100 ms bucket isn't queued behind
|
||
the 1000 ms bucket's `Task.Delay`), not *throughput*. The wire still moves
|
||
one CIP request at a time per device.
|
||
|
||
If you're reading a large tag set and the slow bucket starves the fast
|
||
bucket, the lever is `AbCipDeviceOptions.ConnectionSize` (Phase 3) — pack
|
||
more tags into one CIP RTT so the slow bucket finishes faster. Per-tag scan
|
||
rate is a *scheduling* knob, not a *throughput* knob.
|
||
|
||
### Comparison to Kepware scan classes
|
||
|
||
| Kepware concept | AB CIP equivalent |
|
||
|---|---|
|
||
| Scan class table (named groups → rate) | implicit: each distinct `ScanRateMs` value is its own bucket |
|
||
| Default scan class | OPC UA Subscription's `publishingInterval` |
|
||
| Per-tag scan class assignment | `AbCipTagDefinition.ScanRateMs` |
|
||
| "Scan mode: Respect client" | always — the OPC UA `publishingInterval` is the default |
|
||
| "Force write" / "Write through cache" | not exposed — AB CIP writes always go to the wire |
|
||
|
||
The leaner shape (per-tag rate, not named groups) keeps the JSON config flat
|
||
and reflects how operators tend to use the knob in practice — a handful of
|
||
"this specific tag needs to be fast" overrides on top of a sensible default,
|
||
rather than a separate tier of scan-class definitions.
|
||
|
||
### Verification
|
||
|
||
- **Unit**: `AbCipPerTagScanRateTests` (`tests/.../AbCip.Tests`). Asserts
|
||
bucketing math, default-rate collapse, UDT member inheritance, JSON DTO
|
||
round-trip, and end-to-end cadence against the in-process fake.
|
||
- **Integration**: `AbCipPerTagScanRateTests`
|
||
(`tests/.../AbCip.IntegrationTests`). Drives two tags at 100 ms / 1000 ms
|
||
against a live `ab_server` and asserts the bucket count + each tag receives
|
||
the initial-data push.
|
||
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *PerTagScanRate* assertion.
|
||
|
||
### Cross-references
|
||
|
||
- `docs/Driver.AbCip.Cli.md` — there is no CLI surface change for this knob;
|
||
scan rate is a config-time concern.
|
||
- `docs/drivers/AbCip-Performance.md` — Phase 3 throughput knobs that pair
|
||
with per-tag scan rate when a slow bucket starves a fast one.
|
||
- S7 driver `ScanGroup` model in `src/.../S7DriverOptions.cs` — the
|
||
named-group form of the same idea.
|
||
|
||
## Write deadband / write-on-change
|
||
|
||
PR abcip-4.2 ships the second operability knob: per-tag write coalescing,
|
||
the *write-side* companion to the read-side deadband already shipped at the
|
||
OPC UA monitored-item layer. The driver remembers the value last
|
||
successfully written for a tag and can suppress redundant or below-threshold
|
||
follow-up writes — they return `Good` to the OPC UA client without ever
|
||
hitting the wire.
|
||
|
||
### What it is
|
||
|
||
- **`AbCipTagDefinition.WriteDeadband`** (`double?`, default `null`) —
|
||
numeric absolute-difference threshold. When set, a write whose
|
||
`|new − last|` is below the deadband is suppressed.
|
||
- **`AbCipTagDefinition.WriteOnChange`** (`bool`, default `false`) —
|
||
equality gate. When set, a write whose value equals the last successfully
|
||
written value is suppressed.
|
||
|
||
Both knobs combine on the same tag. For numerics, the deadband path takes
|
||
priority; the equality fallback covers the cases the deadband doesn't (BOOL
|
||
setpoints, STRING constants, `WriteDeadband=0`, etc).
|
||
|
||
### Worked setpoint-jitter example
|
||
|
||
A motor speed setpoint published from an HMI tends to wobble by a few
|
||
ticks even when the operator hasn't touched it — UI rounding, Modbus
|
||
gateway re-encoding, RPN script noise. With `WriteDeadband: 0.5`:
|
||
|
||
```json
|
||
{
|
||
"Tags": [
|
||
{
|
||
"Name": "Motor1.Speed.SP",
|
||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||
"TagPath": "Motor1.Speed.SP",
|
||
"DataType": "Real",
|
||
"WriteDeadband": 0.5
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Sequence of writes from the HMI (one every 100 ms, no operator input):
|
||
|
||
| Time | Value | `\|new − last\|` | Wire? |
|
||
|---|---|---|---|
|
||
| 0 ms | 50.0 | n/a (first) | yes |
|
||
| 100 ms | 50.2 | 0.2 < 0.5 | suppressed |
|
||
| 200 ms | 50.3 | 0.3 < 0.5 | suppressed |
|
||
| 300 ms | 50.6 | 0.6 ≥ 0.5 | yes |
|
||
| 400 ms | 50.6 | 0.0 < 0.5 | suppressed |
|
||
| 500 ms | 51.5 | 0.9 ≥ 0.5 | yes |
|
||
|
||
Three writes hit the wire; three are suppressed. The OPC UA client sees
|
||
`Good` on every call. The PLC sees only the values that actually crossed
|
||
the deadband.
|
||
|
||
### Combining with WriteOnChange
|
||
|
||
A digital reset bit driven by a UI that pulses it at every cycle:
|
||
|
||
```json
|
||
{
|
||
"Name": "Conveyor.Reset",
|
||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||
"TagPath": "Conveyor.Reset",
|
||
"DataType": "Bool",
|
||
"WriteOnChange": true
|
||
}
|
||
```
|
||
|
||
Three consecutive `false → false → false` writes from the UI collapse to
|
||
one wire write (`false`, the first). When the operator clicks the reset
|
||
button (`true`), that write passes; subsequent `true → true` repeats
|
||
suppress until the UI clears it back to `false`.
|
||
|
||
Numeric tags can also opt into both: `WriteDeadband: 0.5` plus
|
||
`WriteOnChange: true` is well-defined — the deadband suppresses jitter, the
|
||
equality gate suppresses exact repeats (which the deadband path also catches
|
||
because `|0| < 0.5`, but having both set documents the operator's intent).
|
||
|
||
### Special cases
|
||
|
||
- **First write** always passes through. The coalescer has no prior value
|
||
to compare against, so the first write of any tag pays the full
|
||
round-trip and seeds the cache.
|
||
- **NaN / Infinity** bypass deadband suppression. IEEE-754 comparisons
|
||
against NaN are undefined and a stale `+Inf` shouldn't silently swallow
|
||
a real reset; the wire decides. `WriteOnChange` equality on NaN still
|
||
follows .NET semantics (`Equals(NaN, NaN) == true` for `double` boxed in
|
||
`object`), so a `WriteOnChange` tag stuck on NaN will suppress repeats
|
||
until something else writes a real value.
|
||
- **Failed writes** do *not* seed the cache. If the wire write fails, the
|
||
next attempt with the same value still hits the wire because the
|
||
coalescer never recorded a "last successful value" for it.
|
||
- **Reconnect drops the cache**. The driver's host-state probe transitions
|
||
`Stopped → Running` after a reconnect; both transitions reset the
|
||
per-device coalescer cache, so the first post-reconnect write of any
|
||
value pays the full round-trip. The PLC may have been restarted while
|
||
the driver was offline and our cached "we already wrote 42" is stale.
|
||
- **Two devices, same tag address**. The cache is keyed on
|
||
`(deviceHostAddress, tagAddress)` so two PLCs running the same Logix
|
||
program keep independent caches — writing 42 to A doesn't suppress
|
||
writing 42 to B.
|
||
- **Bit-in-DINT writes** consult the coalescer too, so a UI that pulses
|
||
`Flags.3` at every cycle benefits from the same `WriteOnChange`
|
||
suppression as a plain BOOL tag.
|
||
- **Plain back-compat tags** (no `WriteDeadband`, no `WriteOnChange`)
|
||
take a fast-path through the coalescer that increments only the
|
||
`WritesPassedThrough` counter — no dictionary lookup, no allocation. The
|
||
knobs are zero-overhead opt-in.
|
||
|
||
### Diagnostics
|
||
|
||
The driver surfaces two counters through `DriverHealth.Diagnostics` (the
|
||
same path the `driver-diagnostics` RPC + Admin UI render for Modbus / S7 /
|
||
OPC UA Client):
|
||
|
||
- `AbCip.WritesSuppressed` — total writes the coalescer skipped.
|
||
- `AbCip.WritesPassedThrough` — total writes that hit the wire after
|
||
consulting the coalescer.
|
||
|
||
Their ratio is the "wire savings" headline. A deployment with `0`
|
||
suppressions either has no tags opted in or has the deadband too tight /
|
||
the equality threshold too loose; revisit the per-tag config.
|
||
|
||
### Verification
|
||
|
||
- **Unit**: `AbCipWriteDeadbandTests` (`tests/.../AbCip.Tests`). Asserts
|
||
the deadband math, the equality fallback, the first-write pass-through,
|
||
reset-on-reconnect, two-device cache independence, suppressed-Good
|
||
status, NaN bypass, the back-compat fast path, and DTO round-trip.
|
||
- **Integration**: `AbCipWriteDeadbandTests`
|
||
(`tests/.../AbCip.IntegrationTests`). Drives a 5-write jittery sequence
|
||
with `WriteDeadband: 1.0` against a live `ab_server` and asserts the
|
||
driver's diagnostics counter matches the expected suppression count.
|
||
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *WriteCoalesce*
|
||
assertion.
|
||
|
||
### Cross-references
|
||
|
||
- `docs/drivers/AbServer-Test-Fixture.md` §7 — capability surfaces beyond
|
||
read; mentions write-coalesce coverage.
|
||
- Modbus driver — read-side deadband in `ModbusDriver` predates this
|
||
write-side equivalent; the config shape is intentionally similar.
|
||
- Kepware "Deadband (write)" knob — this is the AB CIP equivalent.
|
||
|
||
## System tags / `_System` folder
|
||
|
||
PR abcip-4.3 surfaces five read-only diagnostic variables under
|
||
`AbCip/<device>/_System/` so SCADA / Admin clients can pivot from "is the
|
||
wire up?" to "what's our scan rate / tag count?" without leaving the OPC UA
|
||
address space. The values come straight from the live
|
||
`IHostConnectivityProbe` + `DriverHealth` surfaces — reads bypass libplctag
|
||
and are served from the in-memory snapshot the probe loop / read loop
|
||
updates. PR abcip-4.4 added `_RefreshTagDb` as a sixth, writeable entry —
|
||
the Kepware-style refresh trigger.
|
||
|
||
### What it ships
|
||
|
||
| Variable | Type | Access | Source | Notes |
|
||
|---|---|---|---|---|
|
||
| `_ConnectionStatus` | String | ViewOnly | `HostState` | `Running` / `Stopped` / `Unknown` / `Faulted`. Mirrors what the connectivity probe sees. |
|
||
| `_ScanRate` | Float64 | ViewOnly | `AbCipProbeOptions.Interval` | Configured probe interval in milliseconds — compare against `_LastScanTimeMs` to spot wire stretch. |
|
||
| `_TagCount` | Int32 | ViewOnly | `_tagsByName` | Discovered tag count for this device, excluding `_System/*`. |
|
||
| `_DeviceError` | String | ViewOnly | `DriverHealth.LastError` | Most recent error message; empty when the device is healthy. |
|
||
| `_LastScanTimeMs` | Float64 | ViewOnly | `ReadAsync` wall-clock | Duration of the most-recent `ReadAsync` iteration on this device. |
|
||
| `_RefreshTagDb` | Boolean | **Operate** | n/a (write-only trigger) | PR abcip-4.4 — Kepware-style refresh trigger. Reads always return `false`. Writing any truthy value (`true`, non-zero number, `"true"` / `"1"` strings, case-insensitive) dispatches to `RebrowseAsync` against the device's cached `IAddressSpaceBuilder`. Falsy / unparseable writes are no-ops that report `Good` so a UI that resets the trigger flag doesn't see a phantom error. The `AbCip.RefreshTriggers` diagnostic counter increments per truthy write. |
|
||
|
||
### When the snapshot updates
|
||
|
||
- **Probe transitions** — every `Running ↔ Stopped` flip refreshes the
|
||
device's snapshot inline, so a client subscribed to
|
||
`_System/_ConnectionStatus` sees the new state on the next OPC UA
|
||
publish tick.
|
||
- **Read iterations** — `ReadAsync` recomputes `_LastScanTimeMs` per
|
||
device that owned at least one reference in the batch + writes a fresh
|
||
snapshot before returning.
|
||
- **Driver init** — every device gets a seeded snapshot
|
||
(`Unknown` / `0` / `""`) before the probe loop spins up so a read that
|
||
arrives before the first probe iteration returns a stable shape rather
|
||
than null.
|
||
|
||
### Browse + read example
|
||
|
||
```powershell
|
||
# Browse the synthetic folder
|
||
otopcua-client-cli browse -u opc.tcp://localhost:4840 \
|
||
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System"
|
||
|
||
# Read the connection status
|
||
otopcua-client-cli read -u opc.tcp://localhost:4840 \
|
||
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus"
|
||
```
|
||
|
||
The driver-side reference embeds the device host address (the
|
||
`_System/<device>/<name>` form) so the dispatcher can route by device
|
||
without an additional registry. PR abcip-4.4 turned `_RefreshTagDb` into
|
||
a writeable refresh trigger; the rest of the surface remains `ViewOnly`.
|
||
|
||
### Refreshing the tag DB via OPC UA write
|
||
|
||
PR abcip-4.4 wires `_RefreshTagDb` to the same `RebrowseAsync` entry point
|
||
the CLI's `rebrowse` command exercises (issue #233). Operators have two
|
||
roughly-equivalent ways to force a controller-side `@tags` re-walk after a
|
||
program download:
|
||
|
||
```powershell
|
||
# Path A — OPC UA write to the system tag (production / Admin UI path)
|
||
otopcua-client-cli write -u opc.tcp://localhost:4840 \
|
||
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_RefreshTagDb" \
|
||
-v true --type Boolean
|
||
|
||
# Path B — direct CLI rebrowse against a transient driver (admin / debug path)
|
||
otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0
|
||
```
|
||
|
||
Both paths drop the UDT template cache + re-run the enumerator walk. Path A
|
||
is the operator-facing surface (the same `IDriverControl.RebrowseAsync`
|
||
contract, just dispatched from the OPC UA write surface instead of an
|
||
in-process call). Path B spins up its own driver instance so it doesn't
|
||
share the live server's cache, which makes it useful for one-off
|
||
controller-side validation.
|
||
|
||
The `AbCip.RefreshTriggers` driver-diagnostics counter increments per
|
||
successful truthy write, so the Admin UI / driver-diagnostics RPC can show
|
||
a "Refreshes since boot" tile that pairs naturally with the existing
|
||
`WritesSuppressed` / `WritesPassedThrough` write-coalescer counters.
|
||
|
||
### Verification
|
||
|
||
- **Unit**: `AbCipSystemTagSourceTests`
|
||
(`tests/.../AbCip.Tests`) — covers snapshot round-trip, two-device
|
||
isolation, recognised-name lookup, default-shape on unseeded devices,
|
||
discovery emits the six canonical nodes, and `ReadAsync` dispatches
|
||
through the source instead of libplctag.
|
||
- **Unit**: `AbCipRefreshTagDbTests`
|
||
(`tests/.../AbCip.Tests`) — PR abcip-4.4 — covers discovery emits the
|
||
trigger as Operate, reads always return `false`, truthy/falsy/null write
|
||
semantics, the `AbCip.RefreshTriggers` counter, two-device counter
|
||
independence, defends-in-depth `BadNotWritable` for read-only system
|
||
variables, no-op-Good when no builder is cached yet, and mixed-batch
|
||
routing alongside ordinary tag writes.
|
||
- **Integration**: `AbCipSystemTagDiscoveryTests`
|
||
(`tests/.../AbCip.IntegrationTests`) — `[AbServerFact]` connects to a
|
||
real `ab_server`, browses `_System/`, reads each variable, asserts
|
||
every one returns Good with a non-null value.
|
||
- **Integration**: `AbCipRefreshTagDbTests`
|
||
(`tests/.../AbCip.IntegrationTests`) — PR abcip-4.4 — `[AbServerFact]`
|
||
drives a `_RefreshTagDb` write, asserts the template cache drops + the
|
||
per-device counter advances against a live `ab_server`.
|
||
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *SystemTagBrowse* +
|
||
*RefreshTagDbWrite* assertions.
|