Compare commits

...

130 Commits

Author SHA1 Message Date
Joseph Doherty 3baa332d6b Merge: over-the-wire test of the shared client's alarm method calls against the in-process server
v2-ci / build (push) Successful in 4m36s
v2-ci / unit-tests (push) Failing after 19m50s
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:49:44 -04:00
Joseph Doherty d66269a99b test(client): over-the-wire alarm method calls against the in-process server
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:49:32 -04:00
Joseph Doherty d978573fb2 Merge #591: FOCAS duplicate-sibling tests wait for the first axis pass, not the bootstrap stamp
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:35:41 -04:00
Joseph Doherty 159f30dcd0 test(focas): wait for the first full poll, not the bootstrap stamp, before reading (#591)
LastFixedPollUtc is stamped by the fixed-tree bootstrap before any axis snapshot
is published, so under load Each_duplicate_axis_reads_its_own_position could read
inside that window and get BadNodeIdUnknown. The helper now waits for the last
axis slot plus the servo/spindle caches. 45/45 green under 14-core yes load.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:35:25 -04:00
Joseph Doherty 3803df0433 Merge #587/#588: one condition event per method-driven transition (native: Ack/AddComment only); model-change + audit events gated per session on i=2253 when ACL is on
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:23:20 -04:00
Joseph Doherty e2663643dd fix(alarms): native conditions support Acknowledge/AddComment only; report only real method changes (#587 review)
Review of 167a48bf (FIX-FIRST): making native Confirmed/Shelving method-owned produced over-the-wire
regressions — a native TimedShelve never expired, a native OneShotShelve was silently undone by
SetActiveState, an OPC UA ack left a native alarm Unconfirmed forever (ScadaBridge maps ConfirmedState),
and any local state was per-node, never reaching the redundant peer.

- Reverted: the driver projection owns native Confirmed/Shelving again.
- Native Confirm / OneShotShelve / TimedShelve / Unshelve return BadNotSupported with no event, as native
  Enable/Disable already did. Scripted alarms keep every method.
- A native Acknowledge re-asserts ConfirmedState=true and the projection's Retain rule (Active || !Acked)
  before its single report. The native condition starts Confirmed.
- The method path reports only when the state actually moved (before/after). A re-ack is silent and
  restores the Confirmed/Retain that the SDK flips, so a scripted node never drifts from its engine.
  AddComment always reports.
- Docs: native alarms support Acknowledge/AddComment only; the Secondary-node native-ack flap is documented.

Tests: refused native methods (5), native ack stays Confirmed through echo/clear/reactivate, scripted
TimedShelve expires with exactly one unshelve event, re-ack silent (native + scripted). Each fix falsified.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:19:38 -04:00
Joseph Doherty 85f003272c Merge: Client ack/confirm/enable/disable/shelve call Part 9 methods on the condition node (no .Condition/.ShelvingState string ids)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:57:23 -04:00
Joseph Doherty fa7efb3b62 fix(client-cli): call Part 9 ack/confirm/enable/shelve methods on the condition node itself
The client appended .Condition / .ShelvingState to the condition id; neither child exists on the
OtOpcUa server (conditions are their own SourceNode, ShelvingState is an SDK numeric ns0 node), so
every CLI ack/confirm/shelve/enable/disable returned BadNodeIdUnknown.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:57:06 -04:00
Joseph Doherty 167a48bffa fix(alarms,acl): one condition event per Part 9 method call; gate Server-object model-change/audit events (#587, #588)
#587 — a client Acknowledge/Confirm/AddComment/Shelve/Enable/Disable emitted no condition event: the SDK's
ReportStateChange is gated on AutoReportStateChanges (never set), and the engine re-projection found the node
already in the new state and was suppressed by the delta-gate. WireMethodTransitionReporting now wraps each
method's SDK handler and, on Good, reports exactly once via ReportConditionEvent, under Lock so the routed
re-projection cannot race in ahead of the SDK applying the state. Independent of the SDK's imprecise
AreEventsMonitored bookkeeping. The shelve methods' state is applied by the wrapper (the SDK never applies
it). Native conditions' Confirmed/Shelving are method-owned (NativeAlarmProjector's constants would re-report
every ack and undo every shelve). MaxTimeShelved set to int.MaxValue ms at materialise — Create() left it 0
and every TimedShelve failed BadShelvingTimeOutOfRange. Wrong "SDK auto-fires (E2)" comments fixed.

#588 — with ACL on, GeneralModelChange and AuditWriteUpdate events no longer go to Server.ReportEvent
(ungated to every i=2253 item): ReportToServerObject delivers them per item, a model change narrowed to the
Affected nodes the session may Browse (dropped when none), an audit write only with Read on its source plus
the SDK audit/encryption guard. Flag off: unchanged.

#586 review: materialise raises only the new condition's monitored unit (was a full O(N) reconcile per
condition ⇒ O(N²) deploys under Lock).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:54:08 -04:00
Joseph Doherty 15dd75379d Merge #590: ClusterRoleInfo records the RoleLeaderChanged address directly (lagging State snapshot could pin a role leader to null)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:26:16 -04:00
Joseph Doherty 129b84fd69 fix(cluster): record RoleLeaderChanged leader from the event, not the lagging state snapshot (#590)
ClusterRoleInfo resolved the leader address through Cluster.State.Members, which is
published asynchronously; a miss stored null permanently, so RoleLeader(role) stayed null
(flaky test timed out under load: 6/10 failing with CPU contention, 0/20 after). Only the
address is ever read, so store it directly. Adds a deterministic unit test (fails without
the fix) and InternalsVisibleTo for the Cluster.Tests project.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:25:57 -04:00
Joseph Doherty e8453eef6c Merge #589: S7 span-read tests wait out the clock tick before asserting LastSuccessfulRead advanced
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:22:25 -04:00
Joseph Doherty b36f5644d3 test(s7): wait out the clock tick before asserting LastSuccessfulRead advanced (#589)
The driver stamps DateTime.UtcNow at init and again per read pass; under CPU
contention both land in the same tick, so the strict greater-than assertions
flaked (reproduced 1/40 under load, sibling test A_cancelled_pass_still_flushes...
failed identically with equal timestamps). Test-only race; no production defect.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:22:11 -04:00
Joseph Doherty 390c8269a0 Merge #586: Server-object (i=2253) event items gated by the data-plane ACL; per-condition delivery with SDK audit guard + monitored bookkeeping
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:09:03 -04:00
Joseph Doherty fe92ff4c34 fix(acl): keep the SDK's audit guard and EventsMonitored bookkeeping on Server-object delivery (#586)
Review of 75e559f6:
- OnConditionReportEvent now mirrors MonitoredNode2's AuditEventState guard (only while Server.Auditing,
  over SignAndEncrypt / https-uabinary). Conditions report their Part 9 method audit events through
  themselves, so the first cut queued AuditConditionAcknowledge (i=8944) etc. to every i=2253 item with
  auditing off -- the removed folder->Server forward used to drop them.
- ReconcileAllEventsMonitoring keeps one AreEventsMonitored unit on every materialised condition while any
  i=2253 item exists (called from SubscribeToAllEvents, materialise, removal, rebuild), so
  ConditionState.EventsMonitored() is true on a server whose only subscribers are Server-object items.
  A protected SubscribeToEvents override no-ops all-events items, which stops the SDK's AddRootNotifier
  (unsubscribe: true bug) taking that unit back from conditions rebuilt under a live item.

Flag-off over-the-wire coverage: Acknowledge/AddComment with and without a folder co-subscriber (no audit
event, transitions once each), EventsMonitored on/after-rebuild/off, rebuild survival with one copy per
event, native + scripted conditions once each, refresh once per condition with RefreshStart/End; plus an
ACL-on native-vs-scripted notifier-folder decision. Falsified: removing the audit guard reds both audit
cases; removing the reconcile reds the monitored test.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:05:12 -04:00
Joseph Doherty 277cd6ee3e Merge: deterministic AbCip transport-switch probe tests (libplctag timeout races the probe cancel under load)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:51:34 -04:00
Joseph Doherty fe2b0614c8 test(abcip): deterministic transport-switch probe tests
The libplctag-path positive control asserted 'timed out', but libplctag's internal
timeout equals the probe timeout and races the probe's own cancellation; under CPU
load it surfaces as 'ErrorTimeout' instead. Accept either libplctag stall outcome
(native never produces either: it fails at once with 'Connection lost').

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:51:11 -04:00
Joseph Doherty 75e559f6d8 fix(acl): filter Server-object (i=2253) event subscriptions per event through the data-plane ACL (#586)
Server-object (all-events) items bypassed the ACL entirely: the SDK attached them to every root
notifier's fan-out via SubscribeToAllEvents, and a root notifier with no direct subscriber forwarded
to the core Server object, which this node manager never sees.

- SubscribeToAllEvents override records the item and attaches it to nothing.
- Each condition's own OnReportEvent (fires once per event) queues the event to each recorded item
  whose session holds Subscribe on the condition (RawPath) or any notifier folder it reports through
  -- the per-folder path's question; otherwise mirrors MonitoredNode2.OnReportEvent.
- The root-notifier forward to the core Server object (OnReportEvent override) is dropped.
- ConditionRefresh on an i=2253 item is filtered the same way (once per condition).
- Revalidation is implicit: every event re-asks the authorizer (per-generation cache, key expiry and
  revocation re-checked per call). Inert with Security:Acl:Enabled=false.

Over-the-wire tests: no-grant vs granted (positive control), created-before-deploy, revoke/restore by
deployment, key expiry with no tick, refresh, and flag-off. Falsified: disabling the filter, or
restoring the forward, turns the enforcement tests red.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:37:47 -04:00
Joseph Doherty e5fe2e5547 Merge #566/#581/#583: per-pool transport diagnostics + per-device open-attempt limit, CIP-level close recycle, positive-control absence tests
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:31:17 -04:00
Joseph Doherty fe94a3bdb8 test(ab): replace fixed-delay absence assertions with positive controls / processed counts (#583)
Ab.Transport.Tests: predecessor-wait counter instead of 300 ms (DeviceSessionPoolTests x2), throttle
queue/interval-park hooks instead of 50/100 ms (ForwardOpenThrottleTests x2), UnobservedTaskProbe with a
must-report control fault instead of N GC cycles (EncapsulationClientTests), re-armed keep-alive timer as
the control for the never-opened keep-alive test.
AbCip.Tests: still-polling sibling / control driver read counts instead of 300-500 ms (subscriptions x4,
alarm projection x5), structural ProbeTask==null + enabled-probe control (host probe x2), predecessor
wait (NativeAbCipLeaseReleaseTests), processed waits in the shutdown/logging tests.
AbLegacy.Tests: sibling-subscription control (Unsubscribe_halts_polling), ProbeCts==null + control.
AbCip.IntegrationTests differential safety: a post-phase control open through the same throttle instead
of 1.5-2.5 s sleeps (via a native tag init, keeping the harness read-only guard green).
Fake tag ReadCount made interlocked; CapturingLogger entries snapshotted under a lock.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:28:33 -04:00
Joseph Doherty 3edff3304b fix(ab-transport): per-pool diagnostics + per-device open-attempt limit, CIP-level close recycle, CipConnection/pool test gaps (#566, #581)
- Process-wide CipConnection statics (ForwardOpenCount, Forward Close counts, RequestsSent, exchange
  stats, OpenAttempts, OpenAttemptBudget) move to an instance ConnectionDiagnostics owned by each
  DeviceSessionPool (and by a stand-alone CipConnection). The open-attempt budget becomes a disposable
  per-DeviceKey limit (LimitOpenAttempts); the differential harness scopes one to the press under test.
- LibplctagOrphanRelease.ForceRootingForTest is gone: ReleaseIfOrphaned(tag, canReadHandles) overload.
- A connected reply with general 0x01 / extended 0x0107 or 0x0203 (target no longer holds the class-3
  connection, TCP still up) retires the session at once; the next send reopens through the throttle.
- New tests: dispose during RegisterSession, abandoned-open observer (falsified), scheduler-dispose-throws
  guard (DeviceSessionPool.CloseAsync), release-in-background with a shared open in flight, per-device
  limit scoping, CIP-close recycle (request + keep-alive + negative control).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:21:02 -04:00
Joseph Doherty 35152fcaf7 Merge #562/#563: event/alarm items revalidated by both ACL triggers (reversible stand-down); key usability on WriteOperate/AlarmAck gates regardless of Security:Acl:Enabled
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:11:22 -04:00
Joseph Doherty 2b945672e9 fix(acl): revalidate event/alarm subscriptions on both triggers; key-usability check on role gates (#562, #563)
#562: direct event (alarm) monitored items are now tracked in a second map
(_eventItems, fed by the SubscribeToEvents override) and re-authorized by
RevalidateMonitoredItems on both triggers (DataPlaneAuthorityState.Changed and
AclRevalidationTimer). A denied item is detached via the SDK's own protected
SubscribeToEvents(unsubscribe: true) - never MonitoringMode.Disabled - and
re-attached on restore; a client delete of a stood-down item is absorbed.
Over-the-wire tests: revoke -> stop (positive control on a still-granted
notifier) -> restore -> resume; clock-driven expiry via the real timer with a
control session; delete-while-stood-down is not resurrected; unexpired
negative control. Server-object (all-events) subscriptions are documented as
outside the ACL (observed over the wire).

#563: DataPlaneAuthorizer.AuthorizeKeyUsable runs the FindUsableKey probe on
the WriteOperate and AlarmAck role gates regardless of Security:Acl:Enabled,
so an expired or deployment-disabled key loses its activation-time roles on
its open session. Anonymous (no key id) is unaffected; reads/subscriptions
stay ungated with the flag off. No new flag.

Docs: docs/security.md and the CLAUDE.md API-key/ACL section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:10:35 -04:00
Joseph Doherty 501f6ea4e5 Merge #570/#579: native AbCip per-tag config errors, no mistype churn, STRING 82, LWORD type checks; non-blocking lease release (AbCip + AbLegacy)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs
2026-10-07 23:06:52 -04:00
Joseph Doherty 007174d28a Merge #573: AbLegacy fake tag factory is thread-safe (probe raced the test's tag create)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:06:07 -04:00
Joseph Doherty 4e6eed7454 fix(abcip): native runtime STRING capacity, mistype churn, malformed-name config error, LWORD type check; non-blocking lease release (#570, #579)
#570
- STRING: the built-in 88-byte Logix STRING holds 82 characters (LEN + DATA[82] + 2 pad), not 84; the
  write still sends the whole 88-byte structure and a read never decodes the pad as characters.
- Mistype churn: a type refusal (CIP 0xFF/0x2107) on the existence read no longer throws from
  InitializeAsync, so the runtime is not disposed/re-created (and re-Forward-Opened) every poll; the driver
  also keeps a runtime that throws TypeMismatch from init and records the device as reachable.
- Malformed tag name: a TagPath that does not parse, or a name the CIP symbolic encoder refuses (now also
  non-ASCII, empty member, >255-char member, unterminated subscript), is AbCipTagConfigurationException ->
  per-tag BadConfigurationError (named const in AbCipStatusMapper), warned once, never feeding the device
  backoff or health; siblings keep reading. A malformed probe tag no longer feeds the backoff either.
- Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are type-checked as integers of their width: REAL/LREAL or a
  narrower integer over LWORD is TypeMismatch, not decoded garbage.

#579
- ConnectionLease.ReleaseInBackground: one non-blocking release shape for both native AB runtimes. The
  AbCip runtime's Dispose no longer waits up to 2 s under the per-tag lock; the pool still registers the
  close synchronously so a re-create cannot overlap it. Offline soak tests (25 cycles, AbCip + AbLegacy)
  prove no leaked connection; FakeEipServer gains OpenConnections.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:05:49 -04:00
Joseph Doherty 8c685bcbba test(ablegacy): make FakeAbLegacyTagFactory.Tags thread-safe — probe loop raced the test thread (#573)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:05:34 -04:00
Joseph Doherty 1ab961c226 docs(abcip): browsePageDelayMs survives an AdminUI save now that the form preserves unknown keys (#576)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:04:01 -04:00
Joseph Doherty 7105ef3eb5 Merge #574/#576/#577/#584: AB forms preserve unknown keys + write factory ms keys, /raw device-name + prerender fixes, CLI read prints Bad status, docs dot-form
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:03:48 -04:00
Joseph Doherty bafbcbee7e fix(adminui): AB forms read/write the factory-bound *Ms duration keys, migrate legacy TimeSpan strings (#576)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:03:38 -04:00
Joseph Doherty 4ba6cbab3a Merge #572/#575: native browse paging/lease/pacing, differential equal-refusal + budget isolation
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	docs/drivers/AbCip.md
2026-10-07 23:01:26 -04:00
Joseph Doherty be421a4cb0 fix(abcip): native browse paging/pacing/template lease (#572); differential equal-refusal + budget isolation (#575)
#572: listing continues at max(instance in page)+1 (no ascending-order assumption, no-progress guard kept);
browsePageDelayMs (default 5 ms) paces pages; DiscoverAsync reads templates through one batch reader
(IAbCipTemplateReaderFactory.CreateBatch) holding one lease.
#575: differential harness reports a same-status template refusal as an equal refusal; offline test pins the
open-attempt budget without relying on the abort ending the phase.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:42 -04:00
Joseph Doherty 29cd25dec8 Merge #568/#569/#580/#585: shared AB status map + orphan release, confinement guard tokeniser, adapter + DefaultTransport tests
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:30 -04:00
Joseph Doherty 341417f193 fix(ab): share status map + libplctag orphan release, harden confinement guard, pin width-mismatch + DefaultTransport wiring (#568 #569 #580 #585)
- Ab.Transport: AbOpcUaStatusMap is the single AbStatus->OPC UA table; AbCip/AbLegacy mappers delegate.
- Remove dead MapCipGeneralStatus / MapPcccStatus / AbLegacy MapException (+ tests).
- LibplctagOrphanRelease is one source file (Ab.LibplctagShared) compiled into both drivers.
- LibplctagConfinementGuardTests: tokenising comment/literal stripper, block comments, csproj PackageReference guard.
- LibplctagStatusTranslationTests: real LibPlcTagException round trip, WrapSync/Wrap OCE pass-through, adapter routes decode via WrapSync.
- AbCipTypeMismatchTests: libplctag width-mismatch path (BadTypeMismatch, handle kept, no Degraded, warn once).
- Host: AddAbCipDriverDefaults extension + Program.cs source pin for Drivers:AbCip:DefaultTransport.
- docs/drivers/AbCip.md: layout rows + benign Degraded-after-outage note.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:06 -04:00
Joseph Doherty 3d081ad29c fix(adminui,cli): /raw device-name placeholder + no-prerender tree, CLI read prints Bad status (#577)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:56:47 -04:00
Joseph Doherty 13a6727033 Merge #564/#565: CIP codec hardening, EncapsulationClient gate/timeout bounds
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:55:02 -04:00
Joseph Doherty 7892eb13a8 fix(abcip): harden Ab.Transport CIP codec and EncapsulationClient (#564, #565)
CIP codec (#564): PatchU16 bounds guard; CipReply content equality; CipPath
port/length/ASCII/odd/oversize validation with ArgumentException for malformed
tag names and routes; CipPath.MessageRouter/ConnectionManager are now
ReadOnlySpan<byte>; refused Forward Open surfaces ForwardOpenRefusedException
with structured general/extended status; test CaptureFile uses BinaryPrimitives.

EncapsulationClient (#565): frame write bounded by the exchange timeout, which
faults (never cancels) the connection; finite positive timeouts enforced at
every entry point; gate wait bounded by the timeout; deterministic reorder of
the 300 ms test; new dispose-during-drain, gate-wait and RegisterSession
zero-context tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:54:32 -04:00
Joseph Doherty bd2bebeda6 Merge #567/#582: RequestScheduler reply-size budget, private lock, fragment struct-handle check
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:53:42 -04:00
Joseph Doherty 9641cefe2e fix(abcip): budget expected reply size in RequestScheduler.TakeBatch (#582); private lock + struct-handle check across read fragments (#567)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:53:10 -04:00
Joseph Doherty e588443ced fix(abcip,adminui): ParseEnum device wording + libplctag probe assertion (#574); AB forms preserve unknown keys + MaxConcurrentReads (#576); probe off for new AbCip driver (#577); docs T4:0.DN (#584)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:53:04 -04:00
Joseph Doherty 04b6563286 Merge native .NET Allen-Bradley transport (AbCip native default; AbLegacy native opt-in; real-press gated)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:40:06 -04:00
Joseph Doherty b25ee10a14 test(abcip): real-press pass 3 with packet capture — 17/17, zero write services, 4 sequential sessions (Z-35162)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:39:33 -04:00
Joseph Doherty 8e2dbc6721 merge: master (TwinCAT native array subscriptions) into feat/native-ab-transport
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:51:12 -04:00
Joseph Doherty f73c2db599 docs(ab): final review r2 m4/m5 — AbCipTransport doc names Native as default; AbLegacy array-write deferral scoped to libplctag
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:50:57 -04:00
Joseph Doherty ee3ffa90ef docs: final review — CLAUDE.md AB transport section (I1/I2/I3/I4)
AbCip: node-level Drivers:AbCip:DefaultTransport and the working pin
recipe. AbLegacy: default libplctag, Native opt-in, exact wire evidence.
Type-mismatch and Unsupported are refusals that never evict or degrade.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:41:18 -04:00
Joseph Doherty cc3bea8370 fix(ab): final review M2 — a retyped libplctag handle field degrades to keep-alive
HandleOf cast the reflected nativeTagHandle with (int) and was called
inside the runtimes' catch blocks, so a libplctag bump that retyped the
field would replace every create failure with an InvalidCastException.
The field is now accepted only if it is still an int (otherwise
CanReadHandles is false and orphans are rooted), and HandleOf never
throws. Both projects pin the field type explicitly.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:41:03 -04:00
Joseph Doherty 352ea449e9 fix(abcip): final review I4 — a type mismatch is a refusal, not a comms fault
A native TypeMismatch (read status, write gate, or CIP 0xFF/0x2107) used
to evict the runtime and degrade health on every poll; for a sole lease
holder that meant Forward Close + Forward Open per poll, and an all-
mismatched driver tripped the comms-loss watchdog. It now reports
BadTypeMismatch, keeps the handle, leaves health alone and warns once per
tag per instance — reads, whole-UDT reads and writes, plus the libplctag
width check. Tests (red against the pre-fix driver): single create over N
polls, unchanged health, one warning, and one Forward Open / zero Forward
Close over the real native runtime against FakeCipTarget. AbLegacy has no
TypeMismatch path (its native runtime has no type check).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:40:04 -04:00
Joseph Doherty 2cea1a0687 fix(ab-transport): final review I2 — wire DeviceSessionPool.Shared to the host logger
ConfigureSharedLogger had no production caller, so the pool's only
diagnostics (first-wins options conflicts, scheduler/close failures)
went to NullLogger. DriverFactoryBootstrap now configures it from the
driver registry factory and from the AbCip/AbLegacy probe factories
(admin-only nodes reach the pool only through Test Connect). Wiring tests
reset the pool to a null logger first, so removing the call goes red
(verified).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:36:37 -04:00
Joseph Doherty b75e013d0a feat(abcip): final review I1 — node-level Drivers:AbCip:DefaultTransport
The old AdminUI cannot author the per-driver transport key (#576), so the
'pin before upgrading' recipe had no working path. A node-level appsetting
(env Drivers__AbCip__DefaultTransport) now supplies the default for AbCip
drivers with no key: explicit key > node default > compiled Native. Bound
and validated unconditionally (bad value fails host start); the driver
factory and the Test Connect probe both read it so they agree. AbCip.md
upgrade recipe rewritten around the key; Configuration.md documents it.
Also documents first-wins for AllowPacking/probe and host-alias throttles
(final review M6) and moves the status-bit note out of AbCip.md (M5).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:34:48 -04:00
Joseph Doherty b66972b6f5 fix(ablegacy): final review I3 — default reverts to libplctag; Native is opt-in
On a wire the native PCCC path is proven only for N-file reads/writes and
one F8 read (ab_server). An absent transport key now resolves to Libplctag
for every family; explicit Native still wins and still warns once per
instance on PLC-5/LogixPccc. AdminUI dropdown, CLI help, evidence claims
(PcccFileType, AbLegacy.md wire-evidence table, release note) corrected:
S:0 (default probe) and B/T/C/R/S/L/ST/A/O/I are unverified natively.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:34:04 -04:00
Joseph Doherty a9fc933e3b docs(plans): native AB transport tasks.json status sync
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:14:44 -04:00
Joseph Doherty 39ae286cd7 fix(ab): a refused operation is BadNotSupported, not a comms fault (Task 22 review r1; closes #571)
Native is the default, so a locally refused write must not look like a comms fault.

- AbLegacy: an AbTransportException with AbStatus.Unsupported (status-bit sub-element writes, PLC-5
  non-N reads, all PLC-5 writes; libplctag's ErrorUnsupported maps here too) now returns
  BadNotSupported on both the read and the write path (incl. the bit-RMW path). The tag handle is
  not evicted and driver health is not touched. A native runtime whose init read is refused locally
  is CACHED rather than disposed, so a poll of a refused tag does not re-create (and re-lease the
  pooled connection) every cycle.
- AbCip: the same mapping on the write path and both read paths (single + whole-UDT).
- Uses the existing named BadNotSupported constants (StatusCodeParityTests guard).
- Tests (falsified by disabling the mapping, and separately the keep-on-refusal): status-bit
  writes and PLC-5 read/write through the driver over the native runtime — BadNotSupported, zero
  wire requests, one create, health unchanged; AbCip refused read/write via a refusing fake plus a
  control that a non-refusal failure still evicts and degrades.
- Docs/CLAUDE.md: BadNotSupported; #571 known-gap wording removed.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:12:08 -04:00
Joseph Doherty 1806af54f0 fix(ab): Task 22 review r1 — release-note timing and AbLegacy regressions, probe E2E on both transports
- I1: the upgrade restart itself moves every no-key AbCip/AbLegacy driver to Native (every process
  start constructs drivers, incl. boot-from-cache). Docs + CLAUDE.md now say so and tell operators
  to set "transport": "Libplctag" BEFORE upgrading (older builds ignore the unknown key).
- I2: AbLegacy release note leads with the status-bit write refusal (T4:0.DN, C5:0.CU, R6:0.EN, ...)
  on defaulted SLC/MicroLogix drivers and the fallback. The real client status is
  BadCommunicationError, not BadNotSupported (the driver's write catch-all maps the runtime's
  AbTransportException like any transport failure, as #571 does for PLC-5), with handle eviction
  and Degraded health; pinned by a driver-level test (no 0xAA write on the wire, with a positive
  control). ST string byte order stated as unverified. Also corrects the docs' "T4:0/DN" examples:
  the driver's address grammar is "T4:0.DN" ("/DN" is a malformed address).
- I3: DriverProbeHandshakeE2eTests' two AbCip probe tests are a [Theory] over Native and
  Libplctag. Run against ab_server controllogix: all 4 rows pass with identical exact messages.
- M1: documented (docs + resolver XML doc): the AdminUI merges only the probed device, so a
  mixed-family defaulted AbLegacy driver can probe Native while running Libplctag.
- M2: factory ParseEnum rejects undefined integer strings ("7") via Enum.IsDefined (both drivers).
- M3: AbCip driver-level bad value reads "AB CIP config in '<id>' has unknown Transport ..."; the
  named constant is gone (tagName: null now means driver-level).
- M4: the explicit-Native limited-family Warning is once per instance, not per (re)init.
- Nit: EffectiveTransport docs note that injected factories bypass it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:06:40 -04:00
Joseph Doherty ed0881d672 feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback
Task 22 (spec section 8 P5), with the controller rulings that exclude PLC-5 and LogixPccc.

- Transport is nullable on both option records; null (key absent) means "use the default" and is
  never serialized. Factory DTO parse: absent -> null; a bad value still fails via ParseEnum.
- One resolver per driver (AbCipTransportResolver / AbLegacyTransportResolver), used by the driver
  constructor, the Test Connect probe and (through the driver) the CLI.
  AbCip: null -> Native. AbLegacy: null -> Native unless any device is Plc5 or LogixPccc, then the
  whole driver resolves to Libplctag. Explicit values always win; explicit Native with a
  Plc5/LogixPccc device logs one Warning at init.
- Init log shows configured vs effective, e.g. transport=Native (default).
- AdminUI forms: "Default (...)" / "Native (managed)" / "libplctag (fallback)". Default omits the
  key on save (reverses Task 19's explicit re-emit, which would have pinned every edited driver).
- CLIs: --transport default|native|libplctag, default "default" via the resolver, so
  --plc-type Plc5 with no --transport runs libplctag.
- Tests pinned to Libplctag where a default-path runtime was created: found by instrumenting the
  native factories with the current xUnit test name (one unit test, AbLegacyReinitConfigAdoption;
  plus the two fixture-gated AbCip probe E2E tests whose exact messages were proven on libplctag).
  TransportMatrix and the differential harness already pass explicit transports.
- Switch/resolver/CLI/form tests are falsified by mutating the resolver (default -> Libplctag,
  exclusion removed, AbLegacy default -> Libplctag): each mutation turns them red.
- Replaces the inline role-name literal in AbCipDriverFactoryExtensions' ParseEnum call with a named
  constant; RoleAuthorityGuardTests was already red on the base commit for that line.
- Docs: AbCip.md / AbLegacy.md default + release note; CLAUDE.md AB section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:49:33 -04:00
Joseph Doherty 17e1170e61 docs(plans): native AB transport tasks.json status sync
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:26:00 -04:00
Joseph Doherty 6a22d28098 test(abcip): real-press read-only gate — native vs libplctag differential and latency (Z-35162)
Pass 2 (2026-10-08 00:21Z, df776170): 17/17. Browse 3162=3162 tags, 20 UDT
templates identical, 200 tags read with 0 unexpected divergences (6 live
values compared structurally + every still element), 1 Forward Open and 1
acked Forward Close per native phase. Latency native p95 105.8 ms vs
libplctag 139.5 ms (limit 153.4) PASS. Pass 1 failures and their fixes are
recorded in the gate-results doc; pass 2 had no independent pcap.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:25:32 -04:00
Joseph Doherty df77617051 docs(abcip): Large Forward Open refusal targets and ConnectionSize honouring (T21 review r1 M6)
The 5069-L3x supports the Large open, as the press proved. The narrow/refusing case is a pre-v20 1769-L3x or a bridge. AbCipDeviceOptions.ConnectionSize: the native transport honours it on the wire; libplctag ignores it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:34 -04:00
Joseph Doherty 6ae6287360 fix(ab): orphan release clears the wrapper handle; rooting fallback documented and pinned (T21 review r1 M1/M2)
M2: after plc_tag_destroy, the release writes 0 back to the wrapper's nativeTagHandle, so a repeat release is a no-op instead of touching a destroyed id. The runtimes keep the destroyed handle for tests and diagnostics.

M1: the rooting fallback is now described accurately, as an unbounded leak (one native tag per failed create), not a bounded one. A test seam (ForceRootingForTest) pins that the fallback roots the tag and destroys nothing.

Both pins are red with their line removed. The orphan tests share a non-parallel collection, because the counters and the seam are process-wide.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:33 -04:00
Joseph Doherty 43b81b6643 test(abcip): live tags keep a per-element value check; close barrier and GC/RTT attribution (T21 review r1 I1/M9/M8/M4)
I1: a live verdict no longer waives the value check. Only elements that moved between the two libplctag reads are excused.
- An element equal in both libplctag reads must also be equal through native; otherwise **NO: stable element i**.
- A moved element must not read NaN or infinity on native unless libplctag saw it too; otherwise **NO: non-finite element i**.
- Live tags log their full raw bytes and every element, and the summary reports live tags as N of the total.
- The offline pin adds the pass-1 Real[5] shape: a stable element shifted on native fails. It is red with the element check disabled.

M9: after each native phase (differential and latency), wait for the session close. With AB_DIFF_COUNT_OPENS=1, assert exactly one Forward Close was sent, using the new CipConnection counters; acked is logged.

M8: each latency pass logs its GC collection deltas (gen0/1/2) for both transports, plus native per-exchange round-trip min/mean/max.

M4: the latency test also asserts that no libplctag orphan had to be rooted.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:33 -04:00
Joseph Doherty c90b1a8214 feat(ab-transport): Forward Close and per-exchange round-trip gate diagnostics (T21 review r1 M8/M9)
CipConnection gains two process-wide gate-diagnostic counters next to ForwardOpenCount: ForwardClosesSent (incremented as the request goes on the wire) and ForwardClosesAcked (incremented when the reply arrives within the 1 s budget).

It also records round-trip statistics for completed exchanges: ResetExchangeStats() clears them and ReadExchangeStats() returns count, min, max and mean. The cost is two Stopwatch reads and a few interlocked operations per request. No behaviour changes.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:33 -04:00
Joseph Doherty 82a0c298ce test(abcip): read-parity volatility probe; live tags compared structurally (T21 press pass 1)
In T21 press pass 1, two REAL tags diverged only because their live values moved
between the sequential libplctag and native phases (WW_M4ActualTemp 384.887 ->
385.138).

The libplctag phase now reads every selected tag a second time, paced, before
native starts (TransportObservations.ProbeReads). A tag whose two libplctag reads
both succeed and differ is classed live. For a live tag, parity still requires:
- the same status (and the native-TypeMismatch rule);
- no decode error;
- the same raw length;
- the same CIP type code when both sides expose one (native records it now;
  libplctag.NET 1.5.2 does not);
- the same decoded shape.
Value equality is not required. Live tags get their own list and a "libplctag
probe" table column, and are counted as neither expected nor unexpected
divergences.

A tag that reads stable on libplctag but differs on native is still an unexpected
divergence, and a missing or failed probe never makes a tag live. The offline pin
A_live_value_is_compared_structurally_and_a_stable_one_by_value goes red with the
live branch disabled.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 19:50:41 -04:00
Joseph Doherty 6cd6876229 fix(ab): destroy the native libplctag tag a failed initialisation leaves behind (T21 press pass 1 crash)
The T21 press pass 1 test host died at exit with "A callback was made on a garbage
collected delegate of type ...plctag+callback_func_ex::Invoke". The cause is in
libplctag.NET 1.5.2. When a tag's create fails, for example with NotFound,
InitializeAsync throws with the callback still registered. Dispose() destroys a tag
only if it is initialised, so the native tag outlives its wrapper. The GC then frees
the delegate, and libplctag's atexit plc_tag_shutdown raises DESTROYED into it.
Reproduced against ab_server: the production LibplctagTagRuntime with a NotFound tag
exits 134 with that exact message, and exits 0 with this fix.

A timed-out create unregisters its callback but never destroys the handle either,
so that native tag keeps reconnecting for the life of the process.

The fix adds LibplctagOrphanRelease (AbCip and AbLegacy). After a failed
initialise/read/write that leaves the tag uninitialised, it reads the handle through
the pinned wrapper's private fields, unregisters the callback and destroys the tag.
If those fields cannot be read, it roots the tag for the life of the process
instead. It is applied in both tag runtimes, the symbol-listing reader and the
template reader. GetStatus keeps the status the tag held before it was destroyed.

Pins: offline refused-connection tests in AbCip.Tests and AbLegacy.Tests, a live
NotFound test on ab_server, and a reflection-shape pin. All go red with the release
disabled. The differential harness also asserts that no orphan had to be rooted.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 19:49:03 -04:00
Joseph Doherty c306c6f674 fix(abcip): CompactLogix native default connection size matches libplctag's Large Forward Open (found by T21 latency harness) 2026-10-07 19:34:30 -04:00
Joseph Doherty 2dc55ecb8f test(abcip): read-only poll-latency comparison harness for the T21 press gate (proven on ab_server) 2026-10-07 19:26:23 -04:00
Joseph Doherty 3ea72f29dd docs(plans): native AB transport tasks.json status sync 2026-10-07 19:12:59 -04:00
Joseph Doherty e066f89290 docs(ab): note the micro800 libplctag re-run after the empty-path fix 2026-10-07 19:12:59 -04:00
Joseph Doherty c7f7c940a8 docs(ab): native transport, Transport switch, known gaps and the type-check change 2026-10-07 19:12:32 -04:00
Joseph Doherty 9a576599e1 docs(plans): native AB transport tasks.json status sync 2026-10-07 19:11:03 -04:00
Joseph Doherty d613575f32 merge: libplctag empty-path fix (null Path for an empty CIP route) 2026-10-07 19:06:36 -04:00
Joseph Doherty b8ebafc642 feat(adminui): Transport dropdown on AbCip/AbLegacy driver forms (live-verified on docker-dev)
- AbCip/AbLegacy driver forms gain a Transport InputSelect; FromOptions/ToOptions now carry
  Transport, so saving any other field no longer silently reverts Native to libplctag.
- Both drivers log the constructed transport once per init.
- AbCip factory now takes the host ILoggerFactory (it ran on NullLogger in production, hiding
  every AbCip log line); DriverFactoryBootstrap passes it.
- Gate results: AdminUI /run (T19) native, inverted (dead port) and libplctag legs.
2026-10-07 19:05:50 -04:00
Joseph Doherty afe6850f3d fix(abcip,ablegacy): pass a null libplctag path for an empty CIP route (Micro800 / unrouted MicroLogix)
The libplctag adapters assigned Tag.Path straight from the device's parsed
CipPath. A device address with no route (ab://host:44818/ - the normal
Micro800 form, and a MicroLogix with no route) parses to "", and libplctag
rejects an empty path attribute with ErrorBadData; omitting the attribute
(null) is what it accepts. So Micro800 could not connect on the default
libplctag transport.

This predates the native transport work and exists on master.

Add an internal LibplctagPath.Normalize helper per driver project (blank ->
null) and route all four Tag constructions through it (AbCip tag runtime,
symbol-list enumerator, template reader; AbLegacy tag runtime). The runtime
tag construction is extracted to an internal BuildTag so tests can inspect
Tag.Path without connecting. Tests fail with Normalize reverted to identity.
Update the Micro800 smoke-test comment: both transports are expected to pass.
2026-10-07 19:05:43 -04:00
Joseph Doherty c8b35663e5 docs(plans): native AB transport tasks.json status sync 2026-10-07 18:29:04 -04:00
Joseph Doherty 566ab20d72 test(abcip): differential stops on first connection failure, paces + caps requests, reserves array/UDT slots, fails vacuous template parity; write tests opt-in (Task 18 review r1) 2026-10-07 18:24:25 -04:00
Joseph Doherty 1d6501f427 feat(ab-transport): process-wide open-attempt budget so the differential never retries an open (Task 18 review r1) 2026-10-07 18:10:25 -04:00
Joseph Doherty 0e20a8e5a9 test(abcip): read-only differential harness comparing native and libplctag on one target 2026-10-07 17:58:47 -04:00
Joseph Doherty d1ad2600d1 docs(plans): tasks.json sync 2026-10-07 17:46:19 -04:00
Joseph Doherty 52670447ca docs(plans): T17 results commit reference 2026-10-07 17:36:16 -04:00
Joseph Doherty 8c68694df1 test(ab): run the ab_server integration suites on both transports 2026-10-07 17:35:31 -04:00
Joseph Doherty 2171fc8ce3 fix(ablegacy): PCCC requestor id is libplctag's captured identity (found by Task 17 slc500)
ab_server answers every Execute PCCC with a fixed requestor id (pccc.c
PCCC_RESP_PREFIX: 07 3D F3 45 43 50 21) instead of echoing the request's, so
the R14-3 echo check rejected every native reply sent under the 0x1337 CIP
originator identity as BadReply. The pooled runtime now sends libplctag's
identity (PcccServices.RequestorVendor/RequestorSerial), which passes the echo
check against ab_server and against a real controller that echoes. Pinned by a
FakeEipServer round trip that answers with ab_server's fixed prefix.
2026-10-07 17:31:45 -04:00
Joseph Doherty 82d011c7a8 fix(ab-transport): RegisterSession carries a zero sender context (found by Task 17 controllogix)
ab_server's RegisterSession sanity check rejects any non-zero sender context
with encapsulation status 1 and a header-only reply, so every native session
against the simulator failed with BadCommunicationError. libplctag sends zero
(logix-register-session.hex); the codec test now matches that capture byte for
byte instead of excluding the context, and a client test pins the zero on the wire.
2026-10-07 17:20:06 -04:00
Joseph Doherty 72c73f525e docs(plans): tasks.json sync 2026-10-07 17:15:54 -04:00
Joseph Doherty e66b05fddf docs(plans): native AB transport tasks.json status sync 2026-10-07 17:14:02 -04:00
Joseph Doherty cff57d25c5 test(abcip): Task 15 nullable warning fix 2026-10-07 17:13:53 -04:00
Joseph Doherty 45f6e1793f feat(abcip): Transport option selects native or libplctag across driver, probe and CLI 2026-10-07 17:13:37 -04:00
Joseph Doherty 1211cd9a83 test(abcip): Task 13 review r2 - non-vacuous exclusive-packing tests
Scheduler tests (exclusive mid-queue and at head) and a template-reader test that
genuinely queues a Read Template behind a small read; bounded QueuedCount wait
replaces the fixed delay. Falsified: removing exclusive from the reader turns the
reader test red; removing the Exclusive handling in RequestScheduler turns all three red.
2026-10-07 17:10:35 -04:00
Joseph Doherty b2b9aa04ed fix(abcip): Task 13 review r1 - bounded listing pager, exclusive large reads, template bound, safe lease release 2026-10-07 17:07:05 -04:00
Joseph Doherty 78e60a5a75 feat(abcip): native symbol listing and template reads feeding the unchanged decoders 2026-10-07 17:01:40 -04:00
Joseph Doherty 5dd1b987b9 docs(plans): native AB transport tasks.json status sync 2026-10-07 16:58:54 -04:00
Joseph Doherty f2f1e4280c merge: Task 12 — native AbCip tag runtime, factory and device mapping 2026-10-07 16:58:37 -04:00
Joseph Doherty 5fd9ff941e feat(ablegacy): Transport option selects native or libplctag across driver, probe and CLI 2026-10-07 16:57:20 -04:00
Joseph Doherty e2e441d45e fix(abcip): native runtime write guards, same-width integer exemption, deferred path encoding (Task 12 review r1) 2026-10-07 16:56:30 -04:00
Joseph Doherty f2377251af merge: Task 14 — native AbLegacy tag runtime + factory 2026-10-07 16:54:40 -04:00
Joseph Doherty b249c15c1a fix(ablegacy): refuse status-bit writes, decode status bits as 0/1, reject over-long replies, global TNS 2026-10-07 16:54:18 -04:00
Joseph Doherty d0f89f9ec1 docs(plans): native AB transport tasks.json status sync 2026-10-07 16:49:32 -04:00
Joseph Doherty 33dbd342e5 fix(ab-transport): trim-only CIP path canonicalisation, observe abandoned shared opens, no Forward Open after dispose during connect 2026-10-07 16:49:19 -04:00
Joseph Doherty 25cc836865 feat(abcip): native tag runtime over the managed CIP transport, with returned-type checking 2026-10-07 16:47:48 -04:00
Joseph Doherty c31e6deebf feat(ablegacy): native PCCC tag runtime over the managed transport 2026-10-07 16:45:50 -04:00
Joseph Doherty a251954bf7 fix(ab-transport): harden Task 9 pool and connection (close chaining, uncancellable shared open, monotonic throttle, canonical CIP path, internal ctors, shared-pool logger) 2026-10-07 16:45:26 -04:00
Joseph Doherty b82fffd269 feat(ab-transport): per-device connection with routing modes, shared scheduler, keep-alive and Forward Open throttle 2026-10-07 16:35:11 -04:00
Joseph Doherty d7145ddfa5 docs(plans): native AB transport tasks.json status sync 2026-10-07 16:23:09 -04:00
Joseph Doherty ba73f48f9c merge: Task 10 — RequestScheduler (MSP coalescing) + LogixTagClient fragment loops 2026-10-07 16:23:08 -04:00
Joseph Doherty 5211975067 fix(ab-transport): harden Task 10 scheduler and tag client (dispose faults, pump guard, read cap, write validation, 8-byte wide-struct alignment) 2026-10-07 16:22:40 -04:00
Joseph Doherty 1c7fd5d5de feat(ab-transport): MSP-coalescing request scheduler and fragmented Logix read/write 2026-10-07 16:15:05 -04:00
Joseph Doherty 2f9c7eff58 merge: Task 5 — EtherNet/IP encapsulation codec + single-flight client 2026-10-07 16:14:02 -04:00
Joseph Doherty 4c1c362b0e feat(ab-transport): EtherNet/IP encapsulation codec and single-flight session client 2026-10-07 16:09:06 -04:00
Joseph Doherty 7c81844be5 merge: Task 8 — PCCC codec (Execute PCCC, SLC typed, PLC-5 range read) 2026-10-07 16:07:32 -04:00
Joseph Doherty 5eb018937b fix(ab-transport): PCCC review r1 - UA bit, bit limits, stricter ParseReply, non-const verified flags 2026-10-07 16:07:13 -04:00
Joseph Doherty 110fb861e9 merge: Task 7 — CIP service codecs (CM, MSP, Logix, symbol, template) 2026-10-07 16:07:07 -04:00
Joseph Doherty 6d16765804 fix(ab-transport): harden Task 7 codecs (read status, 32-bit start instance, empty-More, size range) 2026-10-07 16:06:49 -04:00
Joseph Doherty 13d631d856 feat(ab-transport): PCCC Execute-PCCC codec for SLC/MicroLogix typed logical and PLC-5 range read 2026-10-07 16:03:43 -04:00
Joseph Doherty 5f86b370f7 feat(ab-transport): Connection Manager, MSP, Logix read/write, symbol and template service codecs 2026-10-07 16:03:41 -04:00
Joseph Doherty ec1a39ff6e merge: Task 4 — golden libplctag<->ab_server frame captures 2026-10-07 15:59:39 -04:00
Joseph Doherty 617b9ba40c docs(plans): native AB transport tasks.json status sync 2026-10-07 15:59:39 -04:00
Joseph Doherty b085360454 test(ab-transport): non-zero array/BOOL captures, committed capture client, sim quirks
Review round 1 on the golden frames:
- Re-captured logix-read-dint-array16 (element i = ((i+1)<<24)|(i+1)) and
  logix-read-bool (= 1) so neither decode oracle is all zeros. ab_server rejects
  libplctag's own BOOL write with CIP 0x03 (two data bytes for a one-byte BOOL),
  so the BOOL is seeded by a raw UCMM Write Tag (client seed-bool).
- Committed the capture client as scripts/ab-capture/client (outside the slnx,
  CPM off, libplctag 1.5.2 pinned); capture.sh gains a `client` subcommand.
- README documents simulator-only behaviour: zeroed reply sender context, STRING
  typed D0 00 instead of A0 02 + handle, and the BOOL write refusal.
- capture.sh no longer claims UnregisterSession reaches the wire (session ends in RST).
- CaptureFileTests also check the CPF walk ends at the frame end and that every
  reply service is its request service | 0x80.
2026-10-07 15:58:05 -04:00
Joseph Doherty 1201d5fcfb test(ab-transport): golden libplctag<->ab_server frame captures for Logix, Micro800 and PCCC
17 .hex fixtures captured from libplctag 1.5.2 (native 2.6.0) against
otopcua-ab-server:libplctag-release, plus the CaptureFile loader, its
length/alternation tests, and the re-runnable scripts/ab-capture/capture.sh.

Observed and recorded in Captures/README.md "Routing observed":
- Micro800 is NOT UCMM under libplctag 2.6.0: it opens a Large Forward Open
  (path 20 02 24 01) and reads connected; use_connected_msg=0 is ignored.
- SLC500/MicroLogix/PLC-5 send Execute PCCC 0x4B unconnected and unwrapped;
  a configured path=1,0 never reaches the wire.
- Only Large FO 0x5B was observed; no 0x54 fallback occurred.
- LogixPccc is unreachable against ab_server (CIP 0x08); no fixture committed.
2026-10-07 15:50:00 -04:00
Joseph Doherty de14d354e6 fix(ab-transport): widen libplctag guard regex to catch aliases and static usings 2026-10-07 15:49:24 -04:00
Joseph Doherty 29f456c3ef test(ab-transport): guard libplctag confinement to its adapters (falsified by adding using libplctag to AbCipDriver.cs) 2026-10-07 15:47:52 -04:00
Joseph Doherty d602a08925 merge: Task 6 — CIP path encoding + Logix type codes 2026-10-07 15:46:31 -04:00
Joseph Doherty 0d2f27bb1d merge: Task 3 — AbLegacy seam decoupled from libplctag types 2026-10-07 15:46:31 -04:00
Joseph Doherty 62abdb4c5b merge: Task 2 — AbCip seam decoupled from libplctag types 2026-10-07 15:46:31 -04:00
Joseph Doherty 52505678d6 docs(plans): native AB transport tasks.json status sync 2026-10-07 15:46:31 -04:00
Joseph Doherty 10c909cbc2 fix(abcip): translate decode/encode-time libplctag exceptions; doc and probe-test cleanups 2026-10-07 15:44:02 -04:00
Joseph Doherty a57d61c484 refactor(abcip): confine libplctag to its adapters behind AbStatus/AbTransportException 2026-10-07 15:40:07 -04:00
Joseph Doherty 1affb4b613 refactor(ablegacy): confine libplctag to its adapter behind AbStatus/AbTransportException 2026-10-07 15:39:01 -04:00
Joseph Doherty f2f1ec2585 feat(ab-transport): CIP route/symbolic path encoders and Logix type codes 2026-10-07 15:38:04 -04:00
Joseph Doherty a0897fda96 feat(ab-transport): project skeleton with AbStatus, CIP reply parsing and the ICipChannel seam 2026-10-07 15:34:34 -04:00
Joseph Doherty b6f64c369a docs(plans): sync native AB transport plan + tasks.json after task transcription fixes 2026-10-07 15:24:25 -04:00
Joseph Doherty d4808fa948 docs(plans): fold Fable review into the native AB transport plan; add tasks.json 2026-10-07 15:17:07 -04:00
Joseph Doherty 6106946504 docs(plans): native AB transport implementation plan (draft, pre-review) 2026-10-07 14:59:26 -04:00
Joseph Doherty 45b6062de5 docs(plans): native .NET EtherNet/IP+CIP+PCCC transport to replace libplctag under AbCip/AbLegacy — design 2026-10-07 14:35:14 -04:00
246 changed files with 28028 additions and 928 deletions
+92 -2
View File
@@ -859,6 +859,15 @@ Two things to know before relying on any of this:
browse, subscribe and HistoryRead are ungated for any admitted session including Anonymous**. With the
flag **on** (`DataPlaneAuthorizer`), scoped grants decide **every** data-plane operation: write and
alarm commands and HistoryRead (stage 5), read and browse and subscription creation (stage 6).
- **The two role gates re-check the session's KEY on every call, flag off or on (Gitea #563).** Roles
are resolved once at activation, so an expired / deployment-disabled key used to keep `WriteOperate` /
`AlarmAck` on its open session until the client disconnected. `DataPlaneAuthorizer.AuthorizeKeyUsable`
runs the same `FindUsableKey` probe against the same snapshot + clock as the ACL path (one dictionary
lookup, no I/O) and denies `BadUserAccessDenied` before the role check. **Only those two gates** —
reads/subscriptions stay ungated with the flag off, by design. Anonymous carries no key and is
unaffected. No flag: a dead key keeping write authority is a defect, not a rollout choice. ⚠️ On a
fused node a key minted after the last deploy authenticates (ConfigDb) but cannot write/ack until a
deployment carries it — the flag-on path always behaved this way.
- ⚠️ **The ACL decision REPLACES the role check — the two are never chained.** Chained, a grant would
have to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped
grants confer a role, so every narrow grant would stay inert and the flag would appear to do
@@ -910,6 +919,39 @@ Two things to know before relying on any of this:
`Security:Acl:Enabled` is false), runs the same pass on the clock — because `ExpiresUtc` passes with
the clock, not with a deployment. The pass's verdict memo is **pass-local and must stay that way**;
hoisting it across ticks would serve an expiry that landed between two ticks the earlier tick's allow.
**Event/alarm items ride both triggers too (Gitea #562)** — a second map (`_eventItems`), fed by the
`SubscribeToEvents` override, holds every *direct* notifier subscription; a denied one is detached via
the SDK's own protected `SubscribeToEvents(…, unsubscribe: true)` (what a client delete does) and
re-attached with `unsubscribe: false` on restore — again never `Disabled`. Unlike a data item it is
**not told** (no value slot for a status; the feed goes quiet), and a client delete of a stood-down
item is absorbed by the override (the SDK would otherwise remove an item that is not attached).
**Server-object (`i=2253`) event subscriptions are filtered per event at delivery (Gitea #586).** The
`SubscribeToAllEvents` override attaches them to **nothing** (just records them in `_allEventsItems`);
each condition's own `OnReportEvent` (`OnConditionReportEvent`, fires once per event ⇒ still one
Server-object copy) queues the event to each recorded item whose session holds `Subscribe` on the
condition (RawPath) **or** any notifier folder it reports through — the per-folder path's question. The
root-notifier forward to the core Server object (`OnReportEvent` override) is **dropped**: it was the
ungated path, and it is the only one that served an item created before the alarm was deployed (the
SDK's `AddRootNotifier` subscribes existing all-events items with `unsubscribe: true`). Revalidation is
**implicit** — every event re-asks the authorizer, so a deploy or expiry lands on the next event with no
pass. `ConditionRefresh` on an `i=2253` item is filtered the same way. The dispatcher **must** keep
the SDK's audit guard (`AuditEventState` only while `Server.Auditing` over SignAndEncrypt/https),
because conditions report their Part 9 method audit events through themselves.
`ReconcileAllEventsMonitoring` must keep `AreEventsMonitored` raised on every condition while any
`i=2253` item exists, because `ConditionState` gates its method **audit** reports on
`EventsMonitored()` (method-driven state-change events no longer depend on it — #587, below). The
protected `SubscribeToEvents` override that no-ops all-events items is what
stops the SDK's `AddRootNotifier` (`unsubscribe: true` bug) taking that unit back. Both review
findings (review of `75e559f6`). ⚠️ The materialise path raises only the **new** condition's unit
(`RaiseAllEventsMonitoring`); a full reconcile there made a deploy of N alarms O(N²) under `Lock` with
ScadaBridge connected — keep the full reconcile to remove/rebuild/(un)subscribe.
**Model-change and audit events are gated too (Gitea #588).** The manager's own server-wide events
(`GeneralModelChangeEvent`, the rejected-write `AuditWriteUpdateEvent`) go through
`ReportToServerObject`: flag **off** ⇒ `Server.ReportEvent` as before; flag **on** ⇒ delivered per item to
`_allEventsItems` (same audience), a model change narrowed to the `Affected` nodes the session may
**Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral
one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other
event type fails closed. Auditing is off by default (never configured in this repo).
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
@@ -932,8 +974,9 @@ Two things to know before relying on any of this:
**Expiry is the exception**: `FindUsableKey` compares `ExpiresUtc` against the authorizer's clock on every
call, so it is immediate everywhere — and since arch-review 03/S19 it reaches live subscriptions too, on
the host's `Security:Acl:RevalidationIntervalSeconds` timer (default 60 s; the session is **silenced with
`BadUserAccessDenied`, not closed**, and recovers on re-activation). ⚠️ Event/alarm monitored items are
revalidated by neither trigger — pre-existing, and a named residual.
`BadUserAccessDenied`, not closed**, and recovers on re-activation). Direct event/alarm subscriptions
are revalidated on both triggers as well (#562); with the flag **off**, expiry and revocation reach the
two role gates (#563) but not reads or subscriptions.
- ⚠️ **The activation lookup is BOUNDED (arch-review 03/S18).** The SDK raises impersonation synchronously
under a `SessionManager`-wide lock, so a ConfigDb that is *slow* rather than *refusing* stalled **every**
session activation on the server, Anonymous and X.509 included. `BoundedApiKeyStore` wraps the ConfigDb
@@ -973,6 +1016,38 @@ docker-dev. The rationale for every decision above is in
Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.)
## AB drivers: native transport (`Transport`)
AbCip and AbLegacy have a second, managed wire transport (`Driver.Ab.Transport`) beside libplctag, chosen by
the driver-config key `transport` = `Native` | `Libplctag` (string; takes effect when the driver is
**constructed** — every process start, or a deploy that recreates it, not `ReinitializeAsync`; AdminUI
"Transport" dropdown; CLIs `--transport`). "Default" is encoded by omitting the key; the AdminUI must never
re-emit a concrete default. The defaults differ per driver (final review, 2026-10-07):
- **AbCip: an absent key resolves to the node default, `Native` unless pinned.** Precedence (one place,
`AbCipTransportResolver`, shared by driver, probe and CLI): explicit per-driver key → node-level appsetting
**`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env `Drivers__AbCip__DefaultTransport`,
`AbCipDriverDefaultsOptions`; a bad value **fails host start**) → compiled `Native`. Bound unconditionally
because the driver factory AND the admin-node Test Connect probe both read it. ⚠️ So **the upgrade restart
alone moves no-key AbCip drivers to Native**; to stay on libplctag, set the node key — NOT a per-driver key
through the old AdminUI, whose typed form drops unknown keys (#576). Evidence: AbCip reads proven on a real
CompactLogix (Z-35162, read-only); AbCip writes are `ab_server`-only.
- **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On
a wire the native PCCC path is proven only for **N-file reads/writes and one F8 read**, all on `ab_server`;
`S:0` (the default probe), B/T/C/R/S/L/ST/A/O/I and the T/C/R sub-element offsets are encoded from the PCCC
table and **unverified natively**. Explicit Native on PLC-5/`LogixPccc` logs a once-per-instance Warning.
AbLegacy native refuses status-bit sub-element writes (`T4:0.DN`…, `BadNotSupported`) and its ST byte order
is unverified.
Refusals are not comms faults: an Unsupported refusal (`BadNotSupported`) and an AbCip native **type
mismatch** (`BadTypeMismatch`, where libplctag returned Good garbage for a same-width mis-authored type) both
keep the tag handle, leave driver health alone (no `Degraded`, nothing for the comms-loss watchdog) and warn
once per tag — only genuine transport failures evict and degrade. `DeviceSessionPool.Shared` logs through the
host logger factory (wired in `DriverFactoryBootstrap`). libplctag is confined to `Libplctag*.cs` adapters by
`LibplctagConfinementGuardTests`; do not reference it elsewhere. See `docs/drivers/AbCip.md` §Transport,
`docs/drivers/AbLegacy.md` §Transport + §Wire evidence, `docs/Configuration.md` §`Drivers:AbCip`, and
`docs/plans/2026-10-07-native-ab-transport-gate-results.md`.
## Library Preferences
- **Logging**: Serilog with rolling daily file sink
@@ -1026,6 +1101,21 @@ See `docs/ScriptEditor.md` for the full guide. Scripts may use the `{{equip}}` t
Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the `alarm-commands` DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in `OtOpcUaNodeManager`, gated on the `AlarmAck` LDAP role via `RoleCarryingUserIdentity`; (2) AdminUI `/alerts` per-row buttons routed through the `AdminOperationsActor` singleton (gated by `DriverOperator` policy). `ScriptedAlarmHostActor` dispatches from the topic to the engine. Client.CLI supports `ack`, `confirm`, `shelve` commands. See `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" and `docs/AlarmTracking.md`.
⚠️ **One condition event per accepted method call is OURS, not the SDK's (Gitea #587).** The SDK's own
report is gated on `AutoReportStateChanges` (left `false`), so before #587 a client Acknowledge emitted **no**
condition event — the old "SDK auto-fires its own event (E2)" comments were wrong. `WireMethodTransitionReporting`
wraps every method's SDK handler (Ack/Confirm/AddComment/Enable/Disable/OneShotShelve/TimedShelve/Unshelve) and,
on Good, reports once via `ReportConditionEvent` — **under `Lock`**, so the engine's/driver's re-projection
(routed by a fire-and-forget Tell *before* the SDK applies state) waits and then hits the delta-gate with no
delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; only a real
before/after change is reported (a re-ack is silent and restores the Confirmed/Retain the SDK flips);
`MaxTimeShelved` is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve
failed. ⚠️ **Native alarms support Acknowledge and AddComment only** — Confirm, the shelve methods and
Enable/Disable return `BadNotSupported` (no upstream path, no pair replication, and `NativeAlarmProjector`'s
Confirmed/Unshelved constants own those fields); a native ack re-asserts `ConfirmedState = true` because the
SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that field. Pinned over the wire by
`ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`).
## Historian / HistoryRead
**Backend: HistorianGateway (sole historian backend).** As of the gateway-integration cutover, the
+2
View File
@@ -40,6 +40,7 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj" />
@@ -109,6 +110,7 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj" />
+78 -9
View File
@@ -36,9 +36,12 @@ same condition is wired as an **event notifier of every referencing equipment fo
`EnsureFolderIsEventNotifier(equipFolder)`.
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
every referencing equipment folder, and up to the Server object. A subscriber at any one
every referencing equipment folder, and to the Server object. A subscriber at any one
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root.
The Server-object (`i=2253`) copy is delivered by the condition itself, not by the folders'
forward to the core Server object, so it can be filtered per subscriber by the data-plane ACL
(Gitea #586 — see `docs/security.md`).
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
Server-object dedup + Part 9 ack correlation).
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
@@ -311,9 +314,30 @@ from the scripted `AlarmCommandRouter`:
OPC UA client (mirrors the Galaxy write-outcome limitation; the local
`AlarmConditionState` SDK update already committed at step 1).
Only the **Acknowledge** is routed to the driver. `Confirm` / `AddComment`
/ `Shelve` operations on a native condition stay on the scripted
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only).
Only the **Acknowledge** is routed to the driver. **A native condition
supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`,
`OneShotShelve` / `TimedShelve` / `Unshelve` and `Enable` / `Disable` return
`BadNotSupported` and report no event. There is no upstream path for them and
no pair replication of a local state, and the driver projection
(`NativeAlarmProjector`: always `Confirmed`, `Unshelved`) would silently undo
one at the next transition. `AddComment` stays on the scripted
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only) and is
applied locally.
A native condition is **never Unconfirmed**: it starts Confirmed, and because
the SDK's own Acknowledge sets `ConfirmedState = false` on any condition type
that supports Confirm, the node manager re-asserts `Confirmed = true` (and the
projection's Retain rule, `Active || !Acked`) after the SDK handler, before the
one ack event. ScadaBridge maps `ConfirmedState` into its alarm state, so this
is client-visible.
> **Secondary-node ack flap.** On a node that is not the redundancy Primary,
> an OPC UA Acknowledge is applied to the local condition and reported (one
> event, Acked), but `DriverHostActor`'s Primary gate drops the route upstream.
> The driver never acknowledges, so its next transition for that alarm is
> projected with `Acked = false` and the node reverts — with its own event. A
> client acking against the Secondary sees acked → unacked. Ack against the
> Primary (the higher ServiceLevel) for the ack to stick.
> **Best-effort during reconnect.** If a native ack arrives while the owning
> driver is `Connecting`/`Reconnecting` (or on a non-Primary node), it is
@@ -368,11 +392,56 @@ hosted service.
`OnTimedUnshelve` (the SDK's automatic unshelve timer) bypasses the
operator gate — it is system-initiated.
`WriteAlarmCondition` fires the Part 9 condition event only when the
incoming state differs from the node's current live state (delta-gate),
preventing the double-emit that would otherwise occur when the SDK
auto-applies the acked state and the engine re-projection fires a
duplicate event immediately after.
### One condition event per method call (Gitea #587)
Every accepted Part 9 method call — Acknowledge, Confirm, AddComment,
OneShotShelve, TimedShelve, Unshelve, Enable, Disable, on a native or a
scripted condition — reports **exactly one** condition event carrying the
new state, to folder subscribers and (through the ACL) Server-object
subscribers alike. A refused call (bad status, e.g. a denied gate, an
already-acknowledged condition, Enable/Disable on a native condition)
reports nothing.
- **The SDK does not report it.** `ConditionState.ReportStateChange` is
gated on `AutoReportStateChanges`, which this repo leaves `false` — so
before #587 a client Acknowledge produced no condition event at all (an
older comment claimed "the SDK auto-fires its own event"; it never did).
Instead the node manager wraps each method's SDK handler
(`WireMethodTransitionReporting`) and, once the handler returns Good,
reports the condition itself. That report does not depend on the SDK's
`AreEventsMonitored` bookkeeping, which is imprecise for conditions
materialised under an already-subscribed folder.
- **The SDK's shelve methods never apply shelving state** (they only call
`OnShelve`), so the wrapper applies it — with the same
`(shelved, oneShot, 0)` mapping the projection uses — before reporting.
- **The re-projection does not duplicate it.** The engine's (or driver's)
answer to the routed command reaches `WriteAlarmCondition`'s delta-gate,
which compares against the node's current state — already the new state —
and suppresses. The whole method call runs under the node-manager lock, so
a fast re-projection cannot slip in between the SDK applying the state and
the report.
- **Only a real change is reported.** The condition's state is compared
before and after the SDK handler; a call that changed nothing reports
nothing. The SDK accepts acknowledging an acknowledged condition and sets
`Confirmed = false` again, so a re-ack also restores the pre-call Confirmed
and Retain — silent, and a scripted node never drifts from its engine.
AddComment always reports (the comment is the change).
- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm
acknowledge → AVEVA" above. The other methods are refused
(`BadNotSupported`, no event), and a native ack keeps the condition
Confirmed.
- **TimedShelve works (scripted).** `Create()` left `MaxTimeShelved` at 0, and
the SDK rejects any shelving time above it, so every client TimedShelve
failed with `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8
days). The node applies the shelve with no SDK timer of its own; the engine
owns the expiry and its Unshelved re-projection reports the one expiry
event.
- Method **audit** events (`AuditConditionAcknowledgeEventType`, …) are still
the SDK's, delivered only while the server is auditing over an encrypted
channel.
Engine-driven transitions are unaffected: `WriteAlarmCondition` still fires
whenever the incoming state differs from the node's current state.
### AdminUI path
+3 -1
View File
@@ -116,6 +116,8 @@ Source Time: 2026-03-30T19:58:38.0961252Z
Server Time: 2026-03-30T19:58:38.0971257Z
```
A Bad node status (unknown node, access denied, ...) is reported, not thrown: the command prints `Node:`, an empty `Value:` and `Status: 0x80340000 (BadNodeIdUnknown)`. Session-level failures (closed connection / channel) still fail the command.
### write
Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`.
@@ -284,7 +286,7 @@ Confirm successful: ns=1;s=TestMachine_001
### shelve
Shelves or unshelves an active alarm condition via the OPC UA Part 9 `ShelvedStateMachine` (`OneShotShelve` / `TimedShelve` / `Unshelve`). Gated on the `AlarmAck` role. `--duration` is given in **seconds** (converted to milliseconds for the OPC UA call) and is required only for `--kind Timed`.
Shelves or unshelves an active alarm condition via the OPC UA Part 9 `ShelvedStateMachine` (`OneShotShelve` / `TimedShelve` / `Unshelve`). Gated on the `AlarmAck` role. `--duration` is given in **seconds** (converted to milliseconds for the OPC UA call) and is required only for `--kind Timed`. The `--node` is the condition node itself (the `SourceNode`/condition id an `alarms` notification reports): `ack`, `confirm`, `shelve`, `enable` and `disable` call the Part 9 method ids directly on that node — no `.Condition` / `.ShelvingState` suffix is appended (those child nodes do not exist on the OtOpcUa server).
```bash
# One-shot shelve until the next clear
+25
View File
@@ -445,6 +445,30 @@ for the enablement runbook (ApiKey handling, stop/start-together rule, tombstone
never-`sqlite3`-a-live-WAL-DB inspection rules) and the "pair-local config cache" section of
[`docs/Redundancy.md`](Redundancy.md) for what boot-from-cache does and does not cover.
### `Drivers:AbCip` (node-level AbCip defaults)
- **Purpose:** pin the wire transport an **AbCip driver with no `transport` key** runs on, for every such
driver on this node, without editing driver configs (final review I1 of the native AB transport). The
per-driver `DriverConfig` key `transport` still wins when present. The main use is an upgrade: the old
AdminUI cannot author the per-driver key (its typed form drops unknown keys, #576), so this is the way to
keep a fleet on libplctag across the upgrade restart. See
[`drivers/AbCip.md` § Transport](drivers/AbCip.md#transport-transport-option).
- **Options class:** `AbCipDriverDefaultsOptions` (`SectionName = "Drivers:AbCip"`) —
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs`. Bound and validated
unconditionally in `Program.cs` (`AddValidatedOptions` + `ValidateOnStart`), because both consumers need
it: the driver factory (driver role) and the AdminUI Test Connect probe (admin role), so the probe
exercises the stack the driver would run.
| Key | Type | Default | Meaning |
|---|---|---|---|
| `DefaultTransport` | string | `Native` | `Native` or `Libplctag`, case-insensitive. Blank/absent = `Native`. **Any other value fails host start** (`AbCipDriverDefaultsOptionsValidator`, naming the key), including numeric (`1`) and flags (`Native,Libplctag`) forms. Env form: `Drivers__AbCip__DefaultTransport`. |
Precedence for an AbCip driver: explicit `transport` in its `DriverConfig` → this key → compiled `Native`.
Like the per-driver key, it takes effect when a driver instance is **constructed** (process start, or a
deploy that recreates the driver), not on `ReinitializeAsync`. The init log line reads
`transport=Libplctag (node default)` when the pin is what selected the stack. There is **no AbLegacy
counterpart**: an AbLegacy driver with no key always runs libplctag (Native is opt-in per driver).
---
## Environment variables
@@ -458,6 +482,7 @@ All names are read in this repo's source via `Environment.GetEnvironmentVariable
| `OTOPCUA_ROLES` | `EffectiveRoles.Resolve` (`ZB.MOM.WW.OtOpcUa.Cluster`) — the single role authority both `Program.cs` and `AddOtOpcUaCluster` consume | Comma-separated cluster roles for the node (`admin`, `driver`, `dev`, `cluster-{ClusterId}`), read as the **fallback** only when `Cluster:Roles` is empty. Drives the conditional wiring, the per-role appsettings overlay, and the node's Akka member roles — all from the same resolved instance. See the note under `Cluster:Roles` above. |
| `OTOPCUA_CONFIG_CONNECTION` | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/DesignTimeDbContextFactory.cs` (design-time / `dotnet ef` only) | Read at **design time** by `DesignTimeDbContextFactory.cs` for `dotnet ef` migrations. At **runtime** the server resolves the connection string from `ConnectionStrings:ConfigDb` (env form: `ConnectionStrings__ConfigDb`) via `configuration.GetConnectionString("ConfigDb")` in `ServiceCollectionExtensions.cs` — `OTOPCUA_CONFIG_CONNECTION` appears there only as a hint in an error message, not via `GetEnvironmentVariable`. No credential is embedded in source. |
| `ASPNETCORE_ENVIRONMENT` | ASP.NET host builder (framework) | Selects `appsettings.{Environment}.json` (e.g. `Development`). |
| `Drivers__AbCip__DefaultTransport` | configuration binder (`AbCipDriverDefaultsOptions`) | Env form of [`Drivers:AbCip:DefaultTransport`](#driversabcip-node-level-abcip-defaults): `Native` (default) or `Libplctag` for AbCip drivers with no `transport` key. A bad value fails host start. |
### Historian (`ServerHistorian__ApiKey`)
+2 -2
View File
@@ -352,13 +352,13 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing comma
**Scripted vs native conditions — Enable/Disable and Acknowledge:**
- **Scripted conditions** — all eight Part 9 operations (including Enable/Disable) route through `AlarmCommandRouter` onto the `alarm-commands` topic, which `ScriptedAlarmHostActor` dispatches to the engine (`EnableAsync` / `DisableAsync` / `AcknowledgeAsync` / …). On enable, `ActiveState` is re-derived from the next predicate evaluation.
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable` returns `BadNotSupported` immediately — native conditions have no engine-side enable/disable surface.
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable`, `OnConfirm` and `OnShelve` return `BadNotSupported` immediately — a native condition supports Acknowledge and AddComment only (no engine-side surface, no upstream path, and the driver projection owns its Confirmed/Shelving; Gitea #587 review).
`OnTimedUnshelve` (the SDK's internal auto-unshelve timer) bypasses the client gate — it is system-initiated and not subject to operator role checks.
### Delta-gate de-duplication
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. This suppresses the double-emit that would otherwise occur when the OPC UA SDK auto-applies the acked state on the node and the engine's re-projection then attempts to fire a duplicate event.
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. a re-ack — reports nothing). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`. The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
### AdminUI path
+198 -3
View File
@@ -20,6 +20,7 @@ coverage map, see [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md).
| Project | Role |
|---------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD alarm projection. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/` | Project-less shared source (`LibplctagOrphanRelease.cs`, #578/#580) compiled into both `Driver.AbCip` and `Driver.AbLegacy`. The `AbStatus` → OPC UA table is shared in `Driver.Ab.Transport` (`AbOpcUaStatusMap`). |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. |
Per family the `AbCipPlcFamilyProfile` (`PlcFamilies/AbCipPlcFamilyProfile.cs`)
@@ -75,13 +76,186 @@ the driver's `DriverConfig` JSON. Key fields:
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`.
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`. A driver created from the AdminUI starts with the probe **off** (an enabled probe with no tag path only logs a warning); an absent `probe` key in a stored config still means enabled to the driver.
- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). They also write durations as the integer-millisecond keys the factory binds (`timeoutMs`, `alarmPollIntervalMs`, `probe.intervalMs`, `probe.timeoutMs`); a legacy TimeSpan-string key (`timeout`: "00:00:05") is loaded and migrated on save.
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
- **Discovery** — `EnableControllerBrowse` (`@tags` walk), `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path) and `browsePageDelayMs` (native browse pacing, see [Native browse](#native-browse-paging-pacing-and-template-leases)).
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
reproduced in [docs/v2/driver-specs.md §3](../v2/driver-specs.md).
## Transport (`transport` option)
The driver has two interchangeable wire transports, selected by one `DriverConfig` key:
| Key | Values | Default | Serialization |
|---|---|---|---|
| `transport` | absent \| `Native` \| `Libplctag` | absent ⇒ the node's `Drivers:AbCip:DefaultTransport` (itself default `Native`) | string, case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by OMITTING the key |
- **`Native`** (the default since Task 22) is a managed in-process EtherNet/IP + CIP client
(`Driver.Ab.Transport`).
- **`Libplctag`** (fallback) drives the libplctag native library, as before.
- An absent key means "use the default". It is resolved in one place, `AbCipTransportResolver`, which the
driver, the Test Connect probe and the CLI all go through. **Precedence:** an explicit per-driver key →
the node-level appsetting **`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env
`Drivers__AbCip__DefaultTransport`; a bad value fails host start; see
[Configuration.md](../Configuration.md#driversabcip-node-level-abcip-defaults)) → compiled `Native`. The
Test Connect probe uses the same node value, so it probes the stack the driver would run. The CLI has no
node config and uses compiled `Native`.
- A bad value fails like a bad `PlcFamily`; the Test Connect probe reports "Config JSON is invalid".
- A change takes effect when the driver instance is **constructed**: on every process start (so the upgrade
restart moves no-key drivers to Native — see the release note) and when a deploy that changes `DriverConfig`
does this (`DriverHostActor` respawns the child, #516). `ReinitializeAsync` alone keeps the transport
it was constructed with.
- AdminUI: the **Transport** dropdown in the advanced section of the AbCip driver form ("Default (node
setting; Native unless pinned)" / "Native (managed)" / "libplctag (fallback)"). **Default omits the key on save**, so a defaulted driver stays
defaulted; saving another field preserves an explicit value and preserves absence.
- The driver logs `transport=<effective> (default|configured)` at Information on every init, e.g.
`transport=Native (default)` or `transport=Libplctag (node default)`, including reconnect re-inits. AbCip
drivers now receive the host logger (they previously ran on a null logger, so none of their warnings
reached the node log); expect previously invisible warnings, e.g. a probe enabled with no probe tag path.
- CLI: `--transport default|native|libplctag` (default `default`, which goes through the same resolver).
- libplctag is confined to `Libplctag*.cs` adapters, enforced by a source-scan guard
(`LibplctagConfinementGuardTests`), so removing it later is mechanical.
### Release note — native is the default (Task 22)
- **The native managed transport is now the default.** A driver config with no `transport` key runs
`Native`; libplctag remains available as the fallback.
- ⚠️ **Client-visible:** native reports **`BadTypeMismatch`** for a mis-authored same-width type (for example a
tag authored `DINT` where the controller holds a `REAL`), where libplctag returned Good garbage. Correct the
authored type; see [Native transport behaviour](#native-transport-behaviour). It does not degrade driver
health or churn the connection; one Warning per tag names it.
- **To fall back** one driver, set `"transport": "Libplctag"` in its driver config (or pick "libplctag
(fallback)" in the AdminUI dropdown). To fall back a whole node, set `Drivers:AbCip:DefaultTransport` to
`Libplctag` (below).
- ⚠️ **When it takes effect: the upgrade restart itself.** Every process start constructs the drivers
(including boot-from-cache on a driver-only node), so **every driver with no `transport` key moves to the
node default — `Native` unless pinned — the moment the upgraded node starts**; no deploy is needed. After the
upgrade, a per-driver transport change takes effect when a deploy recreates the driver (not on
`ReinitializeAsync`).
- **To stay on libplctag across the upgrade (pin before upgrading):** set the node-level key on each node
you upgrade, in the same change that installs the new build — `appsettings.json`
`"Drivers": { "AbCip": { "DefaultTransport": "Libplctag" } }`, or the environment variable
`Drivers__AbCip__DefaultTransport=Libplctag` on the service. Older builds ignore the section, so it can be
staged ahead of the binaries. Every AbCip driver with no `transport` key then starts on libplctag; the init
log shows `transport=Libplctag (node default)`. Move to Native later per driver (explicit
`"transport": "Native"` in the new AdminUI) or for the node (remove the key / set `Native`, then restart).
⚠️ Do **not** rely on adding `"transport": "Libplctag"` to driver configs through the **old** AdminUI before
upgrading: its typed AbCip form has no Transport field and drops unknown keys on save (#576), and on a fused
`admin,driver` node the AdminUI and drivers upgrade together, so there is no window. The node key works on
every node shape, including fused nodes and driver-only nodes booting from cache.
- **Evidence, and its limits:** AbCip **reads** (plus browse and templates) were proven on a production
CompactLogix 5069-L320ERS2 (**Z-35162**, read-only, 2026-10-08; see
[gate results](../plans/2026-10-07-native-ab-transport-gate-results.md) §Real press). AbCip **writes** are
simulator-proven only (`ab_server`).
- **AbLegacy is different: its default stays libplctag** and Native is opt-in per driver; the native PCCC
path has wire evidence (on `ab_server`) only for N-file reads/writes and one F8 read (see
[AbLegacy.md §Transport](AbLegacy.md#transport-transport-option)).
## Native transport behaviour
- **One CIP connection and one request scheduler per device** (`DeviceSessionPool`), shared by reads,
writes, browse and template reads. The Multiple Service Packet scheduler coalesces requests with one
request in flight; large reads (listing pages, Read Template) are sent unpacked and exclusive.
**Packing budgets the expected reply as well as the request (#582):** each queued request declares an
upper bound on its reply (`expectedReplyBytes`; reads: elements x element width, STRING = 88 bytes per
element, BOOL counted as 4) and a batch is closed when the summed MSP reply (`6 + 2n + sum`) would exceed
the connection size. A read whose size cannot be bounded (structure / unknown authored type) is sent
alone; writes and status-only replies count their 4-byte header. Pinned by
`RequestSchedulerTests.Mixed_scalar_string_and_array_replies_are_packed_within_the_reply_budget`.
⚠️ RESIDUAL: mixed STRING/array packing has not been exercised against a real controller (the real-press
gate predates the budget); the budget is unit-tested against the scheduler and fake channel only.
- **Fragmented-read integrity (#567):** `Read Tag Fragmented` replies are compared against the first reply;
a changed type code *or* structure handle mid-read fails the read with `BadReply` rather than stitching
two types into one value.
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
open budget.
- **A target-closed CIP connection is recycled at once (#566).** When a connected reply says the controller
no longer holds the class-3 connection while TCP stays up — general status `0x01` with extended `0x0107`
(target connection not found) or `0x0203` (connection timed out), e.g. after a controller restart or a
connection timeout — the session is closed (UnRegisterSession; no Forward Close, there is nothing to close)
and the next request reopens through the throttle. The request that got the reply fails
`BadCommunicationError` (transport `BadConnection`); a keep-alive that gets it recycles the connection
before any request fails. Every other status — tag-level errors, other `0x01` extended codes — is left to
the request. ⚠️ RESIDUAL: which of the two codes a real Logix controller returns for a forgotten
connection (and whether it answers at all rather than dropping TCP) is unobserved; the check is correct
whichever it does — a silent target still faults the session through the exchange timeout.
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
node (its connection carries the probe timeout). A later opener with different options is ignored and
logged once as a Warning (`DeviceSessionPool`). ⚠️ The device key compares the **host string**, so the
same controller addressed by DNS name in one driver and by IP in another gets two connections and two
Forward Open throttles (up to two opens per second); address a controller the same way everywhere. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
Forward Open (0x5B), the same as libplctag. A target that refuses Large gets one regular open at
min(size, 504) inside the same throttled attempt. An explicit `ConnectionSize` in the options or on the
device overrides the family default.
- **Routing:** Micro800 is connected with an empty route regardless of `UseConnectedMessaging`.
SLC500/MicroLogix/PLC-5 over this driver are direct. `logixpccc` uses UnconnectedSend; its SLC framing is
**unverified**.
- ⚠️ **Type check (client-visible change).** The native path compares the type code the controller returns
with the authored `DataType` and reports **`BadTypeMismatch`** on disagreement. libplctag never checks it,
so a same-width mis-authored tag read as **Good garbage**: authoring a tag as `DINT` while the controller
holds a `REAL` returns the REAL's bits as an integer with a Good status under libplctag, and
`BadTypeMismatch` natively. That is the fix working, but a consumer will see Bad quality on tags that
previously looked healthy: correct the authored type. Same-width signed/unsigned pairs are exempt, and
writes are gated on a mismatch too. A mismatch is a **deterministic configuration refusal**, handled like
an Unsupported refusal (final review I4): the tag's handle is kept (no re-create, no reconnect), driver
health is unchanged (no `Degraded`, nothing fed to the comms-loss watchdog), and the Warning is logged
**once per tag** per driver instance, not every poll. The same applies to the libplctag-path width check.
The same holds when the controller refuses the **existence read itself** with a type error (CIP
`0xFF`/`0x2107`): initialization completes with the status instead of throwing, so the runtime is not
disposed and re-created (nor, for a sole lease holder, re-Forward-Opened) on every poll, and the device's
connect backoff is not opened (#570). Micro800 `BYTE`/`WORD`/`LWORD` (`0xD1`/`0xD2`/`0xD4`) are checked as
integers of their width: a `REAL`/`LREAL` or a narrower integer authored over an `LWORD` is
`BadTypeMismatch`, not decoded garbage (#570).
- **A malformed tag name is a per-tag configuration error** (#570). A `TagPath` that does not parse, or a
name the CIP symbolic encoder refuses (non-ASCII, an empty member such as `A..B`, a member longer than
255 characters, an unterminated or non-numeric subscript), reads and writes **`BadConfigurationError`**
with one Warning per tag. Nothing reaches the device, so it never opens the device's connect backoff and
never degrades driver health — healthy tags on the same device keep reading. (libplctag still reports its
own `ErrorBadParam` for a name it rejects.) A malformed **probe** tag does not feed the backoff either.
- **STRING capacity:** the built-in Logix `STRING` is 88 bytes on the wire — `LEN` + `DATA[82]` + 2 bytes of
alignment padding — so a write holds at most **82** characters (it used to allow 84) and a read never
returns the padding as characters. The write still sends the whole 88-byte structure, padding zeroed, as
libplctag does. A user string type of any other size keeps the buffer-derived capacity (`size − 4`): the
read alone cannot tell its `DATA` length from up to 3 bytes of padding, so a write may accept up to 3
characters more than the type holds (#570).
- **Releasing a tag never blocks** (#579). Disposing a native tag runtime — the driver does it under the
tag's lock on an evict, a reinitialize or a shutdown — releases its connection lease in the background
(`ConnectionLease.ReleaseInBackground`, the same shape the native AbLegacy runtime uses). It used to wait
up to 2 s for the last lease's Forward Close. Nothing is lost by not waiting: the pool records the close
synchronously, so a re-create the next instant still does not open a second session until that Forward
Close + UnRegisterSession have finished, and the close is bounded by the connection's own budgets (the
open budget for an open still in flight, 1 s for the Forward Close), so a connection is always released.
Offline soak: `NativeAbCipLeaseReleaseTests` (25 create/release cycles, no leaked connection); a soak
against `ab_server` has not been run.
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
## Known gaps
- ⚠️ **Native writes are verified against `ab_server` only (simulator-only), never a real controller.** The
real-press gate (Task 21) was **read-only**: it proved native reads, browse and templates on Z-35162
(2026-10-08) and nothing else. Results:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- **libplctag cannot be deleted** until a bench controller allows write verification.
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. Production
presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a
bench controller exists.
- Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages
are paced, and a browse's template reads share one lease. One residual: whether a real controller ever
lists a page out of order is unobserved — the reader is now correct either way. `browsePageDelayMs` has no AdminUI form field; author it in
the driver-config JSON (the form preserves keys it does not model, #576).
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
- Follow-up issues for this workstream: #564 to #577.
- Fixed along the way (pre-existing on master): `ab://host:44818/` with an empty CIP path (Micro800,
unrouted MicroLogix) failed on libplctag with `ErrorBadData` because the adapter sent `Path=""`; it now
sends no path.
## Alarm Projection
`IAlarmSource` is served by `AbCipAlarmProjection`, which polls each subscribed
@@ -119,6 +293,27 @@ integration fixture is down during normal dev (Docker host fixture currently off
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
and the UI authoring flow.
## Native browse: paging, pacing and template leases
Native symbol browse (`transport` = `Native`; libplctag pages inside its own library and is unaffected):
- **Paging continues past the highest instance seen**, not the last entry of a page. The Symbol Object
listing is requested from `start`; the next request starts at `max(instance in the reply) + 1`, so the
order a controller lists a page in does not matter. A "more" reply whose highest instance is still below
its `start` (or equals `uint.MaxValue`) made no progress and fails with `BadReply` ("made no progress")
rather than looping against the controller.
- **Pages are paced.** `browsePageDelayMs` (driver config, default **5**, `0` = back-to-back, negative =
`0`) pauses before every page after the first. 5 ms is small against a page's own round-trip, so a
50k-symbol browse gains a few seconds, but it breaks the back-to-back burst (one request in flight, roughly
a Studio 5000 online browse) into slices the controller's message processing can interleave with. Raise it
for a controller that is tight on CPU; the pause applies per listing (the controller listing and each
program listing).
- **Template reads in a browse share one lease.** `DiscoverAsync` reads the UDT templates (and nested
templates) of a device through a single batch reader (`IAbCipTemplateReaderFactory.CreateBatch`); the
native batch reader holds one device lease from its first read until the browse of that device ends,
instead of leasing per template. `FetchUdtShapeAsync` outside a discovery run is unchanged: one
self-contained lease per call.
## Controller-discovered UDT member variables
When `EnableControllerBrowse` is set, the driver walks the controller's `@tags` symbol table and
@@ -190,7 +385,7 @@ UDT-typed tags found in the walk.
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are still undetectable.
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)). The width-mismatch refusal is pinned by `AbCipTypeMismatchTests.A_width_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once`: `BadTypeMismatch`, handle kept, no `Degraded`, one Warning per tag. *Benign edge (#585):* a driver left `Degraded` by an earlier outage whose only remaining tags are mis-typed gets one extra comms-loss-watchdog reconnect ~90 s later before settling, because a type refusal deliberately writes no health and so never clears the stale `Degraded`.
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
+11
View File
@@ -32,6 +32,17 @@ still the primary coverage. All tests tagged `[Trait("Category", "Unit")]`.
The driver accepts `IAbLegacyTagFactory` via ctor DI; every test
supplies a `FakeAbLegacyTag`.
## Per-transport suites and capture recipe
The `AbLegacy.IntegrationTests` suites now run **once per transport** (`TransportMatrix`: `Libplctag` and
`Native`). Set `AB_LEGACY_COMPOSE_PROFILE` to the running profile. On the 2026-10-07 run native passed
everything libplctag passed on `slc500`, `micrologix` and `plc5` (the PLC-5 test reads N7 only, so the PLC-5
refusal paths and status-bit writes are not exercised by it). Native PCCC is **simulator-only**. Write-capable
tests (the SLC500 N7:5 round trip) require `AB_SERVER_ALLOW_WRITES=1`. Golden frames:
`scripts/ab-capture/capture.sh`, fixtures under
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/`. Evidence:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
## What it actually covers (unit only)
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
+90 -4
View File
@@ -93,10 +93,94 @@ binds from the driver's `DriverConfig` JSON:
- **`Devices`** — one `AbLegacyDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`).
- **`Tags`** — pre-declared `AbLegacyTagDefinition` list (`Name`, `DeviceHostAddress`, `Address`, `DataType`, `Writable`, `WriteIdempotent`).
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeAddress`.
- **`Transport`** — absent (default ⇒ `Libplctag`), `Native` (opt-in), or `Libplctag`; see [Transport](#transport-transport-option).
Full per-field descriptions live in the contracts assembly. The JSON skeleton is
reproduced in [docs/v2/driver-specs.md §4](../v2/driver-specs.md).
## Transport (`transport` option)
Same switch as AB CIP: `transport` = absent \| `Native` \| `Libplctag`, a string (case-insensitive on read,
written as `"Libplctag"` / `"Native"`; "use the default" is encoded by omitting the key). `Native` is a managed
PCCC-over-EtherNet/IP client (`Driver.Ab.Transport`).
**Default resolution** (one place, `AbLegacyTransportResolver`, used by the driver, the Test Connect probe and
the CLI): an absent key resolves to **`Libplctag` for every PLC family** — unchanged from before the native
transport existed. **`Native` is opt-in** (final review I3: on a wire it is proven only for N-file reads and
writes and one F8 read — see [Wire evidence](#wire-evidence-native-pccc)). An explicit value always wins:
explicit `Native` with a PLC-5 / `LogixPccc` device is allowed (an operator choice) and logs one Warning per
driver instance naming the limitation. (AbCip's node-level `Drivers:AbCip:DefaultTransport` has no AbLegacy
counterpart — the AbLegacy default is libplctag and is not node-configurable.)
A change takes effect when the driver instance is constructed: on a deploy that changes `DriverConfig`
(which recreates it) **and on every process start**, including the restart that installs this build (see the
release note). The
AdminUI form's **Transport** dropdown offers "Default (libplctag)" (omits the key on save), "Native (managed,
opt-in)" and "libplctag". The driver logs `transport=<effective> (default|configured)` on every init, e.g.
`transport=Libplctag (default)`. CLI: `--transport default|native|libplctag` (default `default` = libplctag for
every `--plc-type`). See
[AbCip.md §Transport](AbCip.md#transport-transport-option) for the shared behaviour (one pooled connection
per device, Forward Open throttle, routing).
Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses UnconnectedSend and its SLC framing is
**unverified**.
### Release note — native PCCC transport, opt-in (final review I3)
- **The default is unchanged: libplctag.** A driver config with no `transport` key runs `Libplctag`, for
every PLC family, exactly as before this release. The upgrade restart moves nothing. (An interim build on
this branch defaulted SLC 500 / MicroLogix to Native; that was reverted before merge.)
- **`"transport": "Native"` opts a driver into the managed PCCC-over-EtherNet/IP client** (`Driver.Ab.Transport`).
It takes effect when the driver is constructed — a deploy that changes `DriverConfig`, or a process start —
not on `ReinitializeAsync`. The AdminUI **Transport** dropdown authors it; remove the key (or pick
"Default (libplctag)") to go back.
- **Known limits of Native** (why it is opt-in):
- ⚠️ **Wire evidence is narrow and simulator-only** — see [Wire evidence](#wire-evidence-native-pccc). Only
N-file reads and writes and one F8 read have touched a wire (`ab_server`). The **default probe address
`S:0`** and the **B, T, C, R, S, L, ST, A, O and I file types** — and the T/C/R sub-element offsets
(`.PRE`/`.ACC`/`.LEN`/`.POS`) — are encoded from the PCCC file-type table and are **unverified natively**.
No real SLC, MicroLogix or PLC-5 has been exercised. Opt in per driver after checking it against the real
controller.
- ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN`
and the rest return **`BadNotSupported`**; nothing is written to the PLC; the handle is kept and health is
unchanged. libplctag performs a masked bit write here; a native read-modify-write of the control word would
race the PLC updating TT/DN. Reads of status bits are unaffected.
- **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`.
`LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init.
- **ST (string) byte order is unverified** (no capture); string values may be byte-swapped.
- Unlike AbCip, the native AbLegacy runtime has **no element-type check**, so a mis-authored same-width
type reads as reinterpreted data (as on libplctag), not `BadTypeMismatch`.
- Releasing a native tag (evict, reinitialize, shutdown) never blocks on the connection close: the lease
is released in the background (`ConnectionLease.ReleaseInBackground`), the same shape as native AbCip
(#579). A re-create cannot overlap the close, because the pool holds the successor connection behind it.
### Wire evidence (native PCCC)
Everything below is against the `ab_server` simulator; no real PCCC controller has been exercised.
| What | Evidence |
|---|---|
| N-file read (`N7:0`) | libplctag captures `slc500-read-n7`, `micrologix-read-n7`, `plc5-read-n7` (codec byte parity) + native `AbLegacyReadSmokeTests` read on all three profiles |
| N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) |
| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
### Native known gaps
- ⚠️ **Simulator-only, and narrow.** Only N-file reads/writes and one F8 read are on a wire (`ab_server`); see
[Wire evidence](#wire-evidence-native-pccc). No real SLC/MicroLogix/PLC-5 has been exercised; the real-press
gate (Task 21) was read-only and AbCip-only.
- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in
for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init.
- Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the
control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes
above 15 are allowed.
- **ST (string) file layout is uncaptured**, so the string byte order is unverified natively.
- Capture recipe for golden frames: `scripts/ab-capture/capture.sh` (see
[AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md)). Follow-up issues: #564 to #577.
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` cover the driver, the PCCC address parser, and the host-address parser via fake tag runtimes.
@@ -109,14 +193,16 @@ An AB Legacy tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON c
`"isArray": true` and `"arrayLength": N` (N ≥ 1, capped at 256). The canonical rule:
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
**Read mechanism** — the driver issues a **PCCC multi-element file read** via libplctag.
The tag's file address (e.g. `N7:0`) is extended by `N` element count so libplctag fetches
**Read mechanism** — the driver issues a **PCCC multi-element file read** (libplctag by default; the opt-in
native transport issues the same typed read, chunked under the PCCC payload limit).
The tag's file address (e.g. `N7:0`) is extended by `N` element count so the transport fetches
`N` consecutive PCCC file elements in one EIP/PCCC request. The response is decoded into
N values of the declared `AbLegacyDataType`. Maximum element count is 256 (PCCC payload
limit).
This closes the previously-noted limitation ("single-element addressing today") for the
array read path. Array *writes* remain a follow-up.
array read path. The opt-in native transport also writes multi-element arrays (chunked); on libplctag
array *writes* remain a follow-up.
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` covers
the multi-element read path via the fake tag runtime. The AB Legacy integration fixture is
@@ -124,7 +210,7 @@ down during normal dev.
**Live-verify** — integration-fixture-gated (requires the AB Legacy Docker fixture).
**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization.
**Deferrals** — array *writes* on libplctag (the opt-in native transport writes them), multi-dimensional arrays, per-element historization.
`arrayLength > 256` is rejected at init with an explicit error (PCCC payload cap).
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
+32 -9
View File
@@ -33,6 +33,29 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
- **Endpoint override**: `AB_SERVER_ENDPOINT=host:port` points the
fixture at a real PLC instead of the local container.
## Per-transport suites and capture recipe
Every integration test in `AbCip.IntegrationTests` now runs **once per transport** (`TransportMatrix`:
`Libplctag` and `Native`). Run one compose profile at a time and name it with `AB_SERVER_COMPOSE_PROFILE`
(all profiles bind `:44818`, so set it to the running profile). On the 2026-10-07 run native passed everything
libplctag passed on all seven profiles; on `micro800` libplctag failed on the empty-path defect described in
[AbCip.md](AbCip.md#known-gaps) and native passed. That defect is now fixed: a re-run of the `micro800`
smoke test passes on both transports. Evidence:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- Write-capable tests (the Logix Emulate ALMD test) require `AB_SERVER_ALLOW_WRITES=1` and refuse while
`AB_DIFF_ENDPOINT` is set.
- **Differential harness** (`AbCip.IntegrationTests/Differential`, `LiveIntegration`) compares libplctag and
native on browse, template and read parity against one endpoint. It is read-only by construction, aborts on
the first connection failure, never retries, and caps the native phase at one Forward Open. Env:
`AB_DIFF_ENDPOINT`, `AB_DIFF_PATH`, `AB_DIFF_FAMILY`, `AB_DIFF_TAGS`, `AB_DIFF_MAX_TAGS` (at most 500),
`AB_DIFF_MAX_TEMPLATES`, `AB_DIFF_PACE_MS` (default 20), `AB_DIFF_COUNT_OPENS`. Always run with
`--filter "FullyQualifiedName~Differential"` and `AB_SERVER_ENDPOINT=127.0.0.1:1`.
- ab_server serves no UDTs and refuses 1-byte writes, so template parity is vacuous there and BOOL/SINT
parity is weak evidence. Native writes remain simulator-only.
- **Golden-frame capture:** `scripts/ab-capture/capture.sh` (capture client in `scripts/ab-capture/client`);
fixtures live in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/` with a README.
## What it actually covers
- Read path: driver → libplctag → CIP-over-EtherNet/IP → simulator → back.
@@ -93,14 +116,14 @@ Only the `_S`-suffix naming classifier (PR 12, `SecurityClassification.ViewOnly`
forced on safety tags) runs. Actual safety-partition write rejection — what
happens when a non-safety write lands on a real `1756-L8xS` — is not exercised.
### 5. CompactLogix narrow ConnectionSize cap
### 5. CompactLogix connection size and the regular-open fallback
CompactLogix profile `Notes`: *"ab_server lacks the narrower limit itself."*
Driver-side `AbCipPlcFamilyProfile` caps `ConnectionSize` at the CompactLogix
value per PR 10, but `ab_server` accepts whatever the client asks for — the
cap's correctness is trusted from its unit test, never stressed against a
simulator that rejects oversized requests.
Since T21 the CompactLogix profile defaults to a **Large** Forward Open (4002), matching what libplctag
opens for `plc=compactlogix`. The old 504 default was cosmetic under libplctag, which never received it,
and cost the native transport ~3× the round trips per poll. A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses
the Large open is handled by `CipConnection`'s fallback (a regular open at min(size, 504) in the same
throttled attempt). That fallback is pinned by unit tests against a fake target only: `ab_server` accepts
any size, so a refusing controller is never exercised here.
### 6. BOOL-within-DINT read-modify-write (#181)
@@ -145,7 +168,7 @@ skip cleanly + the ab_server Docker fixture runs as usual.
| `@tags` Symbol Object walk with `Program:` scope | partial | **yes** | yes |
| Add-On Instructions | no | **yes** | yes |
| GuardLogix safety-partition write rejection | no | **yes** (Emulate 5580) | yes |
| CompactLogix narrow ConnectionSize enforcement | no | **yes** (5370 firmware) | yes |
| CompactLogix Large-open refusal → regular-open fallback | no | **yes** (5370 firmware) | yes |
| EtherNet/IP embedded-switch behaviour | no | no | yes |
| Redundant chassis failover (1756-RM) | no | no | yes |
| Motion control timing | no | no | yes |
@@ -185,7 +208,7 @@ project is authored.
| "Do ALMD alarms raise + clear?" | no | yes | **yes** | yes |
| "Is Micro800 unconnected-only enforced wire-side?" | partial (--plc=Micro800 boots, but wire rejection untested) | partial | yes | yes (required) |
| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (Emulate 5580) | yes |
| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (5370 firmware) | yes |
| "Does CompactLogix refuse a Large Forward Open?" | no | partial | yes (5370 firmware) | yes |
| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | partial | yes (stress) |
| "Does EtherNet/IP embedded-switch behave correctly?" | no | no | no | yes (required) |
| "Does redundant-chassis failover work?" | no | no | no | yes (required) |
@@ -0,0 +1,265 @@
# Native .NET transport for AbCip + AbLegacy (replace libplctag) — design
**Date:** 2026-10-07 · **Branch:** `feat/native-ab-transport` · **Status:** design, awaiting review
## 1. Goal
Replace the `libplctag` NuGet (managed wrapper + native `plctag` binary, pinned `1.5.2`) under the
AbCip and AbLegacy drivers with a fully managed EtherNet/IP + CIP + PCCC client owned by this repo.
libplctag is kept, behind a switch, as a fallback until the native path is proven.
### Why (all four apply)
1. **Native-binary pain** — RID-specific packaging, container/ARM images, an unmanaged dependency in
an otherwise all-.NET 10 server.
2. **Correctness and control** — wrapper defects we cannot fix: `ElementSize` returns `null` after a
read (`68cf3ae8`), there is no `ConnectionSize` property (the value is plumbed and dropped),
status codes are opaque, and libplctag never checks the CIP type the controller returns (`e3d6b76e`
could only add a width check).
3. **Performance** — one native tag handle per `(device, tag)`; no control over request packing or
scheduling.
4. **Policy** — no native dependencies; libplctag's LGPL/MPL licensing.
### Non-goals
- No change to the driver surface above the transport seams: the address space, tag-config JSON,
the poll engine, the RMW serialization, the backoff, and the decoders all stay.
- No new PLC families. No CIP Class-1 (implicit I/O) connections. No Omron (`omron-njnx` stays an
unmapped libplctag value only while the fallback exists).
- Whole-UDT **writes** stay unsupported, as they are today.
## 2. Decisions (all taken at the recommended option)
| # | Question | Decision |
|---|---|---|
| D1 | Build or buy | **Build** an in-repo managed stack. Existing managed CIP libraries fail the licensing/control goals. |
| D2 | Cutover | **Side-by-side behind a per-driver `Transport` switch** (`Native` \| `Libplctag`), the same dark-switch discipline as `MeshTransport` / `ConfigSource`. |
| D3 | Proof standard | **Flip per evidence tier** (§8). Native becomes default for AbCip once reads/browse/templates pass the real presses read-only; AbCip writes and all of AbLegacy flip on simulator proof, recorded as a known gap. libplctag stays as the fallback until a bench controller exists. |
| D4 | Where the stack lives | A new shared project, **`ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport`**, referenced by both drivers (precedent: `Mqtt.Transport`, `OpcUaClient.Session`). Pure protocol: no OtOpcUa, no OPC UA, no logging-framework dependency beyond `Microsoft.Extensions.Logging.Abstractions`. |
| D5 | Connection model | **One CIP connection per device** (`host:port` + CIP path), shared by every tag runtime on that device. libplctag already shares internally; the native stack makes it explicit. |
| D6 | Request packing | **Transparent coalescing inside the connection** into Multiple Service Packet (0x0A) requests, within the negotiated connection size, when `AllowPacking`. The `IAbCipTagRuntime` interface does not change. |
| D7 | Status neutrality | A **driver-owned `AbStatus` enum + `AbTransportException`** replace libplctag's `Status` / `LibPlcTagException` in the mappers, probes and `AbCipDriver`. The libplctag adapter translates into them. |
| D8 | Decoder reuse | The native enumerator and template reader **emit the same byte layout libplctag's `@tags` / `@udt/{id}` pseudo-tags return**, so `CipSymbolObjectDecoder` / `CipTemplateObjectDecoder` and their real-capture tests are shared unchanged, and the two transports can be diffed byte for byte. |
| D9 | Primary correctness evidence | **Differential testing**: the same reads through both transports against the same target (`ab_server`, then the real presses read-only), compared on raw buffer and decoded value. |
## 3. Architecture
```
AbCipDriver / AbLegacyDriver (unchanged except factory selection + AbStatus)
│ IAbCipTagFactory / IAbCipTagEnumeratorFactory / IAbCipTemplateReaderFactory / IAbLegacyTagFactory
├── Libplctag* adapters (existing; now translate Status → AbStatus) ← fallback
└── Native* adapters (new, in each driver project)
│
▼
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport (new, pure managed)
Eip/ EncapsulationClient — TCP framing, RegisterSession/UnRegisterSession,
ListIdentity, SendRRData (UCMM), SendUnitData (connected)
Cip/ CipPath (port segments from "1,0", ANSI symbolic 0x91, member/element
segments 0x28/0x29/0x2A), CipService, CipGeneralStatus + extended status
Cip/ ConnectionManager — Forward Open (0x54) / Large Forward Open (0x5B) /
Forward Close (0x4E), sequence counts, connection-size negotiation,
UCMM-only mode (Micro800)
Cip/ RequestScheduler — per-connection queue, MSP coalescing, in-flight bound,
per-request timeout + cancellation
Logix/ Read Tag (0x4C) / Read Tag Fragmented (0x52) / Write Tag (0x4D) /
Write Tag Fragmented (0x53); Logix type codes incl. struct handle (0xA0)
Logix/ SymbolLister (Get Instance Attribute List 0x55 on class 0x6B, paged)
Logix/ TemplateReader (Get Attribute List 0x03 + Read 0x4C on class 0x6C, chunked)
Pccc/ Execute PCCC (0x4B, class 0x67) with requestor id; SLC/MicroLogix protected
typed logical read/write (0x0F/0xA2, 0x0F/0xAA); PLC-5 typed read/write
(0x0F/0x68, 0x0F/0x67); PCCC STS/EXT STS
DeviceSessionPool — one session+connection per device key, ref-counted
```
### 3.1 Units and their contracts
- **`EncapsulationClient`**: owns one TCP socket. It sends and receives 24-byte-header encapsulation
frames and correlates replies by sender context. It knows nothing about CIP. Testable against canned
byte streams.
- **`CipPath`**: a pure encoder. It turns `"1,0"` (and `"1,0,2,10.0.0.5"` style routes) into port
segments, and turns a Logix tag name such as `Program:P.Arr[3].Member.5` into symbolic, element and
member segments. `AbCipTagPath` already parses names; `CipPath` encodes them. Golden-byte unit tests.
- **`ConnectionManager`**: opens and closes the connected session for a device.
- **Connection size.** It honours `ConnectionSize` (the field libplctag drops today). Above 511
bytes it uses Large Forward Open. If the target rejects a large open (status 0x01, extended 0x0109
or 0x0111), it falls back to a regular Forward Open at the size the target advertises.
- **Micro800** (`SupportsConnectedMessaging = false`) runs UCMM-only: no Forward Open.
- **`RequestScheduler`**: one queue per connection.
- **Coalescing.** Requests that arrive within one scheduling turn are packed into an MSP until the
next service would overflow the connection size. A reply is fanned back to each waiter with its own
per-service status.
- **Oversize requests.** A request too large for one frame goes through the fragmented service loop.
- **Bounds.** At most one in-flight connected request per connection by default (`MaxInFlight`,
default 1), the conservative choice for a production press.
- **`DeviceSessionPool`**: keyed by `(host, port, cipPath, familyAttribute)`. A runtime acquires a
lease on first `InitializeAsync` and releases it on `Dispose`. The last release sends Forward Close
and UnRegisterSession.
### 3.2 Native adapters (in the driver projects)
- **`NativeAbCipTagRuntime : IAbCipTagRuntime`**
- **Reads.** `InitializeAsync` acquires the device lease and resolves the encoded path once.
`ReadAsync` issues Read Tag with `ElementCount`. The reply's **CIP type code + struct handle** is
kept with the buffer.
- **Decoding.** `Decode*` reads the managed buffer, little-endian. Strings use the Logix
`LEN(u32) + DATA[n]` layout with the stride taken from the reply. BOOL arrays are 32-bit packed.
- **Size and status.** `GetSize()` is the real reply length, so the stride fallback in
`ResolveArrayStride` stops being necessary. `GetStatus()` returns `AbStatus`.
- **Writes.** `WriteAsync` issues Write Tag with the type taken from the last read, or from the
authored type for atomics. A struct-typed write (STRING) needs the struct handle, so a write with
no prior read performs one read first.
- **`NativeAbCipTagEnumerator`** reads controller-scope and `Program:X` listings through
`SymbolLister`, and emits the concatenated entry bytes in the `@tags` layout.
- **`NativeAbCipTemplateReader`** emits the 14-byte header that libplctag synthesizes, then the raw
template bytes (D8).
- **`NativeAbLegacyTagRuntime : IAbLegacyTagRuntime`**
- **Addressing.** Encodes `AbLegacyAddress` (file type, file number, element, sub-element) into PCCC
typed-logical addressing, or PLC-5 system addressing for `plc5`.
- **Arrays.** Multi-element reads use the element count.
- **Bits.** Bit-in-word writes stay at the driver level (`WriteBitInWordAsync`), as today.
### 3.3 The switch
- `AbCipDriverOptions.Transport` and `AbLegacyDriverOptions.Transport` take values
`Native | Libplctag`, are serialized as **strings** (`JsonStringEnumConverter`, per the
driver-enum-serialization trap), and are absent ⇒ the current default.
- `Ab*DriverFactoryExtensions.CreateInstance` picks the factory set from the option. Test-injected
factories still win.
- The **probe** (`Ab*DriverProbe`, i.e. Test Connect) honours the same option. A probe that tests a
different transport than the driver runs would be a lie.
- **AdminUI**: a `Transport` dropdown in the AbCip and AbLegacy driver-config modals under the `/raw`
tree. It needs live `/run` verification on docker-dev, since there is no bUnit.
- Default sequence: `Libplctag` at merge, flipped per §8, and the fallback stays selectable.
## 4. Data flow (a poll read)
1. The driver poll calls `runtime.ReadAsync(ct)` on N runtimes for one device. The concurrency
window is unchanged.
2. Each runtime enqueues a Read Tag request on the device's `RequestScheduler`.
3. The scheduler packs as many as fit into one MSP, sends it via SendUnitData on the shared
connection, awaits the reply, splits it by service, and completes each waiter with
`(status, typeCode, structHandle, payload)`.
4. The runtime stores the payload. The driver calls `Decode*` exactly as it does today.
The result: N round-trips through N libplctag handles become ⌈N / fit⌉ round-trips through one
connection. That is the performance win, with no driver change.
## 5. Error handling and status
- **`AbStatus`** (driver-owned) carries the values the mappers and probes use today: `Ok`,
`Pending`, `Timeout`, `NotFound`, `NotAllowed`, `OutOfBounds`, `TooLarge`, `TooSmall`,
`Unsupported`, `BadConnection`, `BadReply`, `Partial`, `Abort`, plus a new `TypeMismatch`.
- **CIP general status mapping (native):**
- `0x00` ⇒ `Ok`
- `0x04` / `0x05` (path segment / destination unknown) ⇒ `NotFound`
- `0x06` ⇒ `Partial` (continue fragmenting)
- `0x08` ⇒ `Unsupported`
- `0x0F` ⇒ `NotAllowed`
- `0x13` ⇒ `TooSmall`
- `0x15` ⇒ `TooLarge`
- `0xFF` ⇒ by extended status (`0x2105` ⇒ `OutOfBounds`, `0x2107` ⇒ `TypeMismatch`)
- socket, encapsulation or timeout faults ⇒ `BadConnection` / `Timeout`
- **PCCC mapping.** STS/EXT STS map the same way: address-does-not-exist ⇒ `NotFound`, illegal
command/format ⇒ `Unsupported`, and so on.
- **`AbTransportException(AbStatus, string detail)`** is thrown by both transports.
`AbCipDriver.cs:1188`'s "the controller answered, so don't throttle the device" check
(`IsReachableStatus`) keys on it. **That preserves 05/STAB-8 semantics.**
- **Type check, native only.** The reply's CIP type code is compared with the authored type.
- A mismatch ⇒ `BadTypeMismatch`. This now also catches **same-width** mistakes (DINT authored,
REAL returned) that `e3d6b76e` documents as undetectable.
- ⚠️ **This is client-visible.** Tags authored wrong from the legacy Kepware export that read Good
garbage under libplctag will read Bad under native. That is the fix working, but it must be
in the release note and in `docs/drivers/AbCip.md`.
- **Press safety.**
- **Reconnect.** A dropped connection is re-opened through the driver's existing `device.Backoff`,
never in a tight loop.
- **One open at a time.** `ConnectionManager` enforces at most one Forward Open in flight per
device, plus a minimum interval between opens (default 1 s).
- **Pinned by a test.** A regression test asserts a bounded Forward Open rate against a refusing
target. ignitionoee recorded **~15 Forward Opens/s** at a press from a misconfigured client.
- **Close.** Forward Close on dispose. The connection timeout multiplier and RPI are set so a dead
client's connection expires on the PLC within seconds, not minutes.
## 6. Packaging
- New project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/` and its test project
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/`, both added to
`ZB.MOM.WW.OtOpcUa.slnx`.
- While the fallback exists, both drivers keep the `libplctag` package. Libplctag code is confined
to the `Libplctag*.cs` adapters. After phase 2 no other file has `using libplctag`, and a
source-scan guard test enforces that.
- On deletion (phase 6) the package, its `Directory.Packages.props` pin, the adapters and the
`Libplctag*` create-param members (`LibplctagGateway`; `LibplctagPlcAttribute` is renamed
`PlcAttribute`) go.
## 7. Testing
1. **Golden-frame unit tests** (`Ab.Transport.Tests`).
- **Encode** every frame type: RegisterSession, Forward Open / Large / Close, MSP, Read / Write
(Fragmented), 0x55 listing, template attribute reads, Execute PCCC.
- **Decode** every reply type, including error replies.
- **Fixtures** are byte captures taken once from libplctag talking to `ab_server` (pcap, stored as
hex), plus the **existing real-press captures** in `CipSymbolObjectWireCaptureTests` and
`CipTemplateObjectWireCaptureTests`.
- ⚠️ A capture from libplctag ⇄ `ab_server` proves wire parity with libplctag, not correctness. The
real-press captures are the independent reference (the #468 lesson: verify a fixture with the
reference client).
2. **Scheduler and connection tests.** These run against an in-process fake encapsulation endpoint.
They cover:
- MSP packing boundaries at connection size −1, exact and +1
- per-service error fan-out
- cancellation mid-MSP
- the Forward Open rate bound
- large-open fallback
- lease ref-counting and Forward Close on last release
3. **Integration suites run on both transports.** The existing `AbCip.IntegrationTests` and
`AbLegacy.IntegrationTests` against `ab_server` (controllogix / compactlogix / micro800 / guardlogix;
slc500 / micrologix / plc5) are parameterized over `Transport`. A native run must be green on every
test the libplctag run is green on.
4. **Differential harness.** A `LiveIntegration`-category test, env-gated like the HistorianGateway
live suite and skipped when the env vars are absent. It reads the same tag set through both
transports and asserts equal raw buffers and equal decoded values:
- against `ab_server`;
- against a real press, **read-only**. It runs browse (`@tags` + every program listing), every
template, and a sample of scalar, array, STRING and UDT-member reads.
5. **AdminUI `/run`.** Author `Transport: Native` on a docker-dev AbCip driver pointed at the
`ab_server` fixture, deploy, read, then flip it back.
## 8. Phased delivery and gates
| Phase | Delivers | Gate |
|---|---|---|
| **P0 Seam** | `AbStatus` + `AbTransportException`; mappers, probes, `AbCipDriver` and `AbLegacyDriver` decoupled from libplctag types; the 9 unit-test files that build `LibPlcTagException` / `Status` fakes (`AbCipDriverWriteTests`, `AbCipDriverProbeTests`, `AbCipDriverReadTests`, `AbCipStatusMapperTests`, `AbCipDriverCodeReviewRegressionTests`, `AbCipUnknownTagReadTests`, `AbLegacyEvictOnFailureTests`, `AbLegacyReadWriteTests`, `AbLegacyHostAndStatusTests`) moved onto `AbStatus` / `AbTransportException`, with the libplctag translation itself tested in the adapters; the `Transport` option (string enum) + factory selection, including a `--transport` flag on `otopcua-abcip-cli` / `otopcua-ablegacy-cli` (whose help text names libplctag); the source-scan guard | Existing suites green; behaviour identical (default `Libplctag`) |
| **P1 Transport core** | Encapsulation, `CipPath`, `ConnectionManager`, `RequestScheduler` (MSP + fragmentation), `DeviceSessionPool` | Golden-frame + scheduler tests |
| **P2 AbCip native** | `NativeAbCipTagRuntime`, enumerator, template reader; CIP type check | AbCip integration suite green on both transports vs `ab_server` |
| **P3 AbLegacy native** | PCCC layer + `NativeAbLegacyTagRuntime` | AbLegacy integration suite green on both transports vs `ab_server` (slc500 / micrologix / plc5) |
| **P4 Switch surface** | AdminUI dropdown, probe honours the switch, docs (`AbCip.md`, `AbLegacy.md`, fixture docs) | docker-dev `/run` |
| **P5 Real-hardware gate + flip** | Differential run against the CR presses read-only (VPN from the Mac); a perf comparison (round-trips + p50/p95 poll latency for one press's tag set, native vs libplctag); then **default ⇒ `Native` for both drivers** | AbCip: diff clean on browse / templates / reads, no worse latency, Forward Open count during the run = 1. AbLegacy: simulator proof only, **recorded as a known gap** |
| **P6 Delete libplctag** *(conditional)* | Remove the package, adapters and `Libplctag*` members | **Blocked until a bench controller** proves writes (CompactLogix or Micro800) and PCCC (SLC / MicroLogix). Until then libplctag stays selectable |
### Real-hardware constraints (from `~/Desktop/ignitionoee`)
- **Reachable targets:** 20 × CompactLogix **5069-L320ERS2 rev 34.11** presses at CR
(Z-35162..Z-35181; Z-35162 = `10.209.70.160:44818`), reachable from the Mac over VPN. This is
the same rig `cc876bdf` verified against.
- **They are production machines and strictly read-only.** P5 performs **no writes**, opens a
single connection per press, and touches one press at a time.
- **No legacy PCCC hardware is reachable.** The CR presses were confirmed non-MicroLogix by
ListIdentity. The Delmia inventory lists PLC-5/04s at Warsaw with no addresses, and as far as I
know a PLC-5/04 has no built-in Ethernet port. There is no ControlLogix and no Micro800 either. So
routed paths beyond `1,0` and Micro800 UCMM-only mode are simulator-proven only.
## 9. Known gaps and risks
- **AbCip writes are simulator-proven only.** Mitigation: Write Tag shares framing with the
read-proven path. The risk sits in the encoding details (STRING struct handle, type codes), which
have golden tests and run against `ab_server`. libplctag stays selectable.
- **AbLegacy is simulator-proven only**, against `ab_server`'s PCCC emulation, which shares
libplctag's view of the protocol. This is the weakest evidence in the plan, stated plainly.
- **`ab_server` patches** (`0001` symbol listing, `0002` CIP 0x04 for not-found) mean the simulator is
already partly ours. A native/libplctag disagreement against it must be checked against the real
press before blaming either side.
- **The same-width type check changes client-visible quality** (§5).
- **Behaviour libplctag hides that we now own:** connection keep-alive, sequence-count wrap, the
session-registration lifetime, and the TCP reconnect after an idle teardown by a NAT or firewall.
Each one gets a scheduler/connection test.
@@ -0,0 +1,479 @@
# Native AB transport gate results
## ab_server suites (T17)
Every ab_server integration test now runs once per transport (`TransportMatrix` in each integration
project). Run 2026-10-07 against `10.100.0.35`, one compose profile at a time
(AbCip `/opt/otopcua-abcip`, `AB_SERVER_COMPOSE_PROFILE=<p>`; AbLegacy `~/otopcua-ablegacy`,
`AB_LEGACY_COMPOSE_PROFILE=<p>`); every stack was brought down afterwards.
- **Image:** `otopcua-ab-server:libplctag-release` (`sha256:19e814747d85…`, built 2026-08-01; ab_server from
libplctag `v2.7.0` + patches 0001/0002). Host compose files md5-identical to the repo's.
- **Commit:** fixes `82d011c7` + `2171fc8c`, test matrix `8c68694d`.
Counts are pass / fail / skip for the transport-parameterised rows only. Each AbCip run also passed the
6 transport-independent `AbServerProfileTests`. The AbCip skips are the 3 Emulate-tier tests
(`AB_SERVER_PROFILE=emulate` only). The AbLegacy skips are `Slc500_write_then_read…`, which skips itself
on both transports when the slc500 port (`:44819`) is down.
| Profile | Libplctag | Native | Native ⊇ Libplctag |
|---|---|---|---|
| controllogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
| compactlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
| micro800 | 0 / 1 / 3 | 1 / 0 / 3 | yes (libplctag fails, see below) |
| guardlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
| slc500 | 2 / 0 / 0 | 2 / 0 / 0 | yes |
| micrologix | 1 / 0 / 1 | 1 / 0 / 1 | yes |
| plc5 | 1 / 0 / 1 | 1 / 0 / 1 | yes |
**Native-only failures found and fixed:**
- `82d011c7` (controllogix): RegisterSession carried a non-zero sender context, and ab_server rejects that
with encapsulation status 1. Every native session failed.
- `2171fc8c` (slc500): ab_server answers Execute PCCC with a fixed requestor id
(`07 3D F3 45 43 50 21`), so the requestor-echo check rejected every native PCCC reply. Native now sends
libplctag's identity.
**Skips added:** none. The PLC-5 integration test only reads N7, so the PLC-5 Unsupported paths
(non-N reads, all writes) and status-bit sub-element writes are never exercised by the suite.
**micro800:** the smoke test used to send `/1,0` for every family, and both transports failed against
`--plc=Micro800`. It now uses an empty path for Micro800. With the empty path, native passes. libplctag
still fails with BadCommunicationError, because the driver's libplctag runtime passes `Path = ""` and
libplctag answers that with `ErrorBadData`. A `null` path works (checked with the capture client). That
defect is pre-existing and libplctag-side; it is recorded, not fixed, here.
## Differential vs ab_server (T18)
Run 2026-10-07 on branch `feat/native-ab-transport`, harness
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/`, against `ab_server`
(`controllogix` profile only, `/opt/otopcua-abcip` on 10.100.0.35):
```bash
AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
--filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
```
Target as the harness resolved it: `10.100.0.35:44818`, path `1,0`, family `CompactLogix`
(the defaults; libplctag attribute `compactlogix`, connection size 504).
**Result: 7/7 passed, 0 unexpected divergences, 0 expected divergences.**
| Check | Result |
|---|---|
| Browse parity (`@tags` + program listings) | 9 tags via libplctag, 9 via native, identical as multisets |
| Template parity | vacuous: ab_server serves no UDTs, so 0 template ids. Task 21's press is the first real exercise |
| Read parity | 9/9 tags equal on status, raw buffer and decoded value |
| Native Forward Opens over the run (`AB_DIFF_COUNT_OPENS=1`) | **1** |
| TCP connections to the target before the native phase | 0 (baseline 0). libplctag's session closed within 1 ms of the last handle being disposed |
| Read-only guard (offline, runs without the endpoint) | pass |
| Comparison falsifiability (offline) | pass |
| Tag | Type | libplctag status | native status | Equal? |
|---|---|---|---|---|
| `Motor_Run` | Bool | Ok | Ok | yes |
| `Line1_Count` | DInt | Ok | Ok | yes |
| `Pump_1_Speed` | Real | Ok | Ok | yes |
| `TestString` | String | Ok | Ok | yes |
| `TestSINT` | SInt | Ok | Ok | yes |
| `TestBOOL` | Bool | Ok | Ok | yes |
| `TestREAL` | Real | Ok | Ok | yes |
| `TestDINT` | DInt | Ok | Ok | yes |
| `TestArray` | DInt[16] | Ok | Ok | yes |
**The values were made non-zero first.** An all-zero simulator would make raw and value equality
vacuous. Before the run, the simulator was seeded through a throwaway libplctag scratch program
outside the repo. The harness itself never writes. Seeded values:
- `TestDINT` = −123456789
- `TestREAL` = 3.14159
- `Pump_1_Speed` = 1234.5
- `Line1_Count` = 987654
- `TestArray[i]` = (i−8)·100003+7, which includes negatives
- `TestString` = `"Hello T18"`
ab_server refuses 1-byte writes (`ErrorBadParam`), so `TestBOOL`, `TestSINT` and `Motor_Run` stay 0.
Their parity is therefore weaker evidence.
**Wire capture of the same run.** tcpdump ran in the ab_server container's network namespace
(`nicolaka/netshoot`), and the capture was decoded with tshark:
| TCP stream | Session | RegisterSession | Forward Open | Forward Close | Requests | Window (s, relative) |
|---|---|---|---|---|---|---|
| 12 | libplctag | 1 | 1 (Large, 0x5B) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.243 → 0.336 |
| 13 | native | 1 | 1 (0x54) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.349 → 0.452 |
- Exactly two sessions, with no overlap: a 13 ms gap between them.
- Zero write services on the wire: no 0x4D, 0x53 or 0x10.
- The 12 earlier sub-10 ms TCP connections are the `AbServerFact`/`AbServerTheory` reachability probes. xUnit runs them during discovery for the *other* suites, even under the filter. They target `AB_SERVER_ENDPOINT` (default 10.100.0.35), never `AB_DIFF_ENDPOINT`.
**Explicit-list path.** `AB_DIFF_TAGS="TestDINT,NoSuchTag,TestArray,TestString"`: 4/4 equal.
`NoSuchTag` returned `NotFound` under both transports, and there was still exactly one native Forward Open.
**Transport fixes found by the differential: none.** One harness defect was found and fixed before
this result. libplctag closes a session as soon as its last handle is destroyed. The first version of the
libplctag phase created, read and disposed one handle at a time, so it opened a fresh session (and a
fresh Forward Open) per tag: 9+ sessions, which the netstat sampling showed. The libplctag phase now holds an anchor handle
on `@tags` for its whole duration, which mirrors the native anchor lease. That leaves the one-session
capture above.
### Review round 1 re-run (press-safety fixes)
Re-run 2026-10-07 after the review-r1 fixes, with the default pacing. `AB_SERVER_ENDPOINT=127.0.0.1:1`
kept the sibling suites' discovery probes off the network:
```bash
AB_SERVER_ENDPOINT=127.0.0.1:1 AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
--filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
```
**Result: 12/12 passed — 7 live tests and 5 offline safety tests — with 0 unexpected and 0 expected divergences.**
| Check | Result |
|---|---|
| Settings | pace 20 ms, max tags 200, max templates 50 |
| Browse | 9 = 9 |
| Selection (array round-robin first) | 1 array + 8 scalars. ab_server has no UDTs, so there were no UDT members and the coverage check had nothing to require |
| Reads | 9/9 equal |
| Templates | 0 ids, not truncated. Not vacuous by the new rule: ab_server's `STRING` is not a structure, so browse found no structured tags |
| Native Forward Opens | **1** |
| TCP connections to the target before native | 0 (baseline 0) |
**Wire capture of this run** (tcpdump in the ab_server container's namespace, decoded with tshark):
| TCP stream | Session | Forward Open | Forward Close | Requests | Window (s) |
|---|---|---|---|---|---|
| 0 | libplctag | 1 (Large, 0x5B) | 1 | 1× 0x55, 18× 0x52 | 0.000 → 0.487 |
| 1 | native | 1 (0x54) | 1 | 1× 0x55, 18× 0x52 | 0.510 → 0.984 |
- No discovery probes.
- Zero write services: no 0x4D, 0x53, 0x4B or 0x10.
- The two sessions are 23 ms apart and do not overlap.
**The Forward Opens differ between the transports, and that is expected.** libplctag always uses a *Large*
Forward Open. Native uses a regular one because the `CompactLogix` profile's connection size (504) is at most 511. Task 21 will
see the same split on the press.
**What changed for press safety (review r1):**
- **Stop on the first connection-level failure, on either transport.** That covers a refused connect,
RegisterSession or Forward Open, a timeout, and a lost session. Any status other than
NotFound/NotAllowed/OutOfBounds/TooLarge/TooSmall/Unsupported/TypeMismatch aborts the whole run with the
failing operation and status. Nothing is retried.
- **The native phase is capped at one open attempt.** It sets the new process-wide
`CipConnection.OpenAttemptBudget`, so a reconnect cannot reach the wire at all, even from inside an
enumerator that swallows a per-program failure.
- **The run also aborts the moment `ForwardOpenCount` passes 1.** This is checked after every browse entry,
template and tag.
- **Offline pin:** against a loopback listener that accepts and hangs up, the native phase aborts at browse after exactly
**one** TCP connect. Removing the harness abort turns the test red. Removing both the abort and the budget
turns the connect count red.
- **Pacing:** `AB_DIFF_PACE_MS` (default 20) is applied before every harness-issued request: browse,
each template read, each tag's create-read and read. Listing *pages* inside the enumerators are not
paced.
- **Volume caps:** `AB_DIFF_MAX_TEMPLATES` defaults to 50. `AB_DIFF_MAX_TAGS` and `AB_DIFF_MAX_TEMPLATES` must be at most 500;
a higher value fails fast instead of being clamped.
- **Selection:** the default selection takes arrays (up to 20), UDT members (up to 20) and scalars round-robin, so any cap
of 3 or more exercises all three kinds. The reads test fails if browse offered arrays or UDT members
but the selection holds none.
- **Template parity** fails when browse found structured tags but no template id was compared, and treats an
undecodable shape on either side as a mismatch.
- **Write-capable integration tests** now require `AB_SERVER_ALLOW_WRITES=1` and refuse while
`AB_DIFF_ENDPOINT` is set. Those tests are the Logix Emulate ALMD test and the AbLegacy SLC500 N7:5 round trip.
The ab_server controllogix matrix is read-only and unaffected: 15 passed and 11 skipped with
`AB_SERVER_COMPOSE_PROFILE=controllogix`.
### Latency harness proven on ab_server (T21 Step 1)
`Differential/PollLatencyComparisonTests.cs` (+ `PollLatencyRun.cs`) ran 2026-10-07 against ab_server
(`controllogix` image, host port 44828 because an unrelated outbound socket held 44818 on the docker host),
under the same `--filter "FullyQualifiedName~Differential"` invocation as the differential. The mechanics work:
the timed phase reuses the differential's browse, the anchors, the open budget and the barriers; **1** native Forward Open
in the latency phase; 30 passes per transport, 0 tag failures; native packs 100 concurrent reads into **6** requests per pass.
ab_server has 7 atomic scalars, so the 100-tag set was **repeated cyclically** (reported in the output).
| Family | libplctag p50 / p95 ms | native p50 / p95 ms | native requests/pass | Criterion |
|---|---|---|---|---|
| CompactLogix (504, the press default) | 7.7 / 10.9 | 22.1 / 25.3 | 6 | **FAIL** |
| ControlLogix (4002) | 7.3 / 9.1 | 7.7 / 9.2 | 2 | pass |
The CompactLogix result was a real finding, not a harness fault. Native opened a 504-byte connection there, while libplctag
opened a Large one (see the T18 capture above), so native needed 3× the round trips per pass.
**Fixed:** the CompactLogix profile now defaults to 4002, so native opens Large like libplctag. A refusal still falls back to
a regular 504 open. Re-run 2026-10-07 23:30–23:32Z against the `compactlogix` compose profile on :44818 (3 distinct atomic
tags, repeated to 100), with a capture in the container's network namespace:
| Run | libplctag p50 / p95 ms | native p50 / p95 ms | native req/pass | Criterion |
|---|---|---|---|---|
| 1 | — | — | — | aborted in the latency libplctag phase (see below) |
| 2 | 7.6 / 9.6 | 7.5 / 10.8 | 2 | FAIL (limit 10.6) |
| 3 | 7.8 / 9.3 | 8.1 / 9.4 | 2 | PASS (limit 10.2) |
| 4 | 7.7 / 10.0 | 7.8 / 11.7 | 2 | FAIL (limit 11.1) |
| 5 | — | — | — | aborted in the latency libplctag phase |
The structural gap is gone: p50s are equal, and both transports now send 2 requests per pass. At ~10 ms the p95 margin is about
1 ms, which is within this network path's jitter, so the ab_server verdict varies from run to run.
Capture: every session opened with **0x5B (Large Forward Open)** and was accepted (0xDB, status 0). That covers 15 sessions:
four per complete invocation and three in the aborted run 5. Each session had one Forward Close (0x4E to Connection Manager 6/1).
There were **zero** 0x4D/0x53/0x4B. Both aborts (runs 1 and 5) were the simulator: libplctag's ~2 KB packed request was split
across TCP segments (1448 + 606), and ab_server answered with FIN. The harness aborted on the first connection-level failure,
as designed. A real controller reassembles TCP; ab_server does not.
The T21 press result is recorded under `### Pass 3 — 2026-10-08 02:27–02:30 UTC (HEAD `8e2dbc67`, after the final-review fixes; same settings as pass 2) — with packet capture
**17/17 passed.** Browse 3162 = 3162 tags; 20 UDT templates identical; 200 tags read with 0 unexpected divergences
(6 live); native 1 Forward Open, Forward Closes sent/acked 1/1; latency native p95 105.1 ms ≤ libplctag p95 197.3 ms
×1.10 = 217.1 ms → PASS. Raw output: [pass 3](2026-10-07-native-ab-transport-t21/t21-pass3.txt).
Packet capture (`tcpdump -i utun4 host 10.209.70.160 and port 44818`, analysed with TCP sequence reassembly):
| Session | Order | Window (s) | CIP services seen (requests) | Forward Close target |
|---|---|---|---|---|
| libplctag differential | 1 | 0.0 – 82.5 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
| native differential | 2 | 82.5 – 126.3 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
| libplctag latency | 3 | 126.5 – 152.4 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
| native latency | 4 | 152.4 – 176.5 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
- **Zero write-class services** (no 0x4D Write Tag, 0x53 Write Tag Fragmented, 0x4B Execute PCCC, and no 0x4E to
anything but the Connection Manager).
- **Four sessions, strictly sequential** — each starts after the previous one's last packet; never two at once.
- Every session opened exactly one Large Forward Open and closed it with one Forward Close.
## Latency (T21)` and is not filled in here.
## AdminUI /run (T19)
Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture
on `10.100.0.35:44818`. Every container ran the same freshly built image
(`otopcua-host:dev` `sha256:91bffbb0…`, checked per container because `:9200` round-robins). The
modal was driven with the Chrome tools against `http://localhost:9200/raw`.
**Authoring (UI).**
- Created the AbCip driver `t19-abcip` (`DRV-8ea0f0a8cfde`) in the MAIN cluster.
- Configure → Transport = "Native (managed)" → Save. `DriverInstance.DriverConfig` then held
`"transport":"Native"`, a string.
- Reopened Configure. The dropdown showed "Native (managed)". Changed only Timeout from 2 to 3 and
saved. The row then held `"transport":"Native"` and `"timeout":"00:00:03"`, so the save-reverts-transport
defect is fixed live, not only in unit tests.
- Created device `Device1plc1` with host `ab://10.100.0.35:44818/1,0` (ControlLogix). Test Connect
returned `OK · 62 ms`.
- Added the raw tag `TestDINT` (Int32, read-only, tag path `TestDINT`, DInt).
- Seeded `TestDINT = 4242` on the simulator so a non-zero value proves the read path.
- Raw namespace: `ns=2`, the shared `s=OtOpcUa` root plus RawPath ids. UNS uses `ns=3`.
- RawPath: `t19-abcip/Device1plc1/TestDINT`.
**Leg 1: Native.** The deploy sealed with `acks=6`. Both central nodes logged:
```
AbCip driver DRV-8ea0f0a8cfde initializing: transport=Native tagFactory=NativeAbCipTagFactory
```
CLI read on central-1 (`:4840`) and central-2 (`:4841`):
```
Node: ns=2;s=t19-abcip/Device1plc1/TestDINT
Value: 4242
Status: 0x00000000
```
The simulator value was then changed to 5150 through the libplctag CLI. The native-served node
followed it (`Value: 5150`, `Status: 0x00000000`, source time `23:02:09.60Z`).
**Leg 2: inverted gate (Native, dead port).**
- Device host changed to `ab://10.100.0.35:44819/1,0`. Nothing listens on 44819
(`ss -ltn` count 0).
- The node went `BadNoCommunication` (value retained). A CLI read failed with
`ServiceResultException: [80050000]` (BadCommunicationError). The CLI `read` throws on a Bad
status rather than printing it.
- The central-1 log showed the native error text, raised from `NativeAbCipTagRuntime.ReadAsync`:
```
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: TCP connect to 10.100.0.35:44819 failed: ConnectionRefused
---> System.Net.Sockets.SocketException (111): Connection refused
```
- For contrast, the same dead port under libplctag logged different wording:
```
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: ErrorTimeout
---> libplctag.LibPlcTagException: ErrorTimeout
```
So the switch changes behaviour and is not inert.
**Leg 3: back to libplctag.**
- Device restored to port 44818. Configure → Transport = "libplctag (default)" → Save. The row held
`"transport":"Libplctag"`.
- The redeploy sealed with `acks=6`. Both centrals logged
`initializing: transport=Libplctag tagFactory=LibplctagTagFactory`.
- CLI reads on `:4840` and `:4841`: `Value: 5150`, `Status: 0x00000000`.
**Fix found by the gate.** The first deploy logged no transport line. `AbCipDriverFactoryExtensions.Register`
took no `ILoggerFactory`, and `DriverFactoryBootstrap` registered AbCip without one, so every production
AbCip driver ran on `NullLogger`. That hid the new line and every existing AbCip warning or error. AbCip
now takes the host logger factory the same way AbLegacy and Modbus do. `AbCipTransportInitLogTests` is
falsified by reverting that wiring: 2/2 tests fail.
**Cleanup.**
- The `t19-abcip` driver, device and tag rows were deleted and the change redeployed (sealed,
`acks=6`). Both centrals logged `stopped driver child DRV-8ea0f0a8cfde`.
- The rig has no test driver pointing at the fixture.
- The fixture was brought down (`docker compose --profile controllogix down`).
- The simulator's `TestDINT` was left at 5150 until teardown. The container is gone, so the value
does not persist.
## Real press (T21)
Target: **Z-35162**, production CompactLogix 5069-L320ERS2, `10.209.70.160:44818`, path `1,0`, family CompactLogix
(native connection size 4002, Large Forward Open). Read-only. User approved the press and each time window; VPN up;
no other client on this Mac connected to the press. Driven from the Mac over VPN (≈44 ms RTT).
### Pass 1 — 2026-10-07 23:35–23:36 UTC (HEAD `c306c6f6`; 25 tags, 20 templates, pace 50 ms, latency 100×30)
12/14 passed. **Safety held**: packet capture (`tcpdump` on `utun4`) shows 4 sequential sessions, each one Large
Forward Open (0x5B) and one Forward Close (0x4E, Connection Manager 6/1, acked); services seen only 0x03, 0x0A, 0x4C,
0x52, 0x55, 0x5B, 0x4E; **zero writes** (no 0x4D/0x53/0x4B). Browse parity and all 20 UDT template shapes identical;
native Forward Opens = 1.
Failures, and what they led to:
- **Read parity, 2 tags** — live analog values moved between the sequential libplctag and native phases
(`WW_M4ActualTemp` 384.887 → 385.138). Harness defect: it could not tell drift from divergence. Fixed by a volatility
probe (`82a0c298`, tightened element-wise in `43b81b66`): a value that moves between two libplctag reads is
compared by status, length, type, shape and **every element that held still**.
- **Latency** — native p95 142.8 ms vs libplctag 99.1 ms ×1.10 = 109.1 ms (FAIL), medians native 93.7 / libplctag
95.2 ms; three native outliers in 30 passes. Criterion unchanged; pass count raised to 50 and per-pass GC/RTT
diagnostics added.
- **Test host crashed at exit** — `A callback was made on a garbage collected delegate … callback_func_ex`. Root cause
is in libplctag.NET 1.5.2: a failed tag create leaves its native callback registered and `Dispose()` doesn't release
it. **Production-reachable** on the default transport (a nonexistent tag name crashes the server node at
stop/restart). Fixed in the adapters (`6cd68762`, hardened `6ae62873`); Gitea #578.
- Found before pass 1, on ab_server: native CompactLogix opened a 504-byte connection (the family default libplctag
never honoured) and needed ~3× the round-trips. Native default changed to 4002 with the existing Large→regular
fallback (`c306c6f6`).
### Pass 2 — 2026-10-08 00:21–00:24 UTC (HEAD `df776170`, fresh build; 200 tags, 20 templates, pace 50 ms, latency 100×50)
**17/17 passed.**
| Check | Result |
|---|---|
| Browse parity | libplctag 3162 tags, native 3162 — identical sets |
| UDT templates | 20 compared (of 74 found, capped) — identical shapes |
| Read parity | 200 tags: **0 unexpected**, 0 expected (TypeMismatch) divergences; 6 live tags (below) |
| Native Forward Opens | 1 per native phase |
| Native Forward Closes sent/acked | 1/1 per native phase; 0 connections left |
| Sessions | libplctag and native never connected at the same time |
| Writes | none — read-only source guard passes; `grep -c WriteAsync` = 0 over every Differential file |
| libplctag failed-create orphans | 15 destroyed by the #578 fix, 0 rooted — no crash at exit |
⚠️ **Pass 2 has no independent packet capture** (pass 3 below re-ran it with one): the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
and this was not noticed until afterwards. The read-only evidence for pass 2 is the source guard and the harness's
own Forward Open/Close counters; pass 1's capture is the wire-level proof for the same code paths.
Live tags (value moved between the two libplctag reads; every element that held still matched native):
```
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
```
Test summary:
```
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Total tests: 17
Passed: 17
```
Full raw `dotnet test` output: [pass 1](2026-10-07-native-ab-transport-t21/t21-pass1.txt), [pass 2](2026-10-07-native-ab-transport-t21/t21-pass2.txt).
## Latency (T21)
Pass 2 (100 atomic tags × 50 passes per transport, 200 ms gap, all reads in a pass issued concurrently):
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
**Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS.** Native was faster at the median
and much steadier (max 107.5 ms vs 164.8 ms), packing 95 reads into 2 CIP requests per pass. Five pad/config tags that
libplctag refused at setup were removed from both sets (`Local:*:I.Pad1`, `Local:5:C.CfgSize`, `Local:5:C.CfgIDNum`).
Pass 1 (30 passes) failed this criterion on three native outliers (p95 142.8 vs limit 109.1; medians native 93.7 /
libplctag 95.2) — see above.
Per-pass detail (pass 2):
```
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
```
@@ -0,0 +1,307 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.33] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.46] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.57] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [11 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
[xUnit.net 00:01:15.30] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [FAIL]
[xUnit.net 00:01:15.30] Shouldly.ShouldAssertException : natP95
[xUnit.net 00:01:15.30] should be less than or equal to
[xUnit.net 00:01:15.30] 109.06115000000001d
[xUnit.net 00:01:15.30] but was
[xUnit.net 00:01:15.30] 142.765d
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] Additional Info:
[xUnit.net 00:01:15.30] native p95 must be ≤ libplctag p95 × 1.10
[xUnit.net 00:01:15.30] Stack Trace:
[xUnit.net 00:01:15.30] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs(74,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag()
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.30] --- End of stack trace from previous location ---
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.30] Output:
[xUnit.net 00:01:15.30] ## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
[xUnit.net 00:01:15.30] Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
[xUnit.net 00:01:15.30] Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
[xUnit.net 00:01:15.30] Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
[xUnit.net 00:01:15.30] Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] | Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
[xUnit.net 00:01:15.30] |---|---|---|---|---|---|---|---|
[xUnit.net 00:01:15.30] | libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
[xUnit.net 00:01:15.30] | native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] | Pass | libplctag ms | native ms | native requests |
[xUnit.net 00:01:15.30] |---|---|---|---|
[xUnit.net 00:01:15.30] | 1 | 95.0 | 95.4 | 2 |
[xUnit.net 00:01:15.30] | 2 | 94.4 | 92.4 | 2 |
[xUnit.net 00:01:15.30] | 3 | 95.8 | 96.8 | 2 |
[xUnit.net 00:01:15.30] | 4 | 95.0 | 94.7 | 2 |
[xUnit.net 00:01:15.30] | 5 | 98.8 | 93.7 | 2 |
[xUnit.net 00:01:15.30] | 6 | 92.4 | 101.0 | 2 |
[xUnit.net 00:01:15.30] | 7 | 94.1 | 94.1 | 2 |
[xUnit.net 00:01:15.30] | 8 | 97.0 | 96.7 | 2 |
[xUnit.net 00:01:15.30] | 9 | 93.4 | 91.2 | 2 |
[xUnit.net 00:01:15.30] | 10 | 97.7 | 91.0 | 2 |
[xUnit.net 00:01:15.30] | 11 | 94.6 | 91.1 | 2 |
[xUnit.net 00:01:15.30] | 12 | 96.5 | 92.0 | 2 |
[xUnit.net 00:01:15.30] | 13 | 94.1 | 97.6 | 2 |
[xUnit.net 00:01:15.30] | 14 | 96.1 | 94.0 | 2 |
[xUnit.net 00:01:15.30] | 15 | 90.8 | 93.3 | 2 |
[xUnit.net 00:01:15.30] | 16 | 95.6 | 93.7 | 2 |
[xUnit.net 00:01:15.30] | 17 | 93.9 | 100.6 | 2 |
[xUnit.net 00:01:15.30] | 18 | 92.4 | 121.6 | 2 |
[xUnit.net 00:01:15.30] | 19 | 97.5 | 92.7 | 2 |
[xUnit.net 00:01:15.30] | 20 | 95.2 | 93.8 | 2 |
[xUnit.net 00:01:15.30] | 21 | 96.8 | 90.4 | 2 |
[xUnit.net 00:01:15.30] | 22 | 98.6 | 99.1 | 2 |
[xUnit.net 00:01:15.30] | 23 | 92.1 | 146.1 | 2 |
[xUnit.net 00:01:15.30] | 24 | 99.1 | 142.8 | 2 |
[xUnit.net 00:01:15.30] | 25 | 96.6 | 91.7 | 2 |
[xUnit.net 00:01:15.30] | 26 | 96.9 | 91.4 | 2 |
[xUnit.net 00:01:15.30] | 27 | 179.2 | 92.0 | 2 |
[xUnit.net 00:01:15.30] | 28 | 98.5 | 91.7 | 2 |
[xUnit.net 00:01:15.30] | 29 | 95.0 | 88.8 | 2 |
[xUnit.net 00:01:15.30] | 30 | 95.0 | 93.4 | 2 |
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [1 m 6 s]
Error Message:
Shouldly.ShouldAssertException : natP95
should be less than or equal to
109.06115000000001d
but was
142.765d
Additional Info:
native p95 must be ≤ libplctag p95 × 1.10
Stack Trace:
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs:line 74
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
--- End of stack trace from previous location ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
| native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
| Pass | libplctag ms | native ms | native requests |
|---|---|---|---|
| 1 | 95.0 | 95.4 | 2 |
| 2 | 94.4 | 92.4 | 2 |
| 3 | 95.8 | 96.8 | 2 |
| 4 | 95.0 | 94.7 | 2 |
| 5 | 98.8 | 93.7 | 2 |
| 6 | 92.4 | 101.0 | 2 |
| 7 | 94.1 | 94.1 | 2 |
| 8 | 97.0 | 96.7 | 2 |
| 9 | 93.4 | 91.2 | 2 |
| 10 | 97.7 | 91.0 | 2 |
| 11 | 94.6 | 91.1 | 2 |
| 12 | 96.5 | 92.0 | 2 |
| 13 | 94.1 | 97.6 | 2 |
| 14 | 96.1 | 94.0 | 2 |
| 15 | 90.8 | 93.3 | 2 |
| 16 | 95.6 | 93.7 | 2 |
| 17 | 93.9 | 100.6 | 2 |
| 18 | 92.4 | 121.6 | 2 |
| 19 | 97.5 | 92.7 | 2 |
| 20 | 95.2 | 93.8 | 2 |
| 21 | 96.8 | 90.4 | 2 |
| 22 | 98.6 | 99.1 | 2 |
| 23 | 92.1 | 146.1 | 2 |
| 24 | 99.1 | 142.8 | 2 |
| 25 | 96.6 | 91.7 | 2 |
| 26 | 96.9 | 91.4 | 2 |
| 27 | 179.2 | 92.0 | 2 |
| 28 | 98.5 | 91.7 | 2 |
| 29 | 95.0 | 88.8 | 2 |
| 30 | 95.0 | 93.4 | 2 |
[xUnit.net 00:01:15.35] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [FAIL]
[xUnit.net 00:01:15.35] Shouldly.ShouldAssertException : unexpected
[xUnit.net 00:01:15.35] should be empty but had
[xUnit.net 00:01:15.35] 2
[xUnit.net 00:01:15.35] items and was
[xUnit.net 00:01:15.35] ["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
[xUnit.net 00:01:15.35] Stack Trace:
[xUnit.net 00:01:15.35] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs(123,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports()
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.35] --- End of stack trace from previous location ---
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.35] Output:
[xUnit.net 00:01:15.35] ## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
[xUnit.net 00:01:15.35] Selection by origin: array 9, udt member 8, scalar 8
[xUnit.net 00:01:15.35] | Tag | Type | libplctag status | native status | Equal? |
[xUnit.net 00:01:15.35] |---|---|---|---|---|
[xUnit.net 00:01:15.35] | `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `__CONTAINER` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `test` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
[xUnit.net 00:01:15.35] | `UN01_EventsPTR` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
[xUnit.net 00:01:15.35]
[xUnit.net 00:01:15.35] Unexpected divergences: 2; expected divergences: 0.
[xUnit.net 00:01:15.35] - UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
[xUnit.net 00:01:15.35] - UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
[xUnit.net 00:01:15.35] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Process terminated.
A callback was made on a garbage collected delegate of type 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [31 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [2 ms]
Error Message:
Shouldly.ShouldAssertException : unexpected
should be empty but had
2
items and was
["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
Stack Trace:
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs:line 123
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
--- End of stack trace from previous location ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
Standard Output Messages:
## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
Selection by origin: array 9, udt member 8, scalar 8
| Tag | Type | libplctag status | native status | Equal? |
|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
| `__CONTAINER` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
| `test` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
Unexpected divergences: 2; expected divergences: 0.
- UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
- UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 5284 ms.
Test Run Failed.
Total tests: 14
Passed: 12
Failed: 2
Total time: 1.2601 Minutes
@@ -0,0 +1,369 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.46] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.70] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.93] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Standard Output Messages:
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Standard Output Messages:
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Standard Output Messages:
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Selection by origin: array 20, udt member 20, scalar 160
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|---|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `test` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test2` | DInt | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test_stop` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WW_Green` | Bool | Ok | Ok | stable | yes |
| `WW_Down` | Bool | Ok | Ok | stable | yes |
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
[xUnit.net 00:03:12.61] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
Test Run Successful.
Total tests: 17
Passed: 17
Total time: 3.2143 Minutes
@@ -0,0 +1,369 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.35] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.49] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.60] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [9 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [2 m 56 s]
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 101.8 | 197.3 | 54.6 | 219.2 | n/a | 0 |
| native | 50 | 99.4 | 105.1 | 76.0 | 139.3 | 2 | 0 |
Criterion: native p95 105.1 ms ≤ libplctag p95 197.3 ms × 1.10 = 217.1 ms → PASS
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 99.9 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.0/49.4/53.8 |
| 2 | 101.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.1/50.5/53.0 |
| 3 | 100.0 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.7/49.7/53.7 |
| 4 | 102.4 | 0/0/0 | 98.9 | 1/0/0 | 2 | 48.2/49.4/50.5 |
| 5 | 105.8 | 0/0/0 | 97.1 | 0/0/0 | 2 | 44.8/48.5/52.2 |
| 6 | 103.5 | 0/0/0 | 99.8 | 0/0/0 | 2 | 45.3/49.8/54.3 |
| 7 | 100.5 | 0/0/0 | 105.1 | 0/0/0 | 2 | 51.2/52.5/53.8 |
| 8 | 105.7 | 0/0/0 | 94.4 | 0/0/0 | 2 | 43.3/47.1/50.9 |
| 9 | 99.8 | 0/0/0 | 99.1 | 0/0/0 | 2 | 47.0/49.5/51.9 |
| 10 | 102.8 | 0/0/0 | 98.1 | 0/0/0 | 2 | 48.4/49.0/49.5 |
| 11 | 54.6 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.0/49.3/54.5 |
| 12 | 99.8 | 0/0/0 | 105.2 | 0/0/0 | 2 | 44.6/52.5/60.5 |
| 13 | 101.5 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.7/47.4/51.0 |
| 14 | 109.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.9/49.4/51.8 |
| 15 | 106.7 | 0/0/0 | 96.3 | 0/0/0 | 2 | 43.2/48.0/52.8 |
| 16 | 165.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 46.6/49.6/52.5 |
| 17 | 197.3 | 0/0/0 | 101.3 | 0/0/0 | 2 | 45.7/50.6/55.4 |
| 18 | 71.0 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.3/49.3/53.4 |
| 19 | 219.2 | 0/0/0 | 101.0 | 0/0/0 | 2 | 48.0/50.4/52.8 |
| 20 | 168.8 | 0/0/0 | 100.5 | 0/0/0 | 2 | 46.2/50.1/54.1 |
| 21 | 95.3 | 0/0/0 | 98.3 | 0/0/0 | 2 | 45.7/49.0/52.4 |
| 22 | 133.9 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.4/49.4/52.3 |
| 23 | 163.0 | 0/0/0 | 97.2 | 0/0/0 | 2 | 44.9/48.5/52.1 |
| 24 | 184.5 | 0/0/0 | 101.4 | 0/0/0 | 2 | 49.1/50.6/52.2 |
| 25 | 130.9 | 0/0/0 | 97.2 | 0/0/0 | 2 | 43.7/48.5/53.3 |
| 26 | 76.8 | 0/0/0 | 103.2 | 0/0/0 | 2 | 47.3/51.5/55.8 |
| 27 | 99.6 | 0/0/0 | 76.0 | 0/0/0 | 1 | 75.8/75.8/75.8 |
| 28 | 113.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 46.4/50.1/53.7 |
| 29 | 100.9 | 0/0/0 | 96.0 | 0/0/0 | 2 | 44.2/47.9/51.6 |
| 30 | 106.0 | 0/0/0 | 103.0 | 0/0/0 | 2 | 49.9/51.4/53.0 |
| 31 | 107.3 | 0/0/0 | 103.7 | 0/0/0 | 2 | 47.4/51.8/56.1 |
| 32 | 100.9 | 0/0/0 | 102.4 | 0/0/0 | 2 | 47.4/51.1/54.8 |
| 33 | 99.9 | 0/0/0 | 98.6 | 0/0/0 | 2 | 46.0/49.2/52.4 |
| 34 | 101.4 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.7/49.3/51.8 |
| 35 | 97.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.3/49.4/52.5 |
| 36 | 98.7 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.2/49.2/53.2 |
| 37 | 101.2 | 0/0/0 | 100.1 | 0/0/0 | 2 | 45.2/49.9/54.7 |
| 38 | 102.6 | 0/0/0 | 100.0 | 0/0/0 | 2 | 44.0/49.9/55.8 |
| 39 | 103.5 | 0/0/0 | 97.7 | 0/0/0 | 2 | 45.0/48.8/52.5 |
| 40 | 104.5 | 0/0/0 | 99.6 | 0/0/0 | 2 | 46.0/49.7/53.5 |
| 41 | 103.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 45.4/49.6/53.9 |
| 42 | 101.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.0/49.2/52.4 |
| 43 | 101.8 | 0/0/0 | 139.3 | 0/0/0 | 2 | 45.6/69.6/93.6 |
| 44 | 109.4 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.3 |
| 45 | 101.6 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.2 |
| 46 | 101.6 | 0/0/0 | 102.8 | 0/0/0 | 2 | 47.9/51.3/54.8 |
| 47 | 204.5 | 0/0/0 | 101.0 | 0/0/0 | 2 | 47.3/50.4/53.5 |
| 48 | 108.0 | 0/0/0 | 100.0 | 0/0/0 | 2 | 47.4/49.9/52.4 |
| 49 | 99.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.3/50.6/52.8 |
| 50 | 99.7 | 0/0/0 | 99.4 | 1/1/1 | 2 | 46.0/49.6/53.2 |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Standard Output Messages:
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Standard Output Messages:
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Standard Output Messages:
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Selection by origin: array 20, udt member 20, scalar 160
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|---|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `test` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test2` | DInt | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test_stop` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WW_Green` | Bool | Ok | Ok | stable | yes |
| `WW_Down` | Bool | Ok | Ok | stable | yes |
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
- live: WW_M4ActualTemp (lib 4 byte(s) 20526943 = 233.3208; lib probe 4 byte(s) 908D6843 = 232.55298; native 4 byte(s) 501F6743 = 231.12231)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C3487C144C403E0A8640C2F5F9C07C144C40 = [400000,3.1887503,4.1887503,-7.8112497,3.1887503]; lib probe 20 byte(s) 0050C3481C244A400E128540F2EDFAC01C244A40 = [400000,3.158454,4.158454,-7.841546,3.158454]; native 20 byte(s) 0050C34831AA454018D58240E82AFDC031AA4540 = [400000,3.0885127,4.0885124,-7.9114876,3.0885127])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) 96D41EB99A9919BF00D68349CDCCCCBE = [-0.00015147249,-0.6,1080000,-0.4]; lib probe 16 byte(s) A830FE389A9919BF00D68349CDCCCCBE = [0.00012120727,-0.6,1080000,-0.4]; native 16 byte(s) D34D8FB99A9919BF00D68349CDCCCCBE = [-0.0002733307,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 11E416C300000000000000C000000000 = [-150.89088,0,-2,0]; lib probe 16 byte(s) F1E910C300000000000000C000000000 = [-144.91383,0,-2,0]; native 16 byte(s) 194008C300000000000000C000000000 = [-136.25038,0,-2,0])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 91D52843C4085243000090C10022A14991D504C391D5524391D516430000A8C1 = [168.83424,210.03424,-18,1320000,-132.83424,210.83424,150.83424,-21]; lib probe 32 byte(s) CDD7224398074C43000090C10022A1499AAFFDC265D44C43CDD710430000A8C1 = [162.84297,204.02966,-18,1320000,-126.84297,204.82967,144.84297,-21]; native 32 byte(s) 2D311A4388634343000090C10022A1495A62ECC2553044432D3108430000A8C1 = [154.1921,195.3888,-18,1320000,-118.19209,196.1888,136.1921,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 4DC71643E23E7142 = [150.77852,60.31141]; lib probe 8 byte(s) F5BA104388916742 = [144.7303,57.89212]; native 8 byte(s) 650B0843D5AB5942 = [136.04451,54.417805])
[xUnit.net 00:03:06.03] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
Test Run Successful.
Total tests: 17
Passed: 17
Total time: 3.1046 Minutes
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+99 -3
View File
@@ -406,6 +406,27 @@ Both gates fail closed — a session with no identity or no matching role gets
the constants in `OpcUaDataPlaneRoles`, derived from the principal's cluster-scoped `NodeAcl` grants
(see [Role grant source](#role-grant-source-data-plane)).
**Both gates also re-check the session's API key on every call, flag off or on** (Gitea #563). A
session's roles are resolved once, at activation, so before this an open session kept `WriteOperate` /
`AlarmAck` after its key's `ExpiresUtc` passed — or after a deployment disabled or removed the key —
until the client chose to disconnect, which on a machine-to-machine data plane can be never. Now each
write and each Part 9 method call first asks `DataPlaneAuthorizer.AuthorizeKeyUsable`: the same
`FindUsableKey` probe, against the same deployed snapshot and the same clock, that the ACL path makes
(one dictionary lookup, no I/O). An unusable key gets `BadUserAccessDenied` (logged as
`AclGate: denied … key … is no longer enabled or has expired`), checked *before* the "no gateway ⇒
`BadNotWritable`" availability answer. Things to know:
- **Only these two gates.** Read, Browse, Subscribe and HistoryRead stay ungated with the flag off —
that is the flag-off posture, and an expired key's session can still read and keep its subscriptions.
Turn `Security:Acl:Enabled` on to have expiry reach those too.
- **Anonymous is unaffected.** It carries no key id, so there is nothing to expire; its roles stay what
activation resolved from the `anonymous` principal's grants.
- **No flag.** An expired or disabled key keeping write authority is a defect, not a rollout choice.
- ⚠️ **On a fused admin+driver node a key minted after the last deployment authenticates (the ConfigDb
store is read at activation) but its writes and acks are refused until a deployment carries it** — the
check reads the deployed snapshot, which is also what the flag-on path has always done. Deploy after
minting a key.
**What has no authorization check at all:**
- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including
@@ -496,9 +517,84 @@ Behaviours worth knowing before enabling it:
no reconnect** — closing the session instead would force a full reconnect-and-resubscribe on every
machine consumer in the fleet at once when a shared key expired.
⚠️ **Event/alarm monitored items are revalidated by neither trigger.** The live-item map is fed only
from data-change items, so an alarm subscription is gated at creation and never again. Pre-existing —
the deployment trigger has the same hole — and tracked as a residual of arch-review 03/S19.
**Event/alarm monitored items are revalidated by both triggers too** (Gitea #562). A direct event
subscription — one on an equipment or device folder's event notifier — is tracked in a second map
alongside the data-change items and re-asked the creation gate's question (`Subscribe` on the
notifier's scope, for the identity of the session that created it). A denied item is **detached** from
the notifier's event fan-out through the SDK's own unsubscribe — exactly what the client's delete would
do — never set `MonitoringMode.Disabled`, so restoring its authority re-attaches it and alarms resume
with no client action. Events raised while it was stood down are not replayed. Two differences from a
data-change item:
- **The client is not told.** An event item has no value slot to carry `BadUserAccessDenied`, and a
fabricated "access denied" event would land in every alarm consumer's stream, so the feed simply goes
quiet. The operator-facing signal is the `AclGate: RevalidateEventSubscriptions withheld N of M` log line.
- **A client may delete a stood-down item** normally; the delete is absorbed (the item is already off
the fan-out) and a later restoring deployment does not resurrect it.
**Server-object (`i=2253`, all-events) event subscriptions are filtered per event at delivery** (Gitea
#586). They used to be outside the ACL entirely: the SDK attached such an item to the fan-out of every
one of our root notifiers through `SubscribeToAllEvents`, and an equipment folder with no direct
subscriber forwarded its events to the core node manager's Server object, which our node manager never
sees — so a session holding no grant received every alarm in the server. Now:
- The node manager attaches a Server-object item to **nothing**. It records it, and each condition hands
its events to the recorded items itself (the condition's own `OnReportEvent`, which fires exactly once
per event, so the Server object still sees one copy). The root-notifier forward to the core Server
object is dropped.
- Each event is delivered to an item only if the item's session holds `Subscribe` on **any** of: the
condition itself (`ConditionId` = RawPath, raw realm — so a grant on the alarm's raw tag admits it), a
root-notifier folder the condition bubbles through (its raw device folder; a scripted alarm's
equipment folder), or an equipment folder wired as its extra notifier. That is the per-folder path's
question — a session that may subscribe to any notifier the alarm reports through already receives it
there — asked with the same authorizer and the same operation as the per-folder creation gate. The
folders must be asked as well as the condition because the trie inherits downward only within a realm:
an `Equipment` grant covers the UNS folder, not the condition at its RawPath.
- **Revalidation is implicit, not trigger-driven.** Every event re-asks the authorizer, whose decision
cache is keyed per generation and which re-checks key revocation and expiry on every call — so a
deployment takes effect on the next event, and an expired key's feed stops on the next event, without
waiting for the `AclRevalidationTimer` tick. Nothing is detached, so restoring a grant needs no
re-attach: the next event simply passes. Events raised while withheld are not replayed, and the client
is not told (the same reasoning as a stood-down direct item).
- Creation is **not** refused — an all-events item is legitimate for a partially-granted session; what
is withheld is each event it may not see.
- **`ConditionRefresh` on a Server-object item** replays only the conditions the session passes the same
check on (once per condition, not once per notifier it is wired to). Without that, a refresh would
replay every condition's current state to any session.
- Inert with the flag off: every Server-object item receives every condition, as before.
- The replacement delivery keeps the two behaviours of the SDK path it replaced. **Audit events** a
condition reports through itself (`AuditConditionAcknowledgeEventType`, Comment, Enable, Shelving)
reach a Server-object item only while the server is auditing, over SignAndEncrypt or https-binary,
which is exactly the SDK's own guard. And while any Server-object item exists, every condition counts as
**monitored** (`ConditionState.EventsMonitored()`), which gates the SDK's method **audit** events on a
server whose only subscribers are Server-object items. Both were caught in review: the
first cut queued `i=8944` to every Server-object item with auditing off, and left conditions
unmonitored. The second also needed the SDK's `AddRootNotifier` neutralised: its "subscribe existing
all-events items with `unsubscribe: true`" bug would otherwise take the unit back from every
condition rebuilt under a live item. (Method-driven **state-change** events no longer depend on this
bookkeeping at all — the node manager reports them itself, Gitea #587; see `docs/AlarmTracking.md`.
The SDK's counter stays imprecise in one known way — a folder subscriber's unit never reaches a
condition materialised under that folder later, yet its unsubscribe still lowers it — which can now
only cost an audit event, and only while auditing is on.)
- Materialising a condition raises only **that** condition's monitored unit; the full reconcile (every
condition) runs on removal, rebuild and Server-object subscribe/delete. A deploy of N alarms under a
live Server-object item is therefore N bookkeeping steps, not N²/2 under the node-manager lock.
**Model-change and audit events on the Server object are gated too** (Gitea #588). The node manager's
own server-wide events — `GeneralModelChangeEvent` (NodeAdded / NodeDeleted / DataTypeChanged) and the
`AuditWriteUpdateEvent` of a rejected device write — used to go straight to `Server.ReportEvent`, where
the core Server object queued them to every Server-object item ungated: a session with no grant learned
the RawPath / UNS path of every node added, deleted or retyped, and (with auditing on) the written and
prior values of a rejected write. With the flag **on**, the node manager now delivers them itself, item by
item, to the Server-object items it already holds for #586 (the same audience — only Server-object items
ever received them):
- A **model-change** event keeps only the changes whose `Affected` node the session may **Browse**
(ancestors of a granted node included, as for Browse itself). Every event the server raises today carries
one change, so in practice it is delivered whole or not at all; a multi-change event would be delivered
as a copy holding only the visible changes, with a neutral `Message` (the original names the nodes).
- An **audit write** event needs `Read` on its source node — it carries the node's prior value and the
attempted one — on top of the SDK's own guard (server auditing on, SignAndEncrypt or https-binary).
Auditing stays **off by default**: this repo never sets `ServerConfiguration.AuditingEnabled`.
- Any other server-wide event type is withheld (fail closed); the node manager raises none today.
- With the flag **off**, these events go to `Server.ReportEvent` exactly as before.
- **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client
arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note
what this necessarily discloses — the *names* of the folders on the path to a granted node are visible
+3 -3
View File
@@ -303,9 +303,9 @@ Same library as AB CIP — libplctag handles both protocols via the `plc` attrib
| I (Input) | 1 | 2 bytes | INT16/bit | `I:0`, `I:0/3` |
| S (Status) | 2 | 2 bytes | INT16/bit | `S:0`, `S:1/0` |
| B (Binary) | 3 | 2 bytes | INT16/bit | `B3:0`, `B3:0/5` |
| T (Timer) | 4 | 6 bytes | Struct | `T4:0.ACC`, `T4:0.PRE`, `T4:0/DN` |
| C (Counter) | 5 | 6 bytes | Struct | `C5:0.ACC`, `C5:0/DN`, `C5:0/CU` |
| R (Control) | 6 | 6 bytes | Struct | `R6:0.LEN`, `R6:0.POS`, `R6:0/DN` |
| T (Timer) | 4 | 6 bytes | Struct | `T4:0.ACC`, `T4:0.PRE`, `T4:0.DN` |
| C (Counter) | 5 | 6 bytes | Struct | `C5:0.ACC`, `C5:0.DN`, `C5:0.CU` |
| R (Control) | 6 | 6 bytes | Struct | `R6:0.LEN`, `R6:0.POS`, `R6:0.DN` |
| N (Integer) | 7 | 2 bytes | INT16 | `N7:0`, `N7:0/5`, `N10:50` |
| F (Float) | 8 | 4 bytes | FLOAT32 | `F8:0`, `F20:10` |
| ST (String) | 9 | 84 bytes | STRING | `ST9:0` (82 char max) |
+4 -4
View File
@@ -245,8 +245,8 @@ PCCC types are file-based. Int32/Float64/DateTime are not native to SLC/MicroLog
| Int32 | — (decompose in driver from two N words) | partial |
| Float32 | `F8:n` | ✔ |
| String | `ST9:n` | ✔ |
| Timer struct | `T4:n.ACC` / `.PRE` / `/DN` | ✔ |
| Counter struct | `C5:n.ACC` / `.PRE` / `/DN` | ✔ |
| Timer struct | `T4:n.ACC` / `.PRE` / `.DN` | ✔ |
| Counter struct | `C5:n.ACC` / `.PRE` / `.DN` | ✔ |
### Standard Scenario Mapping
@@ -260,8 +260,8 @@ PCCC types are file-based. Int32/Float64/DateTime are not native to SLC/MicroLog
| Ramp (Float32) | `F8:1` sine wave |
| Write-read-back (Bool / Int16 / Float32 / String) | `B3:1/0` / `N7:100` / `F8:100` / `ST9:100` |
| Array element write (Int16[10]) | `N7:200..209` |
| Timer fidelity | `T4:0.ACC`, `T4:0.PRE`, `T4:0/DN` |
| Counter fidelity | `C5:0.ACC`, `C5:0.PRE`, `C5:0/DN` |
| Timer fidelity | `T4:0.ACC`, `T4:0.PRE`, `T4:0.DN` |
| Counter fidelity | `C5:0.ACC`, `C5:0.PRE`, `C5:0.DN` |
| Connection-limit refusal | Driver harness toggle to simulate 4-conn limit |
| Bad on demand | Connection-refused toggle |
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Golden-frame capture recipe for the native AB transport (Task 4 of
# docs/plans/2026-10-07-native-ab-transport.md).
#
# Captures the EtherNet/IP conversation between libplctag (the libplctag 1.5.2
# NuGet wrapper, native core 2.6.0) and the ab_server simulator fixtures, and
# converts each pcap into the `.hex` line format consumed by
# tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures:
#
# > <client->server EIP frame, uppercase hex, no spaces>
# < <server->client EIP frame>
# # comment
#
# Packets are captured INSIDE the simulator container's network namespace
# (netshoot sidecar), where ab_server always listens on 44818 — so the same
# filter works for the AbLegacy containers whose host ports are 44819-44821.
# tshark also runs in the netshoot image on the docker host, so nothing needs
# installing on the workstation beyond python3.
#
# Usage:
# CAPDIR=<fresh empty dir> scripts/ab-capture/capture.sh run <container> <name> -- <client command...>
# scripts/ab-capture/capture.sh tohex <pcap> <out.hex>
# scripts/ab-capture/capture.sh client <client args...>
#
# `client` builds (once) and runs scripts/ab-capture/client — the console program
# over libplctag 1.5.2 that took the committed fixtures: one tag, one Initialize +
# Read/Write, then Dispose + plc_tag_shutdown so the Forward Close reaches the wire.
# No UnregisterSession is ever sent; the session ends with a TCP RST. Example:
#
# CAPDIR=$PWD/caps scripts/ab-capture/capture.sh run otopcua-ab-server-controllogix logix-read-dint -- \
# scripts/ab-capture/capture.sh client read 10.100.0.35:44818 1,0 ControlLogix TestDINT dint
#
# See Captures/README.md for every invocation used.
#
# Fixtures (one AbCip profile at a time — they all bind host :44818):
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d'
# ssh dohertj2@10.100.0.35 'cd ~/otopcua-ablegacy && docker compose --profile slc500 up -d'
set -euo pipefail
DOCKER_HOST_SSH="${DOCKER_HOST_SSH:-dohertj2@10.100.0.35}"
NETSHOOT="${NETSHOOT:-nicolaka/netshoot:v0.13}"
REMOTE_DIR="${REMOTE_DIR:-/tmp/abcap}"
cmd="${1:-}"; shift || true
case "$cmd" in
client)
here="$(cd "$(dirname "$0")" && pwd)"
dll="$here/client/bin/Release/net10.0/ab-capture-client.dll"
[[ -f "$dll" ]] || dotnet build "$here/client/ab-capture-client.csproj" -c Release -v q >&2
exec dotnet "$dll" "$@"
;;
run)
container="$1"; name="$2"; shift 2
[[ "${1:-}" == "--" ]] && shift
: "${CAPDIR:?set CAPDIR to a fresh empty directory}"
sidecar="abcap-$name"
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR && rm -f $REMOTE_DIR/$name.pcap && \
docker run -d --rm --name $sidecar --net=container:$container -v $REMOTE_DIR:/cap $NETSHOOT \
tcpdump -i eth0 -U -w /cap/$name.pcap tcp port 44818 >/dev/null"
sleep 2 # let tcpdump open the interface before the client connects
"$@" || echo "client exited non-zero: $?" >&2
sleep 1
ssh "$DOCKER_HOST_SSH" "docker stop $sidecar >/dev/null"
scp -q "$DOCKER_HOST_SSH:$REMOTE_DIR/$name.pcap" "$CAPDIR/$name.pcap"
echo "$CAPDIR/$name.pcap"
;;
tohex)
pcap="$1"; out="$2"
base="$(basename "$pcap")"
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR"
scp -q "$pcap" "$DOCKER_HOST_SSH:$REMOTE_DIR/tohex-$base"
# srcport + payload of every data-bearing, non-retransmitted segment, in capture order.
ssh "$DOCKER_HOST_SSH" "docker run --rm -v $REMOTE_DIR:/cap $NETSHOOT \
tshark -r /cap/tohex-$base -T fields -e tcp.srcport -e tcp.payload \
-Y 'tcp.len>0 && !tcp.analysis.retransmission'" \
| python3 -I -c '
import sys
# Re-join TCP segments into EIP encapsulation frames per direction, using the
# 24-byte header length field (offset 2, UInt16 LE) to find frame boundaries.
bufs = {True: b"", False: b""}
for line in sys.stdin:
line = line.strip()
if not line:
continue
port, payload = line.split("\t")
from_client = port != "44818" # srcport 44818 => server->client
bufs[from_client] += bytes.fromhex(payload.replace(":", ""))
b = bufs[from_client]
while len(b) >= 24:
total = 24 + int.from_bytes(b[2:4], "little")
if len(b) < total:
break
print(("> " if from_client else "< ") + b[:total].hex().upper())
b = b[total:]
bufs[from_client] = b
for k, rest in bufs.items():
if rest:
sys.exit(f"trailing partial frame ({len(rest)} bytes, client={k})")
' > "$out"
echo "$out: $(grep -c '^[<>]' "$out") frames"
;;
*)
sed -n '2,37p' "$0"; exit 2 ;;
esac
+169
View File
@@ -0,0 +1,169 @@
// Capture client for scripts/ab-capture/capture.sh.
//
// One libplctag tag, one operation, then Dispose + plc_tag_shutdown so the Forward Close
// reaches the wire. The tag is built with exactly the properties the drivers set
// (LibplctagTagRuntime / LibplctagLegacyTagRuntime / LibplctagTagEnumerator): Gateway, Path,
// PlcType, Protocol=ab_eip, Name, Timeout, and ElementCount when > 1. Nothing else.
//
// usage:
// ab-capture-client version
// ab-capture-client read <host:port> <path|-> <plc> <name> <type> [count]
// ab-capture-client write <host:port> <path|-> <plc> <name> <type> <count> <value>
//
// <plc> a libplctag.PlcType name: ControlLogix, Micro800, Slc500, MicroLogix, Plc5, LogixPccc
// <type> dint | int | real | string | bool | raw (read only: hex dump of the buffer)
// dintseq (write only): element i (0-based) = ((i+1) << 24) | (i+1), for <count> elements;
// <value> is ignored but required.
// env UNCONNECTED=1 sets UseConnectedMessaging=false (ignored by libplctag 2.6.0 for Micro800).
using libplctag;
using libplctag.NativeImport;
if (args.Length == 0)
{
Console.Error.WriteLine("usage: see the header of Program.cs");
return 2;
}
if (args[0] == "seed-bool")
{
// ab_server rejects libplctag's BOOL write with CIP 0x03: libplctag sends two data bytes for a
// one-byte BOOL and ab_server's "too much data" check refuses it. This seeds a BOOL for a
// later libplctag READ capture with one raw UCMM Write Tag carrying exactly one data byte.
// usage: seed-bool <host> <port> <tag> <0|1> (route is port 1 / slot 0)
return await SeedBoolAsync(args[1], int.Parse(args[2]), args[3], args[4] == "1" ? (byte)1 : (byte)0);
}
if (args[0] == "version")
{
Console.WriteLine($"{plctag.plc_tag_get_int_attribute(0, "version_major", -1)}." +
$"{plctag.plc_tag_get_int_attribute(0, "version_minor", -1)}." +
$"{plctag.plc_tag_get_int_attribute(0, "version_patch", -1)}");
return 0;
}
var op = args[0];
var gateway = args[1];
var path = args[2] == "-" ? null : args[2];
var plc = Enum.Parse<PlcType>(args[3], ignoreCase: true);
var name = args[4];
var type = args[5];
var count = args.Length > 6 ? int.Parse(args[6]) : 1;
var tag = new Tag
{
Gateway = gateway,
Path = path,
PlcType = plc,
Protocol = Protocol.ab_eip,
Name = name,
Timeout = TimeSpan.FromSeconds(5),
};
if (count > 1) tag.ElementCount = count;
if (Environment.GetEnvironmentVariable("UNCONNECTED") == "1") tag.UseConnectedMessaging = false;
var exit = 0;
try
{
await tag.InitializeAsync();
if (op == "write")
{
var value = args[7];
switch (type)
{
case "dint": tag.SetInt32(0, int.Parse(value)); break;
case "int": tag.SetInt16(0, short.Parse(value)); break;
case "real": tag.SetFloat32(0, float.Parse(value)); break;
case "string": tag.SetString(0, value); break;
// As LibplctagTagRuntime does: a whole-byte write (SetBit on a Logix BOOL is ErrorBadParam).
case "bool": tag.SetInt8(0, value is "1" or "true" ? (sbyte)1 : (sbyte)0); break;
case "dintseq":
for (var i = 0; i < count; i++) tag.SetInt32(i * 4, ((i + 1) << 24) | (i + 1));
break;
default: throw new ArgumentException($"unknown write type {type}");
}
await tag.WriteAsync();
Console.WriteLine("write ok");
}
else
{
await tag.ReadAsync();
Console.WriteLine($"read ok size={tag.GetSize()}");
Console.WriteLine(type switch
{
"dint" => tag.GetInt32(0).ToString(),
"int" => tag.GetInt16(0).ToString(),
"real" => tag.GetFloat32(0).ToString(),
"string" => tag.GetString(0),
"bool" => tag.GetBit(0).ToString(),
_ => Convert.ToHexString(tag.GetBuffer()),
});
}
}
catch (Exception ex)
{
Console.WriteLine($"EXC {ex.GetType().Name}: {ex.Message}");
exit = 1;
}
finally
{
tag.Dispose();
}
plctag.plc_tag_shutdown();
await Task.Delay(500); // let the Forward Close flush before the process exits
return exit;
static async Task<int> SeedBoolAsync(string host, int port, string tagName, byte value)
{
using var tcp = new System.Net.Sockets.TcpClient();
await tcp.ConnectAsync(host, port);
var s = tcp.GetStream();
// RegisterSession
var reg = new byte[28];
reg[0] = 0x65; reg[2] = 4; reg[24] = 1;
await s.WriteAsync(reg);
var regReply = await ReadFrameAsync(s);
var session = BitConverter.ToUInt32(regReply, 4);
// Write Tag (0x4D): ANSI symbol segment, type BOOL 0xC1, count 1, ONE data byte.
var name = System.Text.Encoding.ASCII.GetBytes(tagName);
var sym = new List<byte> { 0x91, (byte)name.Length };
sym.AddRange(name);
if (sym.Count % 2 == 1) sym.Add(0);
var write = new List<byte> { 0x4D, (byte)(sym.Count / 2) };
write.AddRange(sym);
write.AddRange([0xC1, 0x00, 0x01, 0x00, value]);
// Unconnected Send (0x52) to the Connection Manager, routed to port 1 / slot 0.
var ucmm = new List<byte> { 0x52, 0x02, 0x20, 0x06, 0x24, 0x01, 0x0A, 0x05 };
ucmm.AddRange(BitConverter.GetBytes((ushort)write.Count));
ucmm.AddRange(write);
if (write.Count % 2 == 1) ucmm.Add(0);
ucmm.AddRange([0x01, 0x00, 0x01, 0x00]);
// SendRRData: interface handle, timeout, 2 items (null address, unconnected data).
var data = new List<byte> { 0, 0, 0, 0, 0x0A, 0, 2, 0, 0, 0, 0, 0, 0xB2, 0 };
data.AddRange(BitConverter.GetBytes((ushort)ucmm.Count));
data.AddRange(ucmm);
var frame = new byte[24 + data.Count];
frame[0] = 0x6F;
BitConverter.GetBytes((ushort)data.Count).CopyTo(frame, 2);
BitConverter.GetBytes(session).CopyTo(frame, 4);
data.CopyTo(frame, 24);
await s.WriteAsync(frame);
var reply = await ReadFrameAsync(s);
var cip = reply.AsSpan(24 + 16).ToArray(); // after handle, timeout, count, null item, data item header
Console.WriteLine($"seed-bool reply CIP: {Convert.ToHexString(cip)}");
return cip.Length >= 3 && cip[0] == 0xCD && cip[2] == 0 ? 0 : 1;
}
static async Task<byte[]> ReadFrameAsync(System.Net.Sockets.NetworkStream s)
{
var header = new byte[24];
await s.ReadExactlyAsync(header);
var body = new byte[BitConverter.ToUInt16(header, 2)];
await s.ReadExactlyAsync(body);
return [.. header, .. body];
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Capture client for scripts/ab-capture/capture.sh — drives libplctag 1.5.2 against the ab_server
fixtures so the golden frames in tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures
can be re-taken. Deliberately NOT in ZB.MOM.WW.OtOpcUa.slnx. Pinned to the exact libplctag the
drivers use; CPM is switched off so the pin lives here and cannot drift with Directory.Packages.props.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="libplctag" Version="1.5.2" />
</ItemGroup>
</Project>
@@ -45,7 +45,23 @@ public class ReadCommand : CommandBase
var ct = console.RegisterCancellationHandler();
(service, _) = await CreateServiceAndConnectAsync(ct);
var value = await service.ReadValueAsync(nodeId, ct);
DataValue value;
try
{
value = await service.ReadValueAsync(nodeId, ct);
}
catch (ServiceResultException ex) when (ex.StatusCode != StatusCodes.BadSessionIdInvalid
&& ex.StatusCode != StatusCodes.BadConnectionClosed
&& ex.StatusCode != StatusCodes.BadSecureChannelClosed)
{
// The SDK's Session.ReadValue surfaces a Bad node status (BadNodeIdUnknown,
// BadUserAccessDenied, ...) as an exception. A read command's job is to REPORT that status,
// not crash with a stack trace (#577) — session-level failures still propagate.
await console.Output.WriteLineAsync($"Node: {NodeId}");
await console.Output.WriteLineAsync($"Value: ");
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
return;
}
await console.Output.WriteLineAsync($"Node: {NodeId}");
await console.Output.WriteLineAsync($"Value: {value.Value}");
@@ -370,10 +370,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
// The Acknowledge method lives on the .Condition child node, not the source node itself
var conditionObjId = conditionNodeId.EndsWith(".Condition")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".Condition");
// OtOpcUa conditions are their own event source (SourceNode == ConditionId), so the Part 9 methods are
// invoked on that node directly. There is no ".Condition" child to append.
var conditionObjId = NodeId.Parse(conditionNodeId);
var acknowledgeMethodId = MethodIds.AcknowledgeableConditionType_Acknowledge;
// CallMethodAsync throws ServiceResultException on a bad call result;
@@ -406,10 +405,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
// Confirm lives on the same .Condition child node as Acknowledge
var conditionObjId = conditionNodeId.EndsWith(".Condition")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".Condition");
// Confirm is invoked on the condition node itself, like Acknowledge.
var conditionObjId = NodeId.Parse(conditionNodeId);
var confirmMethodId = MethodIds.AcknowledgeableConditionType_Confirm;
try
@@ -438,11 +435,11 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
// The shelve methods live on the ShelvingState child of the condition node.
// conditionNodeId is the alarm/condition node; append .ShelvingState to get the state machine node.
var shelvingStateNodeId = conditionNodeId.EndsWith(".ShelvingState")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".ShelvingState");
// The server's ShelvingState child is SDK-created with a numeric namespace-0 id, so a synthesized
// "<condition>.ShelvingState" node id never exists. The shelve methods are called on the condition
// object itself with the AlarmConditionType_ShelvingState_* method ids, which the server dispatches
// to the condition's ShelvingState methods.
var shelvingStateNodeId = NodeId.Parse(conditionNodeId);
NodeId methodId;
object[] inputArgs;
@@ -495,8 +492,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
/// <summary>
/// Shared body for the H4 Enable/Disable client path. Invokes the OPC UA Part 9 ConditionType
/// <c>Enable</c>/<c>Disable</c> method (no input arguments) on the condition object node, mirroring
/// the <see cref="AcknowledgeAlarmAsync"/> convention: the methods live on the <c>.Condition</c>
/// child node, and a bad call result surfaces as the returned <see cref="StatusCode"/> rather than
/// the <see cref="AcknowledgeAlarmAsync"/> convention: the methods are invoked on the condition
/// node itself, and a bad call result surfaces as the returned <see cref="StatusCode"/> rather than
/// escaping as an uncaught <see cref="ServiceResultException"/>.
/// </summary>
/// <param name="conditionNodeId">The condition node associated with the alarm.</param>
@@ -507,10 +504,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
// Enable/Disable live on the same .Condition child node as Acknowledge/Confirm.
var conditionObjId = conditionNodeId.EndsWith(".Condition")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".Condition");
// Enable/Disable are invoked on the condition node itself, like Acknowledge/Confirm.
var conditionObjId = NodeId.Parse(conditionNodeId);
var methodId = enabling ? MethodIds.ConditionType_Enable : MethodIds.ConditionType_Disable;
try
@@ -37,7 +37,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
// RoleLeaderChanged event landed last would silently overwrite the other's leader — turning two
// real, independent facts Akka tracks separately into one, wrong, merged one. Pinned by
// ClusterRoleInfoRoleLeaderCaseTests.RoleLeaderChanged_for_driver_then_Driver_keeps_distinct_entries.
private readonly Dictionary<string, Member?> _roleLeaders = new(StringComparer.Ordinal);
private readonly Dictionary<string, Akka.Actor.Address?> _roleLeaders = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<Member>> _membersByRole = new(StringComparer.OrdinalIgnoreCase);
private IActorRef? _subscriber;
@@ -113,7 +113,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
lock (_lock)
{
return _roleLeaders.TryGetValue(role, out var leader) && leader is not null
? ToNodeId(leader.Address)
? ToNodeId(leader)
: (CommonsNodeId?)null;
}
}
@@ -137,9 +137,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
foreach (var role in snapshot.Members.SelectMany(m => m.Roles).Distinct())
{
var leaderAddr = snapshot.RoleLeader(role);
_roleLeaders[role] = leaderAddr is not null
? snapshot.Members.FirstOrDefault(m => m.Address == leaderAddr)
: null;
_roleLeaders[role] = leaderAddr;
}
}
}
@@ -179,17 +177,17 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
lock (_lock)
{
_roleLeaders.TryGetValue(evt.Role, out var prevMember);
if (prevMember is not null)
previous = ToNodeId(prevMember.Address);
_roleLeaders.TryGetValue(evt.Role, out var prevLeader);
if (prevLeader is not null)
previous = ToNodeId(prevLeader);
var nextMember = evt.Leader is null
? null
: _cluster.State.Members.FirstOrDefault(m => m.Address == evt.Leader);
_roleLeaders[evt.Role] = nextMember;
if (nextMember is not null)
next = ToNodeId(nextMember.Address);
// Record the event's own address. The leader used to be resolved by looking the address up
// in Cluster.State.Members, but that snapshot is published asynchronously and can lag the
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good
// (Gitea #590). Only the address is ever read, so no Member lookup is needed.
_roleLeaders[evt.Role] = evt.Leader;
if (evt.Leader is not null)
next = ToNodeId(evt.Leader);
raise = !Nullable.Equals(previous, next);
}
@@ -5,6 +5,10 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Cluster.Tests"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka.Hosting"/>
<PackageReference Include="Akka.Cluster"/>
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
/// <summary>
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
/// <see cref="AbCipDriverOptions.Transport"/> null so the driver resolves it through
/// <see cref="AbCipTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
/// </summary>
public enum AbCipCliTransport
{
/// <summary>Use the driver's default (resolved by <see cref="AbCipTransportResolver"/>).</summary>
Default,
/// <summary>The managed native transport.</summary>
Native,
/// <summary>The libplctag fallback transport.</summary>
Libplctag,
}
/// <summary>Maps a <see cref="AbCipCliTransport"/> onto the driver option.</summary>
public static class AbCipCliTransportExtensions
{
/// <summary>Returns the configured transport: null for <see cref="AbCipCliTransport.Default"/>.</summary>
/// <param name="choice">The CLI choice.</param>
/// <returns>The value for <see cref="AbCipDriverOptions.Transport"/>.</returns>
public static AbCipTransport? ToConfigured(this AbCipCliTransport choice) => choice switch
{
AbCipCliTransport.Native => AbCipTransport.Native,
AbCipCliTransport.Libplctag => AbCipTransport.Libplctag,
_ => null,
};
}
@@ -26,6 +26,11 @@ public abstract class AbCipCommandBase : DriverCommandBase
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: default (= native), native (managed EtherNet/IP + CIP) or libplctag (fallback).")]
public AbCipCliTransport Transport { get; init; } = AbCipCliTransport.Default;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -68,6 +73,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
WriteIdempotent: t.WriteIdempotent,
DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Transport = Transport.ToConfigured(),
Probe = new AbCipProbeOptions { Enabled = false },
EnableControllerBrowse = false,
EnableAlarmProjection = false,
@@ -6,6 +6,6 @@ return await new CliApplicationBuilder()
.SetDescription(
"OtOpcUa AB CIP test-client — ad-hoc probe + Logix symbolic reads/writes + polled " +
"subscriptions against ControlLogix / CompactLogix / Micro800 / GuardLogix families " +
"via libplctag. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.")
"via libplctag or the native transport. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.")
.Build()
.RunAsync(args);
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
/// <summary>
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
/// <see cref="AbLegacyDriverOptions.Transport"/> null so the driver resolves it through
/// <see cref="AbLegacyTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
/// </summary>
public enum AbLegacyCliTransport
{
/// <summary>Use the driver's default (resolved by <see cref="AbLegacyTransportResolver"/>).</summary>
Default,
/// <summary>The managed native transport.</summary>
Native,
/// <summary>The libplctag fallback transport.</summary>
Libplctag,
}
/// <summary>Maps a <see cref="AbLegacyCliTransport"/> onto the driver option.</summary>
public static class AbLegacyCliTransportExtensions
{
/// <summary>Returns the configured transport: null for <see cref="AbLegacyCliTransport.Default"/>.</summary>
/// <param name="choice">The CLI choice.</param>
/// <returns>The value for <see cref="AbLegacyDriverOptions.Transport"/>.</returns>
public static AbLegacyTransport? ToConfigured(this AbLegacyCliTransport choice) => choice switch
{
AbLegacyCliTransport.Native => AbLegacyTransport.Native,
AbLegacyCliTransport.Libplctag => AbLegacyTransport.Libplctag,
_ => null,
};
}
@@ -25,6 +25,13 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: default, native or libplctag. default = libplctag for every --plc-type; native is " +
"opt-in (wire-proven only for N-file reads/writes and an F8 read; PLC-5 native is N-file reads only, " +
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -56,6 +63,7 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, AbLegacyTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Transport = Transport.ToConfigured(),
Probe = new AbLegacyProbeOptions { Enabled = false },
};
@@ -5,7 +5,7 @@ return await new CliApplicationBuilder()
.SetExecutableName("otopcua-ablegacy-cli")
.SetDescription(
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag or the native transport. " +
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
.Build()
.RunAsync(args);
@@ -0,0 +1,151 @@
using System.Collections.Concurrent;
using System.Reflection;
using libplctag;
using libplctag.NativeImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
/// <summary>
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
/// </summary>
/// <remarks>
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
/// background for the life of the process.</para>
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> / <c>LibplctagLegacyOrphanReleaseTests</c> fail, so the bump is noticed.</para>
/// <para>SHARED SOURCE (#580): this directory has no project. <c>Driver.AbCip</c> and <c>Driver.AbLegacy</c> each
/// compile this file in (<c>&lt;Compile Include&gt;</c>), so each assembly has its own internal copy and its own
/// counters. It must keep a <c>Libplctag*.cs</c> name — <c>LibplctagConfinementGuardTests</c> allows libplctag
/// references only in such files.</para>
/// </remarks>
internal static class LibplctagOrphanRelease
{
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
// alive"), which the pin test reports.
private static readonly FieldInfo? HandleField =
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
private static readonly ConcurrentBag<Tag> Rooted = [];
private static int _released;
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
internal static bool CanReadHandles => HandleField is not null;
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
internal static int Released => Volatile.Read(ref _released);
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
internal static int RootedCount => Rooted.Count;
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
internal static int HandleOf(Tag tag)
{
try
{
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
}
catch (Exception)
{
return 0;
}
}
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
private static void ClearHandle(Tag tag)
{
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
}
/// <summary>
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
/// created is unregistered and destroyed before the failure propagates.
/// </summary>
internal static async Task RunAsync(Tag tag, Func<Task> op)
{
try
{
await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
{
try
{
return await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <summary>
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
/// </summary>
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
/// reported), or null when nothing was destroyed.</returns>
internal static Status? ReleaseIfOrphaned(Tag tag) => ReleaseIfOrphaned(tag, CanReadHandles);
/// <summary>
/// <see cref="ReleaseIfOrphaned(Tag)"/> with the handle-readability decision passed in, so a test can take the
/// rooting fallback (review r1 M1) without a process-wide switch (Gitea #581). Production always passes
/// <see cref="CanReadHandles"/>.
/// </summary>
internal static Status? ReleaseIfOrphaned(Tag tag, bool canReadHandles)
{
try
{
if (tag.IsInitialized) return null;
if (!canReadHandles)
{
Rooted.Add(tag);
return null;
}
var handle = HandleOf(tag);
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
var status = (Status)plctag.plc_tag_status(handle);
plctag.plc_tag_unregister_callback(handle);
plctag.plc_tag_destroy(handle);
ClearHandle(tag);
Interlocked.Increment(ref _released);
return status;
}
catch (Exception)
{
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
// state the native side is in, its callback delegate stays alive.
Rooted.Add(tag);
return null;
}
}
}
@@ -0,0 +1,55 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>
/// The single <see cref="AbStatus"/> to OPC UA StatusCode table shared by <c>AbCipStatusMapper</c> and
/// <c>AbLegacyStatusMapper</c> (#580) — the two drivers used to carry identical copies.
/// </summary>
public static class AbOpcUaStatusMap
{
/// <summary>OPC UA <c>Good</c>.</summary>
public const uint Good = 0u;
/// <summary>OPC UA <c>GoodMoreData</c>.</summary>
public const uint GoodMoreData = 0x00A60000u;
/// <summary>OPC UA <c>BadNodeIdUnknown</c>.</summary>
public const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>OPC UA <c>BadNotWritable</c>.</summary>
public const uint BadNotWritable = 0x803B0000u;
/// <summary>OPC UA <c>BadOutOfRange</c>.</summary>
public const uint BadOutOfRange = 0x803C0000u;
/// <summary>OPC UA <c>BadNotSupported</c>.</summary>
public const uint BadNotSupported = 0x803D0000u;
/// <summary>OPC UA <c>BadDeviceFailure</c>.</summary>
public const uint BadDeviceFailure = 0x808B0000u;
/// <summary>OPC UA <c>BadCommunicationError</c>.</summary>
public const uint BadCommunicationError = 0x80050000u;
/// <summary>OPC UA <c>BadTimeout</c>.</summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary>OPC UA <c>BadTypeMismatch</c>.</summary>
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>Maps a transport-neutral <see cref="AbStatus"/> to an OPC UA StatusCode.</summary>
/// <param name="status">The transport status.</param>
/// <returns>The corresponding OPC UA status code.</returns>
public static uint MapStatus(AbStatus status) => status switch
{
AbStatus.Ok => Good,
AbStatus.Pending => GoodMoreData,
AbStatus.Timeout => BadTimeout,
AbStatus.NotFound => BadNodeIdUnknown,
AbStatus.NotAllowed => BadNotWritable,
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
AbStatus.Unsupported => BadNotSupported,
AbStatus.TypeMismatch => BadTypeMismatch,
AbStatus.DeviceStateConflict => BadDeviceFailure,
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
};
/// <summary>
/// Status for an exception thrown while creating / reading a tag: a transport exception maps through its
/// <see cref="AbStatus"/>; anything else is <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The OPC UA status code.</returns>
public static uint MapException(Exception ex) =>
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>Transport-neutral status shared by the libplctag and native AB transports.
/// Zero is success; every error is negative so existing <c>status != 0</c> checks stay correct.</summary>
public enum AbStatus
{
Ok = 0,
Pending = 1,
Timeout = -1,
NotFound = -2,
NotAllowed = -3,
OutOfBounds = -4,
TooLarge = -5,
TooSmall = -6,
Unsupported = -7,
BadConnection = -8,
BadReply = -9,
Partial = -10,
Abort = -11,
TypeMismatch = -12,
DeviceStateConflict = -13,
}
@@ -0,0 +1,31 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
public static class AbStatusCodes
{
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
{
0x00 or 0x0B => AbStatus.Ok,
0x04 or 0x05 or 0x16 => AbStatus.NotFound,
0x06 => AbStatus.Partial,
0x08 => AbStatus.Unsupported,
0x0A => AbStatus.OutOfBounds,
0x0C or 0x10 => AbStatus.DeviceStateConflict,
0x0E or 0x0F => AbStatus.NotAllowed,
0x13 => AbStatus.TooSmall,
0x15 => AbStatus.TooLarge,
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
_ => AbStatus.BadReply,
};
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
{
0x00 => AbStatus.Ok,
0x10 => AbStatus.Unsupported,
0x20 => AbStatus.NotFound,
0x30 => AbStatus.NotAllowed,
0x40 or 0x50 => AbStatus.DeviceStateConflict,
0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
_ => AbStatus.BadReply,
};
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>Thrown by both AB transports. <see cref="Status"/> drives OPC UA status mapping and the
/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).</summary>
public class AbTransportException(AbStatus status, string message, Exception? inner = null)
: Exception(message, inner)
{
public AbStatus Status { get; } = status;
}
@@ -0,0 +1,381 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>
/// One device's CIP channel: one EtherNet/IP session and, when <see cref="CipRouting.Connected"/>, one
/// class-3 connection. Opens lazily on first use, reconnects (through the <see cref="ForwardOpenThrottle"/>)
/// after the session faults, and keeps an idle connection alive.
/// </summary>
/// <remarks>
/// <para><b>Reconnect.</b> Only a faulted session (timeout, I/O error, malformed frame) is reopened, and
/// only by the <i>next</i> send; the failing call throws its own exception. Caller cancellation never
/// faults the session (B2), so it never causes a reconnect. A connected reply saying the target no longer
/// holds the class-3 connection (general 0x01, extended 0x0107 / 0x0203 — the session itself still up) retires
/// the pair at once, whether a request or a keep-alive got it, so the next send reopens it (Gitea #566).</para>
/// <para><b>Bounded.</b> Every <see cref="SendAsync"/> is bounded even if the caller passes no token
/// (ruling R9-3): opening (including the throttle wait and any wait behind another opener) is bounded by
/// <c>MinForwardOpenInterval + 4 × Timeout</c> (connect, RegisterSession, Forward Open, fallback Forward
/// Open), and the exchange by <c>2 × Timeout</c> (waiting behind one in-flight exchange, then its own).
/// Exceeding either surfaces as <see cref="AbTransportException"/>(<see cref="AbStatus.Timeout"/>).</para>
/// <para><b>Connection identity.</b> Every Forward Open attempt uses a fresh connection serial and T→O id,
/// so a reopen is never mistaken by the target for a duplicate of a connection it has not yet expired.</para>
/// </remarks>
public sealed class CipConnection : ICipChannel, IAsyncDisposable
{
/// <summary>Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID).</summary>
internal static readonly byte[] KeepAliveRequest = [0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01];
/// <summary>Largest unconnected message a target is guaranteed to accept.</summary>
private const int UnconnectedMessageLimit = 504;
private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn, ushort ConnectionSerial, int NegotiatedSize);
private readonly DeviceKey _key;
private readonly ConnectionOptions _o;
private readonly ForwardOpenThrottle _throttle;
private readonly TimeProvider _time;
private readonly Task _predecessorClosed;
private readonly ConnectionDiagnostics _diagnostics;
private readonly byte[] _route;
private readonly object _sync = new();
// Cancels only the waits BEFORE an attempt (predecessor close, throttle interval), never an attempt
// already on the wire. Never disposed: it has no timer, and late registrations must stay legal.
private readonly CancellationTokenSource _disposing = new();
private readonly ITimer? _keepAlive;
private Task<Open>? _pendingOpen; // the one shared open in progress; under _sync
private Open? _open; // written under _sync (Volatile); read lock-free via Volatile
private int _disposed; // set under _sync; read lock-free via Volatile
private int _disposeStarted;
private long _lastSendTimestamp;
private int _keepAlivesSent;
private int _connectionSerial = Random.Shared.Next(1, ushort.MaxValue);
/// <param name="key">The device; <see cref="DeviceKey.CipPath"/> is the route (ignored for Direct).</param>
/// <param name="options">Routing, sizes, timeouts and keep-alive settings.</param>
/// <param name="throttle">The device's process-lifetime Forward Open throttle.</param>
/// <param name="time">Clock for the throttle, deadlines and keep-alive.</param>
/// <param name="predecessorClosed">Completes when the previous connection for the same key has
/// finished closing, so two connections to one device never overlap.</param>
/// <param name="diagnostics">The owning pool's counters and open-attempt limits; a private set when null.</param>
internal CipConnection(DeviceKey key, ConnectionOptions options, ForwardOpenThrottle throttle, TimeProvider time,
Task? predecessorClosed = null, ConnectionDiagnostics? diagnostics = null)
{
_diagnostics = diagnostics ?? new ConnectionDiagnostics();
_key = key ?? throw new ArgumentNullException(nameof(key));
_o = options ?? throw new ArgumentNullException(nameof(options));
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
_time = time ?? throw new ArgumentNullException(nameof(time));
_predecessorClosed = predecessorClosed ?? Task.CompletedTask;
// Direct ignores the path entirely (B3), so it is not even parsed.
_route = options.Routing == CipRouting.Direct ? [] : CipPath.EncodeRoute(key.CipPath);
_lastSendTimestamp = time.GetTimestamp();
if (options.Routing == CipRouting.Connected)
_keepAlive = time.CreateTimer(static s => ((CipConnection)s!).OnKeepAliveTimer(), this,
options.KeepAliveInterval, Timeout.InfiniteTimeSpan);
}
/// <summary>The options this connection was built with.</summary>
internal ConnectionOptions Options => _o;
/// <summary>Test hook: keep-alives started (counted synchronously when the timer decides to send).</summary>
internal int KeepAlivesSent => Volatile.Read(ref _keepAlivesSent);
/// <summary>This connection's counters (its pool's, when it has one).</summary>
internal ConnectionDiagnostics Diagnostics => _diagnostics;
/// <summary>Test hook: true while a shared open is in progress.</summary>
internal bool OpenInProgress { get { lock (_sync) return _pendingOpen is not null; } }
private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
private TimeSpan ExchangeBudget => 2 * _o.Timeout;
public int MaxRequestBytes => _o.Routing switch
{
CipRouting.Connected => (Volatile.Read(ref _open)?.NegotiatedSize ?? Math.Min(_o.ConnectionSize, UnconnectedMessageLimit)) - 2,
CipRouting.UnconnectedSend => UnconnectedMessageLimit - (12 + _route.Length),
_ => UnconnectedMessageLimit,
};
public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken)
{
ThrowIfDisposed();
var open = await EnsureOpenAsync(cancellationToken).ConfigureAwait(false);
return await ExchangeAsync(open, cipRequest, cancellationToken).ConfigureAwait(false);
}
/// <summary>Returns the live (client, connection) pair, opening one if needed. The pair is returned
/// rather than re-read from fields afterwards, so a concurrent dispose can never hand the caller a
/// null (finding 16).</summary>
/// <remarks>All callers share ONE open, which runs under its own deadline only. A caller's token (or the
/// scheduler's stop token) ends that caller's <i>wait</i>, never the open itself: cancelling a Forward
/// Open mid-flight could leave a connection the target accepted but nobody ever closes. A completed
/// open is stored even if disposal began meanwhile, so <see cref="DisposeAsync"/> Forward Closes it.</remarks>
private Task<Open> EnsureOpenAsync(CancellationToken ct)
{
if (Volatile.Read(ref _open) is { Client.IsConnected: true } live) return Task.FromResult(live);
Task<Open> pending;
TaskCompletionSource<Open>? started = null;
lock (_sync)
{
ThrowIfDisposed();
if (_open is { Client.IsConnected: true } current) return Task.FromResult(current);
if (_pendingOpen is null)
{
started = new TaskCompletionSource<Open>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingOpen = started.Task;
}
pending = _pendingOpen;
}
if (started is not null)
{
// Every waiter may have abandoned the open (its own token); observe a failure so it never
// surfaces as UnobservedTaskException.
_ = started.Task.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
_ = RunOpenAsync(started);
}
return pending.WaitAsync(ct);
}
/// <summary>The shared open. Bounded by <see cref="OpenBudget"/> (ruling R9-3); its outcome, success or
/// failure, goes to every caller waiting on it.</summary>
private async Task RunOpenAsync(TaskCompletionSource<Open> result)
{
using var deadline = new CancellationTokenSource(OpenBudget, _time);
using var beforeAttempt = CancellationTokenSource.CreateLinkedTokenSource(deadline.Token, _disposing.Token);
Open? opened = null;
Exception? error = null;
try
{
Open? stale;
lock (_sync)
{
stale = _open;
Volatile.Write(ref _open, null);
}
if (stale is not null) await stale.Client.DisposeAsync().ConfigureAwait(false); // faulted: no UnRegister
if (!_predecessorClosed.IsCompleted) _diagnostics.CountPredecessorWait();
await _predecessorClosed.WaitAsync(beforeAttempt.Token).ConfigureAwait(false);
opened = await _throttle.RunAsync(_ => OpenAsync(deadline.Token), beforeAttempt.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
error = Volatile.Read(ref _disposed) != 0
? new AbTransportException(AbStatus.Abort, "Connection disposed.", ex)
: ex is OperationCanceledException && deadline.IsCancellationRequested
? new AbTransportException(AbStatus.Timeout,
$"Opening {_key.Host}:{_key.Port} did not complete within {OpenBudget.TotalMilliseconds:F0} ms.", ex)
: ex;
}
lock (_sync)
{
if (opened is not null) Volatile.Write(ref _open, opened);
_pendingOpen = null;
}
if (opened is not null) result.SetResult(opened);
else result.SetException(error!);
}
private async Task<Open> OpenAsync(CancellationToken ct)
{
ThrowIfDisposed(); // disposal may have begun while this attempt waited out the throttle
if (_diagnostics.AdmitOpenAttempt(_key) is { } refused) throw new AbTransportException(AbStatus.Abort, refused);
var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false);
if (Volatile.Read(ref _disposed) != 0)
{
// Disposal began during the TCP connect / RegisterSession: never put a Forward Open on the wire.
await client.DisposeAsync().ConfigureAwait(false);
throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
}
if (_o.Routing != CipRouting.Connected) return new Open(client, null, 0, UnconnectedMessageLimit);
try
{
var serial = NextConnectionSerial();
var toId = (uint)Random.Shared.NextInt64(1, uint.MaxValue);
var large = _o.ConnectionSize > 511;
var size = _o.ConnectionSize;
var reply = await ForwardOpenAsync(client, large, size, serial, toId, ct).ConfigureAwait(false);
if (large && ConnectionManagerServices.ShouldFallBackToRegularOpen(reply))
{
// Pre-v20 Logix / small-buffer targets: retry once, inside this same throttled attempt.
size = Math.Min(_o.ConnectionSize, UnconnectedMessageLimit);
reply = await ForwardOpenAsync(client, false, size, serial, toId, ct).ConfigureAwait(false);
}
return new Open(client, ConnectionManagerServices.ParseForwardOpenReply(reply), serial, size);
}
catch
{
await client.DisposeAsync().ConfigureAwait(false);
throw;
}
}
private async Task<CipReply> ForwardOpenAsync(EncapsulationClient client, bool large, int size, ushort serial,
uint toId, CancellationToken ct)
{
var p = new ForwardOpenParams(large, toId, serial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier,
_o.RpiMicros, size, _route);
var frame = ConnectionManagerServices.ForwardOpen(p);
_diagnostics.CountForwardOpen();
return CipReply.Parse(await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false));
}
private ushort NextConnectionSerial()
{
var s = (ushort)Interlocked.Increment(ref _connectionSerial);
return s == 0 ? (ushort)Interlocked.Increment(ref _connectionSerial) : s;
}
/// <summary>One request over an established pair, bounded by <see cref="ExchangeBudget"/>.</summary>
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Open open, ReadOnlyMemory<byte> cip, CancellationToken ct)
{
_diagnostics.CountRequest();
Interlocked.Exchange(ref _lastSendTimestamp, _time.GetTimestamp());
using var deadline = new CancellationTokenSource(ExchangeBudget, _time);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
var started = Stopwatch.GetTimestamp();
try
{
var reply = _o.Routing switch
{
CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, linked.Token).ConfigureAwait(false),
CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, linked.Token).ConfigureAwait(false),
_ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false),
};
_diagnostics.RecordExchange(Stopwatch.GetTimestamp() - started);
if (_o.Routing == CipRouting.Connected && ConnectionManagerServices.IsConnectionGone(reply.Span) is { } extended)
{
await RetireClosedByTargetAsync(open).ConfigureAwait(false);
throw new AbTransportException(AbStatus.BadConnection,
$"{_key.Host}:{_key.Port} no longer holds the CIP connection (general status 0x01, extended 0x{extended:X4}); " +
"the session was closed and the next request reopens it.");
}
return reply;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
{
throw new AbTransportException(AbStatus.Timeout,
$"Request to {_key.Host}:{_key.Port} did not complete within {ExchangeBudget.TotalMilliseconds:F0} ms.");
}
}
/// <summary>
/// The target answered over a session that is still up, but says the class-3 connection itself is gone (it
/// timed the connection out, or was restarted / re-downloaded and forgot it). Every later connected request
/// would get the same answer while <see cref="EncapsulationClient.IsConnected"/> stays true, so nothing else
/// would ever reopen it (Gitea #566): retire the pair now, so the next send reopens through the throttle.
/// No Forward Close — the target has just said there is nothing to close.
/// </summary>
private async Task RetireClosedByTargetAsync(Open open)
{
lock (_sync)
{
if (!ReferenceEquals(_open, open)) return; // a concurrent exchange (keep-alive) already retired it
Volatile.Write(ref _open, null);
}
_diagnostics.CountClosedByTarget();
await open.Client.DisposeAsync().ConfigureAwait(false); // UnRegisterSession, then close the socket
}
/// <summary>One-shot timer, re-armed after every tick so the longest idle gap is one
/// <see cref="ConnectionOptions.KeepAliveInterval"/> (half the connection expiry), not two.</summary>
private void OnKeepAliveTimer()
{
if (Volatile.Read(ref _disposed) != 0) return;
var interval = _o.KeepAliveInterval;
var open = Volatile.Read(ref _open);
if (open is not { Client.IsConnected: true })
{
// Nothing to keep alive, and a keep-alive must never be what reopens a connection.
Rearm(interval);
return;
}
var idle = _time.GetElapsedTime(Interlocked.Read(ref _lastSendTimestamp));
if (idle < interval)
{
Rearm(interval - idle);
return;
}
Interlocked.Increment(ref _keepAlivesSent);
_ = KeepAliveAsync(open);
}
private async Task KeepAliveAsync(Open open)
{
try { await ExchangeAsync(open, KeepAliveRequest, CancellationToken.None).ConfigureAwait(false); }
catch (Exception)
{
// A timeout or I/O failure has already faulted the session; the next real send reconnects
// through the throttle. Nothing to report here: the keep-alive has no caller.
}
finally { Rearm(_o.KeepAliveInterval); }
}
private void Rearm(TimeSpan due)
{
if (Volatile.Read(ref _disposed) != 0) return;
try { _keepAlive?.Change(due, Timeout.InfiniteTimeSpan); }
catch (ObjectDisposedException) { /* disposed concurrently */ }
}
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
}
/// <summary>Stops the keep-alive, sends Forward Close (Connected only) and closes the session, whose
/// own dispose sends UnRegisterSession. Later sends throw <see cref="AbStatus.Abort"/>.</summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
// Under the lock, so no caller can start a new open after this point. Then cancel the shared
// open's pre-attempt waits; an attempt already on the wire is allowed to finish (bounded by
// OpenBudget) so that whatever it opened is Forward Closed below.
Task<Open>? pending;
lock (_sync)
{
Volatile.Write(ref _disposed, 1);
pending = _pendingOpen;
}
await _disposing.CancelAsync().ConfigureAwait(false);
if (pending is not null)
{
try { await pending.ConfigureAwait(false); }
catch (Exception) { /* its callers have the error; nothing was opened */ }
}
Open? open;
lock (_sync)
{
open = _open;
Volatile.Write(ref _open, null);
}
if (_keepAlive is not null) await _keepAlive.DisposeAsync().ConfigureAwait(false);
if (open is null) return;
if (open.Conn is not null && open.Client.IsConnected)
{
try
{
var budget = TimeSpan.FromSeconds(1);
using var cts = new CancellationTokenSource(budget, _time);
_diagnostics.CountForwardCloseSent();
await open.Client.SendRRDataAsync(
ConnectionManagerServices.ForwardClose(open.ConnectionSerial, _o.VendorId, _o.OriginatorSerial, _route),
budget, cts.Token).ConfigureAwait(false);
_diagnostics.CountForwardCloseAcked();
}
catch (Exception ex) when (ex is AbTransportException or OperationCanceledException)
{
// The target expires the connection after RPI × multiplier anyway.
}
}
await open.Client.DisposeAsync().ConfigureAwait(false);
}
}
@@ -0,0 +1,153 @@
using System.Globalization;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
public static class CipPath
{
/// <summary>Route path from "port,link[,port,link…]". Links that are not a byte value are IP
/// addresses, encoded as extended link-address port segments.</summary>
public static byte[] EncodeRoute(string cipPath)
{
if (string.IsNullOrWhiteSpace(cipPath)) return [];
var w = new CipWriter();
var parts = cipPath.Split(',', StringSplitOptions.TrimEntries);
if (parts.Length % 2 != 0) throw new ArgumentException($"CIP path '{cipPath}' must be port,link pairs.", nameof(cipPath));
for (var i = 0; i < parts.Length; i += 2)
{
if (!ushort.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out var port))
throw new ArgumentException($"CIP path '{cipPath}' has a port '{parts[i]}' that is not 0..65535.", nameof(cipPath));
var link = parts[i + 1];
// A link is a slot byte or an IP address/hostname; neither contains whitespace. Without this a
// typo like "1,2 3" would be sent as an ASCII link "2 3" instead of failing here.
if (link.Length == 0 || link.Any(char.IsWhiteSpace))
throw new ArgumentException($"CIP path '{cipPath}' has an empty or malformed link '{link}'.", nameof(cipPath));
var isByte = byte.TryParse(link, NumberStyles.None, CultureInfo.InvariantCulture, out var linkByte);
byte[] a = [];
if (!isByte)
{
// The link-address length rides in one byte, and a non-ASCII char would be sent as '?'.
a = EncodeAscii(link, cipPath, nameof(cipPath), "link");
}
if (port < 15)
{
if (isByte) w.U8((byte)port).U8(linkByte);
else w.U8((byte)(0x10 | port)).U8((byte)a.Length).Bytes(a).PadToEven();
}
else
{
if (isByte) w.U8(0x0F).U16(port).U8(linkByte);
else w.U8(0x1F).U8((byte)a.Length).U16(port).Bytes(a).PadToEven();
}
}
// The route size is carried in words in one byte (Unconnected Send / Forward Open paths).
if (w.Length > MaxPathBytes)
throw new ArgumentException($"CIP path '{cipPath}' encodes to {w.Length} bytes; the maximum is {MaxPathBytes}.", nameof(cipPath));
return w.ToArray();
}
/// <summary>Splits a trailing purely numeric member ("Tag.5") into the tag name and bit index.</summary>
public static (string Name, int? Bit) SplitBitSelector(string tagName)
{
var dot = tagName.LastIndexOf('.');
if (dot > 0 && dot < tagName.Length - 1 && tagName[(dot + 1)..].All(char.IsAsciiDigit))
{
if (!int.TryParse(tagName[(dot + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var bit))
throw new ArgumentException($"Bit selector in '{tagName}' is out of range.", nameof(tagName));
return (tagName[..dot], bit);
}
return (tagName, null);
}
/// <exception cref="ArgumentException">The name is empty, has an empty member, an over-long (&gt; 255) or
/// non-ASCII member, an unterminated / non-numeric subscript, or ends in a bit selector.</exception>
public static byte[] EncodeSymbolic(string tagName)
{
if (string.IsNullOrEmpty(tagName))
throw new ArgumentException("Tag name is empty.", nameof(tagName));
if (SplitBitSelector(tagName).Bit is not null)
throw new ArgumentException($"'{tagName}' ends in a bit selector; strip it with SplitBitSelector first.", nameof(tagName));
var w = new CipWriter();
foreach (var part in SplitMembers(tagName))
{
var bracket = part.IndexOf('[');
var name = bracket < 0 ? part : part[..bracket];
if (name.Length == 0)
throw new ArgumentException($"Tag name '{tagName}' has an empty member.", nameof(tagName));
var a = EncodeAscii(name, tagName, nameof(tagName), "member");
w.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
if (bracket >= 0)
{
var close = part.IndexOf(']', bracket);
if (close != part.Length - 1)
throw new ArgumentException($"Tag name '{tagName}' has an unterminated or misplaced subscript in '{part}'.", nameof(tagName));
foreach (var idxText in part[(bracket + 1)..close].Split(',', StringSplitOptions.TrimEntries))
{
if (!uint.TryParse(idxText, NumberStyles.None, CultureInfo.InvariantCulture, out var idx))
throw new ArgumentException($"Tag name '{tagName}' has a subscript '{idxText}' that is not an unsigned integer.", nameof(tagName));
if (idx < 0x100) w.U8(0x28).U8((byte)idx);
else if (idx < 0x10000) w.U8(0x29).U8(0).U16((ushort)idx);
else w.U8(0x2A).U8(0).U32(idx);
}
}
}
return w.ToArray();
}
/// <summary>ASCII bytes of a name / link whose length rides in a single byte.</summary>
private static byte[] EncodeAscii(string text, string whole, string paramName, string what)
{
if (text.Length > byte.MaxValue)
throw new ArgumentException($"'{whole}' has a {what} longer than 255 characters.", paramName);
foreach (var c in text)
if (!char.IsAscii(c))
throw new ArgumentException($"'{whole}' has a {what} with a non-ASCII character.", paramName);
return Encoding.ASCII.GetBytes(text);
}
/// <summary>Split on '.' outside brackets, keeping "Program:X" whole.</summary>
private static IEnumerable<string> SplitMembers(string name)
{
var depth = 0; var start = 0;
for (var i = 0; i < name.Length; i++)
{
if (name[i] == '[') depth++;
else if (name[i] == ']') depth--;
else if (name[i] == '.' && depth == 0) { yield return name[start..i]; start = i + 1; }
}
yield return name[start..];
}
public static byte[] EncodeClassInstance(ushort cls, uint instance)
{
var w = new CipWriter();
if (cls < 0x100) w.U8(0x20).U8((byte)cls); else w.U8(0x21).U8(0).U16(cls);
if (instance < 0x100) w.U8(0x24).U8((byte)instance);
else if (instance < 0x10000) w.U8(0x25).U8(0).U16((ushort)instance);
else w.U8(0x26).U8(0).U32(instance);
return w.ToArray();
}
/// <summary>Prefixes the path with its size in 16-bit words.</summary>
/// <exception cref="ArgumentException">The segments are not a whole number of 16-bit words, or exceed
/// the 255-word maximum the one-byte size field can carry.</exception>
public static byte[] WithPathSize(byte[] segments)
{
if ((segments.Length & 1) != 0)
throw new ArgumentException($"A CIP path is whole 16-bit words; got {segments.Length} bytes.", nameof(segments));
if (segments.Length > MaxPathBytes)
throw new ArgumentException($"A CIP path is at most {MaxPathBytes} bytes (255 words); got {segments.Length}.", nameof(segments));
var w = new CipWriter();
w.U8((byte)(segments.Length / 2)).Bytes(segments);
return w.ToArray();
}
/// <summary>The path-size field is one byte of 16-bit words.</summary>
private const int MaxPathBytes = 255 * 2;
/// <summary>Message Router (class 2, instance 1).</summary>
public static ReadOnlySpan<byte> MessageRouter => [0x20, 0x02, 0x24, 0x01];
/// <summary>Connection Manager (class 6, instance 1).</summary>
public static ReadOnlySpan<byte> ConnectionManager => [0x20, 0x06, 0x24, 0x01];
}
@@ -0,0 +1,41 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general
/// status, extended status words, and the reply data.</summary>
public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory<byte> Data)
{
// Record equality over an array / ReadOnlyMemory is reference equality; two parses of the same wire
// bytes must compare equal, so compare the extended-status words and the data by content.
public bool Equals(CipReply? other) =>
other is not null
&& Service == other.Service
&& GeneralStatus == other.GeneralStatus
&& ExtendedStatus.AsSpan().SequenceEqual(other.ExtendedStatus)
&& Data.Span.SequenceEqual(other.Data.Span);
public override int GetHashCode()
{
var h = new HashCode();
h.Add(Service);
h.Add(GeneralStatus);
foreach (var e in ExtendedStatus) h.Add(e);
h.AddBytes(Data.Span);
return h.ToHashCode();
}
public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);
public static CipReply Parse(ReadOnlyMemory<byte> raw)
{
var r = new CipReader(raw.Span);
var service = (byte)(r.U8() & 0x7F);
r.U8(); // reserved
var general = r.U8();
var extWords = r.U8();
var ext = new ushort[extWords];
for (var i = 0; i < extWords; i++) ext[i] = r.U16();
var data = raw[r.Position..];
return new CipReply(service, general, ext, data);
}
}
@@ -0,0 +1,163 @@
using System.Diagnostics;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>
/// Counters and the open-attempt limit for the <see cref="CipConnection"/>s one <see cref="DeviceSessionPool"/>
/// owns. Gate and harness diagnostics: nothing in production reads them, and a limit is only ever set by the
/// read-only differential harness.
/// </summary>
/// <remarks>
/// Instance-scoped (Gitea #581): every pool — and every connection a test builds on its own — has its own, so
/// no process-wide mutable state ships in the transport, and two tests can never move each other's counts. The
/// open-attempt limit is scoped to one <see cref="DeviceKey"/> (Gitea #566), so limiting the press under test
/// can never refuse an open to any other device in the process.
/// </remarks>
internal sealed class ConnectionDiagnostics
{
private readonly object _lock = new();
private readonly Dictionary<DeviceKey, OpenAttemptLimit> _limits = new();
private int _forwardOpens;
private int _forwardClosesSent;
private int _forwardClosesAcked;
private int _requestsSent;
private int _openAttempts;
private int _predecessorWaits;
private int _connectionsClosedByTarget;
// Round-trip statistics of completed exchanges (T21 latency attribution). Two Stopwatch reads and four
// interlocked ops per request; reset by the harness between timed passes.
private long _exchangeCount;
private long _exchangeTotalTicks;
private long _exchangeMinTicks = long.MaxValue;
private long _exchangeMaxTicks;
/// <summary>Forward Open frames sent (fallback included).</summary>
public int ForwardOpens => Volatile.Read(ref _forwardOpens);
/// <summary>Forward Close requests put on the wire.</summary>
public int ForwardClosesSent => Volatile.Read(ref _forwardClosesSent);
/// <summary>Forward Close requests that got a reply within their budget.</summary>
public int ForwardClosesAcked => Volatile.Read(ref _forwardClosesAcked);
/// <summary>Channel sends (keep-alives included).</summary>
public int RequestsSent => Volatile.Read(ref _requestsSent);
/// <summary>Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included), counted
/// before any I/O, so an attempt refused by a limit counts too.</summary>
public int OpenAttempts => Volatile.Read(ref _openAttempts);
/// <summary>Opens that found an earlier connection for the same device still closing and parked behind it.</summary>
public int PredecessorWaits => Volatile.Read(ref _predecessorWaits);
/// <summary>Connections retired because the target reported the CIP connection itself gone (TCP still up).</summary>
public int ConnectionsClosedByTarget => Volatile.Read(ref _connectionsClosedByTarget);
/// <summary>Zeroes the Forward Open / Forward Close counters (the harness, before its native phase).</summary>
public void ResetForwardCounts()
{
Interlocked.Exchange(ref _forwardOpens, 0);
Interlocked.Exchange(ref _forwardClosesSent, 0);
Interlocked.Exchange(ref _forwardClosesAcked, 0);
}
internal void CountForwardOpen() => Interlocked.Increment(ref _forwardOpens);
internal void CountForwardCloseSent() => Interlocked.Increment(ref _forwardClosesSent);
internal void CountForwardCloseAcked() => Interlocked.Increment(ref _forwardClosesAcked);
internal void CountRequest() => Interlocked.Increment(ref _requestsSent);
internal void CountPredecessorWait() => Interlocked.Increment(ref _predecessorWaits);
internal void CountClosedByTarget() => Interlocked.Increment(ref _connectionsClosedByTarget);
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
/// <summary>Clears the round-trip statistics.</summary>
public void ResetExchangeStats()
{
Interlocked.Exchange(ref _exchangeCount, 0);
Interlocked.Exchange(ref _exchangeTotalTicks, 0);
Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
Interlocked.Exchange(ref _exchangeMaxTicks, 0);
}
/// <summary>Snapshot of the round-trip statistics.</summary>
public ExchangeStats ReadExchangeStats()
{
var count = Interlocked.Read(ref _exchangeCount);
if (count == 0) return new ExchangeStats(0, 0, 0, 0);
static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
}
internal void RecordExchange(long ticks)
{
Interlocked.Increment(ref _exchangeCount);
Interlocked.Add(ref _exchangeTotalTicks, ticks);
long seen;
while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
&& Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
&& Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
}
/// <summary>
/// Caps the open attempts to <paramref name="key"/> at <paramref name="attempts"/> until the returned scope is
/// disposed. An attempt beyond the cap fails with <see cref="AbStatus.Abort"/> before any I/O — no TCP connect,
/// no Forward Open — so a press that refuses or drops the connection sees exactly the budgeted attempts instead
/// of one per remaining operation. Only the read-only differential harness (Task 18/21) sets one; one limit per
/// key at a time.
/// </summary>
public OpenAttemptLimit LimitOpenAttempts(DeviceKey key, int attempts)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentOutOfRangeException.ThrowIfNegative(attempts);
var limit = new OpenAttemptLimit(this, key, attempts);
lock (_lock)
{
if (!_limits.TryAdd(key, limit))
throw new InvalidOperationException($"An open-attempt limit for {key.Host}:{key.Port} is already in force.");
}
return limit;
}
/// <summary>True while a limit is in force for <paramref name="key"/>.</summary>
public bool HasOpenAttemptLimit(DeviceKey key) { lock (_lock) return _limits.ContainsKey(key); }
/// <summary>Counts one open attempt to <paramref name="key"/>; null when it may proceed, else why it may not.</summary>
internal string? AdmitOpenAttempt(DeviceKey key)
{
var attempt = Interlocked.Increment(ref _openAttempts);
OpenAttemptLimit? limit;
lock (_lock) _limits.TryGetValue(key, out limit);
return limit is null || limit.TryTake()
? null
: $"Open attempt {attempt} to {key.Host}:{key.Port} refused before any I/O: the open-attempt budget of {limit.Budget} for this device is spent.";
}
private void Remove(OpenAttemptLimit limit)
{
lock (_lock)
{
if (_limits.TryGetValue(limit.Key, out var current) && current == limit) _limits.Remove(limit.Key);
}
}
/// <summary>An open-attempt cap on one device; disposing it lifts the cap.</summary>
internal sealed class OpenAttemptLimit(ConnectionDiagnostics owner, DeviceKey key, int budget) : IDisposable
{
private int _attempts;
public DeviceKey Key { get; } = key;
public int Budget { get; } = budget;
/// <summary>Attempts counted against this limit, the refused ones included.</summary>
public int Attempts => Volatile.Read(ref _attempts);
internal bool TryTake() => Interlocked.Increment(ref _attempts) <= Budget;
public void Dispose() => owner.Remove(this);
}
}
@@ -0,0 +1,113 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>Parameters for a (Large) Forward Open. <see cref="ConnectionSize"/> is the maximum CIP
/// connection payload in bytes (e.g. 4002 for Large, 504 for regular).</summary>
public sealed record ForwardOpenParams(
bool Large, uint TOConnectionId, ushort ConnectionSerial, ushort VendorId, uint OriginatorSerial,
byte TimeoutMultiplier, uint RpiMicros, int ConnectionSize, byte[] Route);
public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);
/// <summary>A target refused a Forward Open. The refusal is data, not just message text: callers (and
/// diagnostics) read <see cref="GeneralStatus"/> / <see cref="ExtendedStatus"/> directly.</summary>
public sealed class ForwardOpenRefusedException : AbTransportException
{
public ForwardOpenRefusedException(AbStatus status, byte generalStatus, IReadOnlyList<ushort> extendedStatus)
: base(status, $"Forward Open refused: general 0x{generalStatus:X2}, extended {string.Join(",", extendedStatus.Select(e => $"0x{e:X4}"))}.")
{
GeneralStatus = generalStatus;
ExtendedStatus = [.. extendedStatus]; // defensive copy: the exception is immutable
}
/// <summary>The CIP general status of the refusal.</summary>
public byte GeneralStatus { get; }
/// <summary>The CIP extended-status words of the refusal (may be empty).</summary>
public IReadOnlyList<ushort> ExtendedStatus { get; }
}
/// <summary>Connection Manager (class 6) request builders and reply parsers.</summary>
public static class ConnectionManagerServices
{
/// <summary>Priority/time-tick byte.</summary>
public const byte PriorityTick = 0x0A;
/// <summary>Timeout ticks. The brief proposed 0x0E; every libplctag capture (Forward Open, Forward
/// Close, Unconnected Send) carries 0x05, and the capture wins.</summary>
public const byte TimeoutTicks = 0x05;
public static byte[] UnconnectedSend(ReadOnlySpan<byte> embedded, ReadOnlySpan<byte> route)
{
var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
.U8(PriorityTick).U8(TimeoutTicks).U16((ushort)embedded.Length).Bytes(embedded);
if ((embedded.Length & 1) == 1) w.U8(0);
return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
}
public static byte[] ForwardOpen(ForwardOpenParams p)
{
if (p.ConnectionSize < 1 || p.ConnectionSize > (p.Large ? 0xFFFF : 0x1FF))
throw new ArgumentOutOfRangeException(nameof(p), p.ConnectionSize,
p.Large ? "Large connection size must be 1..65535." : "Regular connection size must be 1..511.");
var connPath = new CipWriter().Bytes(p.Route).Bytes(CipPath.MessageRouter).ToArray();
var w = new CipWriter().U8(p.Large ? (byte)0x5B : (byte)0x54).U8(2).Bytes(CipPath.ConnectionManager)
.U8(PriorityTick).U8(TimeoutTicks)
.U32(0).U32(p.TOConnectionId).U16(p.ConnectionSerial).U16(p.VendorId).U32(p.OriginatorSerial)
.U8(p.TimeoutMultiplier).U8(0).U8(0).U8(0);
WriteSide(w, p);
WriteSide(w, p);
return w.U8(0xA3).U8((byte)(connPath.Length / 2)).Bytes(connPath).ToArray();
}
private static void WriteSide(CipWriter w, ForwardOpenParams p)
{
w.U32(p.RpiMicros);
if (p.Large) w.U32(0x42000000u | (uint)p.ConnectionSize);
else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
}
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
{
if (reply.GeneralStatus != 0)
throw new ForwardOpenRefusedException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
reply.GeneralStatus, reply.ExtendedStatus);
var r = new CipReader(reply.Data.Span);
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
}
/// <summary>True when a refused Large Forward Open should be retried as a regular one: general 0x08
/// (Service Not Supported — pre-v20 Logix) or general 0x01 / extended 0x0109 (invalid connection size).</summary>
public static bool ShouldFallBackToRegularOpen(CipReply r) =>
r.GeneralStatus == 0x08
|| (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
/// <summary>Connection Manager extended status: target connection not found.</summary>
public const ushort TargetConnectionNotFound = 0x0107;
/// <summary>Connection Manager extended status: the connection timed out.</summary>
public const ushort ConnectionTimedOut = 0x0203;
/// <summary>
/// For a reply received over a class-3 connection: the extended status when the target says the connection
/// itself no longer exists — general 0x01 (Connection Failure) with extended 0x0107 (target connection not
/// found) or 0x0203 (connection timed out) — else null. Any other status, a tag-level one included, describes
/// the request and leaves the connection alone.
/// </summary>
public static ushort? IsConnectionGone(ReadOnlySpan<byte> reply)
{
// Reply header: service | reserved | general status | extended-status word count | words...
if (reply.Length < 6 || reply[2] != 0x01 || reply[3] == 0) return null;
var extended = (ushort)(reply[4] | (reply[5] << 8));
return extended is TargetConnectionNotFound or ConnectionTimedOut ? extended : null;
}
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
{
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
return new CipWriter().U8(0x4E).U8(2).Bytes(CipPath.ConnectionManager).U8(PriorityTick).U8(TimeoutTicks)
.U16(connectionSerial).U16(vendorId).U32(originatorSerial)
.U8((byte)(connPath.Length / 2)).U8(0).Bytes(connPath).ToArray();
}
}
@@ -0,0 +1,100 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>How CIP requests reach the target's message router (review finding B3; the captures decide
/// each family's mode).</summary>
public enum CipRouting
{
/// <summary>Forward Open a class-3 connection, then SendUnitData. Logix and Micro800 (empty route).</summary>
Connected,
/// <summary>SendRRData wrapping each request in Unconnected Send (0x52) carrying the route.</summary>
UnconnectedSend,
/// <summary>SendRRData carrying the request bare; any configured CIP path is ignored (PCCC families).</summary>
Direct,
}
/// <summary>Per-device connection settings. Validated on assignment so an unusable value fails at the
/// configuration seam rather than on the wire.</summary>
public sealed record ConnectionOptions
{
/// <summary>Press safety floor: no setting may let Forward Opens to one device start closer together.</summary>
public static readonly TimeSpan MinimumForwardOpenInterval = TimeSpan.FromSeconds(1);
/// <summary>Originator serial shared by every connection this process opens.</summary>
private static readonly uint ProcessOriginatorSerial = (uint)Random.Shared.Next(1, int.MaxValue);
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(5);
private readonly int _connectionSize = 4002;
private readonly TimeSpan _minForwardOpenInterval = MinimumForwardOpenInterval;
private readonly uint _rpiMicros = 2_000_000;
private readonly byte _timeoutMultiplier = 1;
private readonly TimeSpan? _keepAliveInterval;
public CipRouting Routing { get; init; } = CipRouting.Connected;
/// <summary>Pack queued requests into Multiple Service Packets (the scheduler's switch).</summary>
public bool AllowPacking { get; init; }
/// <summary>Bound on each single exchange (TCP connect, RegisterSession, Forward Open, request).
/// Must be finite and positive (ruling R9-2): an infinite timeout would let an abandoned exchange
/// hold the session's single-flight gate forever.</summary>
public TimeSpan Timeout
{
get => _timeout;
init => _timeout = value > TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan
? value
: throw new ArgumentOutOfRangeException(nameof(Timeout), value, "Timeout must be finite and positive.");
}
/// <summary>Requested connection size in bytes. Above 511 the open is a Large Forward Open (0x5B).</summary>
public int ConnectionSize
{
get => _connectionSize;
init => _connectionSize = value is >= 1 and <= ushort.MaxValue
? value
: throw new ArgumentOutOfRangeException(nameof(ConnectionSize), value, "Connection size must be 1..65535.");
}
/// <summary>Minimum time between Forward Open attempt starts for one device (≥ 1 s, press safety).</summary>
public TimeSpan MinForwardOpenInterval
{
get => _minForwardOpenInterval;
init => _minForwardOpenInterval = value >= MinimumForwardOpenInterval && value != System.Threading.Timeout.InfiniteTimeSpan
? value
: throw new ArgumentOutOfRangeException(nameof(MinForwardOpenInterval), value, "Must be finite and at least 1 s (press safety).");
}
/// <summary>Requested packet interval for both directions, in microseconds.</summary>
public uint RpiMicros
{
get => _rpiMicros;
init => _rpiMicros = value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(RpiMicros), value, "RPI must be positive.");
}
/// <summary>Encoded connection timeout multiplier: the connection expires after RPI × (4 &lt;&lt; value).
/// The default 1 means ×8, so 2 s × 8 = 16 s.</summary>
public byte TimeoutMultiplier
{
get => _timeoutMultiplier;
init => _timeoutMultiplier = value <= 7 ? value : throw new ArgumentOutOfRangeException(nameof(TimeoutMultiplier), value, "Multiplier must be 0..7.");
}
/// <summary>How long an idle connected session waits before the target would drop it.</summary>
public TimeSpan ConnectionExpiry => TimeSpan.FromTicks(RpiMicros * 10L * (4L << TimeoutMultiplier));
/// <summary>Idle time after which a keep-alive is sent (Connected only). Defaults to half the
/// connection expiry: RPI × 8 / 2 = 8 s.</summary>
public TimeSpan KeepAliveInterval
{
get => _keepAliveInterval ?? ConnectionExpiry / 2;
init => _keepAliveInterval = value > TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan
? value
: throw new ArgumentOutOfRangeException(nameof(KeepAliveInterval), value, "Keep-alive interval must be finite and positive.");
}
public ushort VendorId { get; init; } = 0x1337;
/// <summary>Random per process; together with the vendor id and connection serial it names a connection.</summary>
public uint OriginatorSerial { get; init; } = ProcessOriginatorSerial;
}
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>
/// Identity of one physical CIP connection: exactly one <see cref="CipConnection"/> (and one Forward Open
/// throttle) exists per key (press safety). <see cref="Host"/> and <see cref="PlcAttribute"/> compare
/// case-insensitively; <see cref="Port"/> compares exactly; <see cref="CipPath"/> is canonicalised (each
/// comma-separated part trimmed, so <c>"1,0"</c> and <c>" 1, 0 "</c> are the same device) and then
/// compares exactly. Whitespace INSIDE a part is kept, so a typo such as <c>"1,2 3"</c> still fails loudly in
/// <see cref="Cip.CipPath.EncodeRoute"/> instead of silently becoming slot 23.
/// </summary>
public sealed record DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)
{
private readonly string _cipPath = Canonicalise(CipPath);
/// <summary>The route with each part trimmed. Canonicalised on construction and on <c>with</c>.</summary>
public string CipPath
{
get => _cipPath;
init => _cipPath = Canonicalise(value);
}
private static string Canonicalise(string? path) =>
string.IsNullOrWhiteSpace(path) ? "" : string.Join(',', path.Split(',').Select(p => p.Trim()));
public bool Equals(DeviceKey? other) =>
other is not null
&& string.Equals(Host, other.Host, StringComparison.OrdinalIgnoreCase)
&& Port == other.Port
&& string.Equals(CipPath, other.CipPath, StringComparison.Ordinal)
&& string.Equals(PlcAttribute, other.PlcAttribute, StringComparison.OrdinalIgnoreCase);
public override int GetHashCode() => HashCode.Combine(
StringComparer.OrdinalIgnoreCase.GetHashCode(Host),
Port,
StringComparer.Ordinal.GetHashCode(CipPath),
StringComparer.OrdinalIgnoreCase.GetHashCode(PlcAttribute));
}
@@ -0,0 +1,75 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>Press safety: at most one Forward Open attempt in flight per device and a minimum
/// interval between attempt starts. A misconfigured client once drove ~15 Forward Opens/s at a
/// production press (ignitionoee vite-press-oee-bind.md, 2026-09-23); this is what prevents it.</summary>
/// <remarks>One instance per <see cref="DeviceKey"/>, kept by <see cref="DeviceSessionPool"/> for the
/// process lifetime so a reconnect after the last lease was released is still limited. Its semaphore is
/// therefore never disposed. The interval is measured from the previous attempt's <i>start</i>, whatever
/// that attempt's outcome.</remarks>
public sealed class ForwardOpenThrottle
{
private readonly SemaphoreSlim _inFlight = new(1, 1);
private readonly TimeSpan _minInterval;
private readonly TimeProvider _time;
private long? _lastStartTimestamp; // monotonic (TimeProvider.GetTimestamp); only touched while holding _inFlight
private int _queued;
private Task? _intervalWait; // the pending interval wait (test hook only)
/// <remarks>Internal: the only legitimate owner is <see cref="DeviceSessionPool"/>, which keeps exactly
/// one per device. A second throttle for the same device would halve the guaranteed interval.</remarks>
internal ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time)
{
if (minInterval < TimeSpan.Zero || minInterval == Timeout.InfiniteTimeSpan)
throw new ArgumentOutOfRangeException(nameof(minInterval), minInterval, "Interval must be finite and non-negative.");
_minInterval = minInterval;
_time = time ?? throw new ArgumentNullException(nameof(time));
}
/// <summary>Test hook: callers waiting behind an in-flight attempt.</summary>
internal int Queued => Volatile.Read(ref _queued);
/// <summary>Test hook: true while the next attempt is parked on the minimum interval and that wait has not
/// yet elapsed — no attempt is then in flight, and none can start until the clock moves.</summary>
internal bool IsWaitingOutInterval => Volatile.Read(ref _intervalWait) is { IsCompleted: false };
/// <summary>Run one open attempt once the throttle allows it. <paramref name="ct"/> bounds only the wait
/// BEFORE the attempt (queued behind an in-flight attempt, or the interval): when it fires there, this
/// throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>, "Forward Open
/// throttled"). <paramref name="ct"/> is also passed to <paramref name="open"/>, which may ignore it:
/// <see cref="CipConnection"/> does, running the attempt under its own open deadline so that an accepted
/// Forward Open is never abandoned without a Forward Close.</summary>
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(open);
Interlocked.Increment(ref _queued);
try { await _inFlight.WaitAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{ throw Throttled("another attempt is in flight"); }
finally { Interlocked.Decrement(ref _queued); }
try
{
if (_lastStartTimestamp is { } last)
{
// Monotonic: a wall-clock step (NTP, DST, an operator) can never shorten the interval.
var wait = _minInterval - _time.GetElapsedTime(last);
if (wait > TimeSpan.Zero)
{
var delay = Task.Delay(wait, _time, ct);
Volatile.Write(ref _intervalWait, delay);
try { await delay.ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{ throw Throttled("the minimum interval had not elapsed"); }
finally { Volatile.Write(ref _intervalWait, null); }
}
}
_lastStartTimestamp = _time.GetTimestamp();
return await open(ct).ConfigureAwait(false);
}
finally { _inFlight.Release(); }
}
private static AbTransportException Throttled(string why) =>
new(AbStatus.BadConnection, $"Forward Open throttled; caller deadline reached before {why}.");
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). Task 9
/// implements it; Task 10's scheduler and the native adapters consume it.</summary>
public interface ICipChannel
{
/// <summary>Largest CIP request (service byte through end of data) one call may carry.</summary>
int MaxRequestBytes { get; }
/// <summary>Send one CIP request and return the raw CIP reply bytes (reply-service byte first).
/// Throws <see cref="AbTransportException"/> on transport failure or timeout.</summary>
Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken);
}
@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>Multiple Service Packet (service 0x0A on the Message Router).</summary>
public static class MultipleServicePacket
{
/// <summary>Service + path size + Message Router path.</summary>
private const int HeaderSize = 6;
public static byte[] Build(IReadOnlyList<byte[]> requests)
{
var w = new CipWriter().U8(0x0A).U8(2).Bytes(CipPath.MessageRouter).U16((ushort)requests.Count);
var offset = 2 + 2 * requests.Count; // measured from the start of the count field
foreach (var r in requests) { w.U16((ushort)offset); offset += r.Length; }
foreach (var r in requests) w.Bytes(r);
return w.ToArray();
}
/// <summary>Exact byte size of an MSP carrying requests of the given sizes.</summary>
public static int BuildSize(IReadOnlyList<int> requestSizes)
{
var total = HeaderSize + 2 + 2 * requestSizes.Count;
foreach (var s in requestSizes) total += s;
return total;
}
/// <summary>Splits an MSP reply (the outer reply's data) into the embedded replies.</summary>
public static IReadOnlyList<CipReply> Parse(CipReply reply, int count)
{
var data = reply.Data;
var r = new CipReader(data.Span);
var n = r.U16();
if (n != count)
throw new AbTransportException(AbStatus.BadReply, $"MSP reply carries {n} replies, expected {count}.");
var offsets = new int[n];
for (var i = 0; i < n; i++) offsets[i] = r.U16();
var result = new CipReply[n];
for (var i = 0; i < n; i++)
{
var end = i + 1 < n ? offsets[i + 1] : data.Length;
if (offsets[i] < 2 + 2 * n || end < offsets[i] || end > data.Length)
throw new AbTransportException(AbStatus.BadReply, $"MSP reply offset {offsets[i]}..{end} is out of range.");
result[i] = CipReply.Parse(data[offsets[i]..end]);
}
return result;
}
}
@@ -0,0 +1,348 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>
/// Per-connection CIP request scheduler. Keeps exactly one request in flight on its
/// <see cref="ICipChannel"/>; requests that arrive while one is in flight queue, and when the channel
/// frees the scheduler packs as many of them (FIFO) as fit in <see cref="ICipChannel.MaxRequestBytes"/>
/// into one Multiple Service Packet. Each waiter receives its own embedded <see cref="CipReply"/>.
/// </summary>
/// <remarks>
/// One scheduler per device connection, never per tag (review finding B1). The channel is always
/// called with the scheduler's own stop token, never a waiter's, so one caller's cancellation can
/// never abort or fault a shared exchange (B2). A waiter cancelled while queued is removed and never
/// sent; one cancelled while its batch is in flight is abandoned and the batch's other waiters still
/// complete. There is deliberately no batching delay window: packing falls out of the single pump
/// being busy on the channel while requests accumulate.
/// </remarks>
public sealed class RequestScheduler : IAsyncDisposable
{
private const byte MultipleServiceCode = 0x0A;
private const byte EmbeddedServiceError = 0x1E;
/// <summary>Smallest possible CIP reply: service, reserved, general status, additional-status size.</summary>
public const int ReplyHeaderBytes = 4;
/// <summary>The <c>expectedReplyBytes</c> default: the reply carries no data beyond its header (a
/// write, a status-only service). Every caller whose reply carries data must pass its real bound.</summary>
public const int StatusOnlyReply = ReplyHeaderBytes;
/// <summary>Pass as <c>expectedReplyBytes</c> when the reply size cannot be bounded: the request is
/// then sent alone, never packed, because its reply could fill the whole connection.</summary>
public const int UnknownReply = -1;
private sealed class Pending(byte[] request, bool exclusive, int expectedReplyBytes)
{
// Private lock object: lock(this) would let any holder of the Pending reference contend.
private readonly object _gate = new();
private bool _completed;
private CancellationTokenRegistration _registration;
private bool _hasRegistration;
public byte[] Request { get; } = request;
/// <summary>True when this request must never share a Multiple Service Packet with another.</summary>
public bool Exclusive { get; } = exclusive;
/// <summary>Expected whole-reply size (header included), or <see cref="UnknownReply"/>.</summary>
public int ExpectedReplyBytes { get; } = expectedReplyBytes;
public TaskCompletionSource<CipReply> Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public LinkedListNode<Pending>? Node { get; set; }
/// <summary>Stores the waiter's registration; disposes it at once if the waiter already completed.</summary>
public void SetRegistration(CancellationTokenRegistration registration, RequestScheduler owner)
{
lock (_gate)
{
if (!_completed)
{
_registration = registration;
_hasRegistration = true;
return;
}
}
registration.Dispose();
Interlocked.Decrement(ref owner._liveRegistrations);
}
/// <summary>Marks the waiter complete exactly once and releases its registration (finding 10).
/// Returns false if it was already completed.</summary>
private bool Finish(RequestScheduler owner)
{
CancellationTokenRegistration registration;
bool hasRegistration;
lock (_gate)
{
if (_completed) return false;
_completed = true;
registration = _registration;
hasRegistration = _hasRegistration;
_registration = default;
_hasRegistration = false;
}
if (hasRegistration)
{
// Safe from inside the registration's own callback: Dispose does not wait on the
// callback that is currently executing on this thread.
registration.Dispose();
Interlocked.Decrement(ref owner._liveRegistrations);
}
return true;
}
public void Succeed(CipReply reply, RequestScheduler owner)
{
if (Finish(owner)) Tcs.TrySetResult(reply);
}
public void Fail(Exception ex, RequestScheduler owner)
{
if (Finish(owner)) Tcs.TrySetException(ex);
}
public void Cancel(CancellationToken ct, RequestScheduler owner)
{
if (Finish(owner)) Tcs.TrySetCanceled(ct);
}
}
private readonly ICipChannel _channel;
private readonly bool _allowPacking;
private readonly LinkedList<Pending> _queue = new();
private readonly object _lock = new();
private readonly SemaphoreSlim _signal = new(0);
private readonly CancellationTokenSource _stop = new();
private readonly Task _pump;
private bool _disposed;
private int _liveRegistrations;
private int _cancellationCallbacksRun;
public RequestScheduler(ICipChannel channel, bool allowPacking)
{
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
_allowPacking = allowPacking;
_pump = Task.Run(PumpAsync);
}
/// <summary>Test hook: waiter cancellation registrations that have not yet been disposed.</summary>
internal int LiveRegistrations => Volatile.Read(ref _liveRegistrations);
/// <summary>Test hook: waiter cancellation callbacks that have actually run. A disposed
/// registration never runs, so this stays flat when a long-lived token is cancelled later.</summary>
internal int CancellationCallbacksRun => Volatile.Read(ref _cancellationCallbacksRun);
/// <summary>Test hook: requests still queued (not yet taken into a batch).</summary>
internal int QueuedCount { get { lock (_lock) return _queue.Count; } }
/// <summary>
/// Queue one CIP request. The returned reply carries the service's own general status; a non-zero
/// status is not an exception here. Faults with <see cref="AbTransportException"/> when the channel
/// or the MSP envelope fails, or the scheduler is disposed; cancels only when <paramref name="ct"/> fires.
/// </summary>
/// <param name="request">The CIP request.</param>
/// <param name="ct">Cancels this waiter only.</param>
/// <param name="exclusive">When true the request is sent alone, never packed into a Multiple Service
/// Packet: for requests whose reply can fill the whole connection.</param>
/// <param name="expectedReplyBytes">Upper bound on this request's whole reply (header included) as
/// it would sit inside a Multiple Service Packet reply. The packer budgets this against the
/// connection size as well as the request bytes (#582), so a batch of small requests with large
/// replies (STRINGs, arrays) is split instead of overflowing. Defaults to <see cref="StatusOnlyReply"/>;
/// <see cref="UnknownReply"/> sends the request alone.</param>
public Task<CipReply> SendAsync(byte[] request, CancellationToken ct, bool exclusive = false,
int expectedReplyBytes = StatusOnlyReply)
{
ArgumentNullException.ThrowIfNull(request);
if (ct.IsCancellationRequested) return Task.FromCanceled<CipReply>(ct);
var p = new Pending(request, exclusive || expectedReplyBytes < 0, Math.Max(expectedReplyBytes, UnknownReply));
lock (_lock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
p.Node = _queue.AddLast(p);
}
if (ct.CanBeCanceled)
{
Interlocked.Increment(ref _liveRegistrations);
var registration = ct.Register(static state =>
{
var (self, pending, token) = ((RequestScheduler, Pending, CancellationToken))state!;
Interlocked.Increment(ref self._cancellationCallbacksRun);
lock (self._lock)
{
if (pending.Node?.List is not null) self._queue.Remove(pending.Node);
}
pending.Cancel(token, self);
}, (this, p, ct));
p.SetRegistration(registration, this);
}
lock (_lock)
{
if (!_disposed) _signal.Release();
}
return p.Tcs.Task;
}
/// <summary>Moves the next batch from the queue into <paramref name="batch"/>. Filling a caller-owned
/// list means a throw part-way through still leaves every taken waiter reachable for faulting.</summary>
private void TakeBatch(List<Pending> batch)
{
lock (_lock)
{
var sizes = new List<int>();
var replySizes = new List<int>();
var replyBudget = _channel.MaxRequestBytes; // connections are symmetric: one size bounds both directions
while (_queue.First is { } n)
{
if (batch.Count > 0)
{
if (!_allowPacking || n.Value.Exclusive) break;
sizes.Add(n.Value.Request.Length);
replySizes.Add(n.Value.ExpectedReplyBytes);
if (MultipleServicePacket.BuildSize(sizes) > _channel.MaxRequestBytes
|| ReplyEnvelopeSize(replySizes) > replyBudget)
{
break;
}
}
else
{
// The first request always goes, even when it alone exceeds the limit: it is sent
// bare, and callers that must fit (LogixTagClient) fragment before they get here.
sizes.Add(n.Value.Request.Length);
replySizes.Add(n.Value.ExpectedReplyBytes);
}
batch.Add(n.Value);
_queue.RemoveFirst();
if (n.Value.Exclusive) break;
}
}
}
/// <summary>Expected size of the MSP reply for the given embedded reply sizes: reply header, count,
/// one offset per reply, then the replies themselves.</summary>
internal static int ReplyEnvelopeSize(IReadOnlyList<int> replySizes)
{
var total = ReplyHeaderBytes + 2 + 2 * replySizes.Count;
foreach (var r in replySizes) total += r;
return total;
}
private async Task PumpAsync()
{
while (true)
{
try { await _signal.WaitAsync(_stop.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
// A signal only means "there may be work"; drain the queue fully before waiting again,
// so a surplus signal (e.g. from a cancelled waiter) is just one empty pass.
while (!_stop.IsCancellationRequested)
{
var batch = new List<Pending>();
try
{
TakeBatch(batch);
if (batch.Count == 0) break;
await ExecuteAsync(batch).ConfigureAwait(false);
}
catch (Exception ex)
{
// Last resort: ExecuteAsync already handles every exchange failure, so reaching here
// means the scheduler itself broke (e.g. MaxRequestBytes threw). Fault everything
// pending rather than let the pump die and leave callers hanging, then keep pumping
// so later requests still get an answer.
FaultAll(batch, ex);
break;
}
}
}
}
private void FaultAll(List<Pending> batch, Exception ex)
{
var error = ex as AbTransportException
?? new AbTransportException(AbStatus.BadConnection, $"Request scheduler failed: {ex.Message}", ex);
List<Pending> queued;
lock (_lock)
{
queued = [.. _queue];
_queue.Clear();
}
foreach (var b in batch) b.Fail(error, this);
foreach (var q in queued) q.Fail(error, this);
}
private async Task ExecuteAsync(List<Pending> batch)
{
try
{
if (batch.Count == 1)
{
// Never a waiter's token (B2).
var raw = await _channel.SendAsync(batch[0].Request, _stop.Token).ConfigureAwait(false);
batch[0].Succeed(CipReply.Parse(raw), this);
return;
}
var requests = new List<byte[]>(batch.Count);
foreach (var b in batch) requests.Add(b.Request);
var envelopeRaw = await _channel.SendAsync(MultipleServicePacket.Build(requests), _stop.Token).ConfigureAwait(false);
var envelope = CipReply.Parse(envelopeRaw);
if (envelope.Service != MultipleServiceCode)
throw new AbTransportException(AbStatus.BadReply, $"MSP reply carries service 0x{envelope.Service:X2}.");
if (envelope.GeneralStatus is not (0x00 or EmbeddedServiceError))
throw new AbTransportException(envelope.Status, $"Multiple Service Packet failed: general 0x{envelope.GeneralStatus:X2}.");
var replies = MultipleServicePacket.Parse(envelope, batch.Count);
for (var i = 0; i < batch.Count; i++) batch[i].Succeed(replies[i], this);
}
catch (OperationCanceledException) when (_stop.IsCancellationRequested)
{
// The scheduler is being disposed: not the caller's cancellation, so not an OCE.
var error = DisposedError();
foreach (var b in batch) b.Fail(error, this);
}
catch (AbTransportException ex)
{
foreach (var b in batch) b.Fail(ex, this);
}
catch (Exception ex)
{
var wrapped = new AbTransportException(AbStatus.BadConnection, $"CIP exchange failed: {ex.Message}", ex);
foreach (var b in batch) b.Fail(wrapped, this);
}
}
/// <summary>
/// The error a waiter sees when the scheduler (i.e. its connection) goes away under it. Deliberately
/// NOT an <see cref="OperationCanceledException"/>: upstream treats OCE as "the caller unsubscribed"
/// and swallows it, whereas this must surface as a communication failure. A waiter cancelled by its
/// own token still gets an OCE.
/// </summary>
private static AbTransportException DisposedError() =>
new(AbStatus.BadConnection, "Scheduler disposed.");
/// <summary>Stops the pump. In-flight and queued waiters fault with
/// <see cref="AbTransportException"/> (<see cref="AbStatus.BadConnection"/>).</summary>
public async ValueTask DisposeAsync()
{
lock (_lock)
{
if (_disposed) return;
_disposed = true;
}
_stop.Cancel();
try { await _pump.ConfigureAwait(false); } catch { /* stopping */ }
List<Pending> remaining;
lock (_lock)
{
remaining = [.. _queue];
_queue.Clear();
}
var error = DisposedError();
foreach (var p in remaining) p.Fail(error, this);
_signal.Dispose();
_stop.Dispose();
}
}
@@ -0,0 +1,197 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>
/// Process-wide owner of device connections: exactly one <see cref="CipConnection"/> and one
/// <see cref="RequestScheduler"/> per <see cref="DeviceKey"/> (review B1), handed out as ref-counted
/// <see cref="ConnectionLease"/>s. The last lease's dispose closes the connection; a later acquire builds
/// fresh instances, which do not open until the previous connection has finished closing.
/// </summary>
/// <remarks>Forward Open throttles are kept per key for the process lifetime and never pruned, so a
/// reconnect after the last release is still rate-limited against the previous attempt.</remarks>
public sealed class DeviceSessionPool
{
/// <summary>The process-wide pool the drivers use. Tests build their own.</summary>
public static DeviceSessionPool Shared { get; } = new();
internal sealed class Entry(CipConnection connection, RequestScheduler scheduler, ConnectionOptions options,
Task predecessorClosed)
{
public CipConnection Connection { get; } = connection;
/// <summary>Completes when every earlier connection for this key has finished closing.</summary>
public Task PredecessorClosed { get; } = predecessorClosed;
public RequestScheduler Scheduler { get; } = scheduler;
public ConnectionOptions Options { get; } = options;
public int RefCount; // under the pool lock
public bool MismatchLogged; // under the pool lock
}
private readonly object _lock = new();
private readonly Dictionary<DeviceKey, Entry> _entries = new();
private readonly Dictionary<DeviceKey, ForwardOpenThrottle> _throttles = new();
private readonly Dictionary<DeviceKey, Task> _closing = new();
private readonly TimeProvider _time;
private volatile ILogger _logger;
public DeviceSessionPool(TimeProvider? time = null, ILogger? logger = null)
{
_time = time ?? TimeProvider.System;
_logger = logger ?? NullLogger.Instance;
}
/// <summary>Give <see cref="Shared"/> a logger (it is built before any host exists), so its
/// options-mismatch and close-failure warnings reach the service log. Call once at startup.</summary>
public static void ConfigureSharedLogger(ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(loggerFactory);
Shared._logger = loggerFactory.CreateLogger<DeviceSessionPool>();
}
/// <summary>Wiring-test hook: the logger this pool currently writes to (final review I2).</summary>
internal ILogger Logger => _logger;
/// <summary>Counters and open-attempt limits for this pool's connections (gate and harness diagnostics).</summary>
internal ConnectionDiagnostics Diagnostics { get; } = new();
/// <summary>Test hook: keys with a live entry.</summary>
internal int EntryCount { get { lock (_lock) return _entries.Count; } }
/// <summary>Lease the shared connection for <paramref name="key"/>, creating it on first use. Performs
/// no I/O: the connection opens on its first request. When the key already has an entry, that entry's
/// options win; a differing <paramref name="options"/> is logged once per entry.</summary>
public ConnectionLease Acquire(DeviceKey key, ConnectionOptions options)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(options);
lock (_lock)
{
if (!_entries.TryGetValue(key, out var entry))
{
if (!_throttles.TryGetValue(key, out var throttle))
_throttles[key] = throttle = new ForwardOpenThrottle(options.MinForwardOpenInterval, _time);
var predecessor = _closing.GetValueOrDefault(key) ?? Task.CompletedTask;
var connection = new CipConnection(key, options, throttle, _time, predecessor, Diagnostics);
entry = new Entry(connection, new RequestScheduler(connection, options.AllowPacking), options, predecessor);
_entries[key] = entry;
}
else if (!entry.Options.Equals(options) && !entry.MismatchLogged)
{
entry.MismatchLogged = true;
_logger.LogWarning(
"Device {Host}:{Port} path '{CipPath}' is already connected with different connection options; " +
"the existing options ({Existing}) stay in force and the requested ({Requested}) are ignored.",
key.Host, key.Port, key.CipPath, entry.Options, options);
}
entry.RefCount++;
return new ConnectionLease(this, key, entry);
}
}
internal async ValueTask ReleaseAsync(DeviceKey key, Entry entry)
{
TaskCompletionSource closed;
Task chain;
lock (_lock)
{
if (--entry.RefCount > 0) return;
_entries.Remove(key);
closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// The successor must wait for THIS close and for every earlier one still running. An entry
// that never opened closes instantly; without the chain it would hide a slow predecessor
// (A closing, B acquired+released unopened, C opens while A's Forward Close is still pending).
chain = Task.WhenAll(entry.PredecessorClosed, closed.Task);
_closing[key] = chain;
}
_ = chain.ContinueWith(_ =>
{
lock (_lock)
{
if (_closing.TryGetValue(key, out var t) && t == chain) _closing.Remove(key);
}
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
// Outside the lock.
try
{
await CloseAsync(key, entry.Scheduler, entry.Connection, _logger).ConfigureAwait(false);
}
finally
{
closed.TrySetResult(); // never faults: a successor only needs to know the close is over
}
}
/// <summary>
/// Scheduler first (fails its waiters, stops sending), then Forward Close + UnRegisterSession via the
/// connection. The connection is disposed even if the scheduler's dispose throws: otherwise its keep-alive
/// would hold the press connection open forever. Never throws; each failure is logged.
/// </summary>
internal static async Task CloseAsync(DeviceKey key, IAsyncDisposable scheduler, IAsyncDisposable connection, ILogger logger)
{
try { await scheduler.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex)
{
logger.LogWarning(ex, "Disposing the request scheduler for {Host}:{Port} failed; closing the connection anyway.",
key.Host, key.Port);
}
try { await connection.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex)
{
logger.LogWarning(ex, "Closing the connection to {Host}:{Port} failed.", key.Host, key.Port);
}
}
}
/// <summary>A reference on a device's shared connection. Dispose exactly once (further disposes are no-ops).</summary>
public sealed class ConnectionLease : IAsyncDisposable
{
private readonly DeviceSessionPool _pool;
private readonly DeviceKey _key;
private readonly DeviceSessionPool.Entry _entry;
private int _disposed;
internal ConnectionLease(DeviceSessionPool pool, DeviceKey key, DeviceSessionPool.Entry entry)
{
_pool = pool;
_key = key;
_entry = entry;
}
public DeviceKey Key => _key;
/// <summary>The device's channel. Prefer <see cref="Scheduler"/>: it serializes and packs requests.</summary>
public ICipChannel Channel => _entry.Connection;
/// <summary>The device's single request scheduler, shared by every lease on the key.</summary>
public RequestScheduler Scheduler => _entry.Scheduler;
public ValueTask DisposeAsync() =>
Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask;
/// <summary>
/// Releases this lease without waiting for the teardown — the one release shape both native AB tag
/// runtimes use from their synchronous <c>Dispose</c>, which the drivers call under a per-tag lock
/// (Gitea #579). Idempotent, like <see cref="DisposeAsync"/>.
/// </summary>
/// <remarks>
/// <para>Nothing is lost by not waiting. The release's bookkeeping — the ref-count drop and, for the
/// last lease, the entry's removal and the registration of its close in the pool's closing chain — runs
/// synchronously, before this returns. So an acquire made the very next instant builds a connection that
/// does not open until this one's Forward Close + UnRegisterSession have finished (the guarantee the old
/// blocking 2 s wait in the AbCip runtime existed for), and the close itself is bounded by
/// <see cref="CipConnection"/>'s own budgets (the open budget for an in-flight open, 1 s for the Forward
/// Close), so it always completes and the socket is always released.</para>
/// <para>The returned task is the teardown; it never faults (the pool logs its own close failures), so
/// a caller may drop it.</para>
/// </remarks>
/// <returns>The teardown, for tests and callers that want to observe it.</returns>
public Task ReleaseInBackground() => Observe(DisposeAsync());
private static async Task Observe(ValueTask release)
{
try { await release.ConfigureAwait(false); }
catch (Exception) { /* ReleaseAsync logs and swallows its own failures; never fault the observer */ }
}
}
@@ -0,0 +1,319 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
/// <summary>
/// One EtherNet/IP TCP session: connects, registers, and sends SendRRData (unconnected) and
/// SendUnitData (connected) requests with <b>at most one request in flight</b>.
/// </summary>
/// <remarks>
/// <para><b>Single flight.</b> Every exchange (write one frame, read until its reply) runs under a
/// <see cref="SemaphoreSlim"/>(1,1). The connected sequence count is advanced inside that gate, so
/// concurrent callers can never put sequences on the wire out of order.</para>
/// <para><b>Caller cancellation never faults the connection, and never misattributes a reply</b>
/// (rulings R5-1/R5-2). The caller gets <see cref="OperationCanceledException"/> at once, but the
/// abandoned exchange keeps reading <i>its own</i> reply in the background while still holding the
/// gate, bounded by the original timeout. The reply is discarded and the connection stays healthy;
/// if the timeout elapses first the connection faults exactly as a normal timeout does. Because the
/// next request is not written until the previous reply has been consumed, a late reply can never be
/// taken for the next request's — which matters because ab_server zeroes the reply sender context,
/// so context matching alone cannot tell two unconnected replies apart.</para>
/// <para><b>Faults.</b> A timeout, an I/O error or an encapsulation-level protocol error
/// (<see cref="AbStatus.BadReply"/>) closes the socket; <see cref="IsConnected"/> turns false and
/// every later call throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>).
/// The owner reconnects with a fresh client.</para>
/// <para><b>No half-written frames.</b> A frame write never observes a cancellation token. It is
/// nevertheless bounded: the exchange timeout also covers the write, and if it elapses first the
/// connection is <i>faulted</i> (socket closed, which fails the pending write) rather than the write
/// being cancelled — so a peer that stops reading cannot hold the single-flight gate forever, and a
/// torn frame can only ever sit on a socket that is already dead.</para>
/// <para><b>Timeouts are finite.</b> Every entry point rejects a non-positive or infinite timeout
/// (<see cref="ArgumentOutOfRangeException"/>): the abandoned-exchange drain is bounded by it, so an
/// infinite one would let a cancelled caller's drain hold the gate forever. The gate wait itself is
/// bounded by the same timeout (<see cref="AbStatus.Timeout"/>, nothing written, connection intact).</para>
/// <para><b>RegisterSession.</b> It goes out with a zero sender context (ab_server refuses a non-zero
/// one) and therefore relies on the unconnected "reply context == 0 is tolerated" rule in
/// <c>TryMatch</c> to match its reply; both ends of that dependency are pinned by tests.</para>
/// </remarks>
public sealed class EncapsulationClient : IAsyncDisposable
{
private static readonly TimeSpan UnregisterBudget = TimeSpan.FromMilliseconds(500);
private readonly TcpClient _tcp;
private readonly NetworkStream _stream;
// Never disposed: an abandoned exchange may release it after DisposeAsync returns, and
// SemaphoreSlim.Release on a disposed instance throws. It holds no unmanaged resource unless
// AvailableWaitHandle is touched, which this class never does.
private readonly SemaphoreSlim _gate = new(1, 1);
private long _ctx;
private ushort _seq; // connected sequence count; only touched under _gate
private volatile bool _faulted;
private int _disposed;
/// <summary>The session handle the target assigned in its RegisterSession reply.</summary>
public uint SessionHandle { get; private set; }
/// <summary>False once the connection has faulted or been disposed.</summary>
public bool IsConnected => !_faulted && Volatile.Read(ref _disposed) == 0 && _tcp.Connected;
private EncapsulationClient(TcpClient tcp)
{
_tcp = tcp;
_stream = tcp.GetStream();
}
/// <summary>Open the TCP connection and register an encapsulation session. <paramref name="timeout"/>
/// bounds the TCP connect and the RegisterSession exchange separately.</summary>
public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
{
ValidateTimeout(timeout);
var tcp = new TcpClient { NoDelay = true };
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
await tcp.ConnectAsync(host, port, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
tcp.Dispose();
throw new AbTransportException(AbStatus.Timeout, $"TCP connect to {host}:{port} timed out.");
}
catch (SocketException ex)
{
tcp.Dispose();
throw new AbTransportException(AbStatus.BadConnection, $"TCP connect to {host}:{port} failed: {ex.SocketErrorCode}", ex);
}
catch
{
tcp.Dispose();
throw;
}
var client = new EncapsulationClient(tcp);
try
{
var (header, _) = await client.ExchangeAsync(connected: false,
(_, _) => EncapsulationCodec.RegisterSession(), timeout, ct).ConfigureAwait(false);
client.SessionHandle = header.SessionHandle;
return client;
}
catch
{
client.Abort();
throw;
}
}
/// <summary>Send one unconnected (UCMM) CIP request and return the CIP reply bytes.</summary>
public async Task<ReadOnlyMemory<byte>> SendRRDataAsync(ReadOnlyMemory<byte> cip, TimeSpan timeout, CancellationToken ct)
{
ValidateTimeout(timeout);
var secs = (ushort)Math.Clamp((int)Math.Ceiling(timeout.TotalSeconds), 1, ushort.MaxValue);
var (_, reply) = await ExchangeAsync(connected: false,
(ctx, _) => EncapsulationCodec.SendRRData(SessionHandle, ctx, cip.Span, secs), timeout, ct).ConfigureAwait(false);
return reply;
}
/// <summary>Send one connected (class 3) CIP request over <paramref name="otConnectionId"/> and return
/// the CIP reply bytes. The client owns the connected sequence count (review B1).</summary>
public async Task<ReadOnlyMemory<byte>> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory<byte> cip,
TimeSpan timeout, CancellationToken ct)
{
ValidateTimeout(timeout);
var (_, reply) = await ExchangeAsync(connected: true,
(ctx, seq) => EncapsulationCodec.SendUnitData(SessionHandle, ctx, otConnectionId, seq, cip.Span), timeout, ct)
.ConfigureAwait(false);
return reply;
}
private static void ValidateTimeout(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero || timeout == Timeout.InfiniteTimeSpan)
throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Timeout must be finite and positive.");
}
/// <summary>The connected sequence count after <paramref name="current"/>: increments, wrapping
/// 65535 → 1 (0 is never put on the wire).</summary>
internal static ushort NextSequence(ushort current) => current == ushort.MaxValue ? (ushort)1 : (ushort)(current + 1);
/// <summary>Acquire the gate, build the request (sequence assigned under the gate), then hand the
/// gate to <see cref="RunExchangeAsync"/>, which owns its release from that point on.</summary>
private async Task<(EncapsulationHeader Header, ReadOnlyMemory<byte> Cip)> ExchangeAsync(
bool connected, Func<ulong, ushort, byte[]> build, TimeSpan timeout, CancellationToken ct)
{
ThrowIfUnusable();
if (!await _gate.WaitAsync(timeout, ct).ConfigureAwait(false))
throw new AbTransportException(AbStatus.Timeout,
$"Timed out after {timeout.TotalMilliseconds:F0} ms waiting for the session's single-flight gate.");
Task<(EncapsulationHeader, ReadOnlyMemory<byte>)> exchange;
try
{
ThrowIfUnusable(); // a drain we queued behind may have faulted
ct.ThrowIfCancellationRequested(); // nothing written yet: cancel cleanly
var ctx = (ulong)Interlocked.Increment(ref _ctx);
ushort seq = 0;
if (connected) seq = _seq = NextSequence(_seq);
var request = build(ctx, seq);
exchange = RunExchangeAsync(request, connected, ctx, seq, timeout);
}
catch
{
_gate.Release(); // RunExchangeAsync never started: release here
throw;
}
try
{
return await exchange.WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Caller abandoned the wait (R5-1). The exchange keeps draining its own reply under the
// gate and faults on its own timeout; observe its outcome so a fault is never unobserved.
_ = exchange.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
throw;
}
}
/// <summary>Write one frame, then read frames until the matching reply arrives. Releases the gate
/// exactly once, in its <c>finally</c>. Never throws <see cref="OperationCanceledException"/>: every
/// failure surfaces as <see cref="AbTransportException"/> and faults the connection first.</summary>
private async Task<(EncapsulationHeader, ReadOnlyMemory<byte>)> RunExchangeAsync(
byte[] request, bool connected, ulong ctx, ushort seq, TimeSpan timeout)
{
var cmd = (ushort)(request[0] | (request[1] << 8));
try
{
using var timeoutCts = new CancellationTokenSource(timeout);
try
{
// The write ignores cancellation (a half-written frame would desync the stream) but is
// bounded: on expiry the catch below faults the connection, closing the socket, which
// is what fails the still-pending write.
var write = _stream.WriteAsync(request, CancellationToken.None).AsTask();
_ = write.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
await write.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
while (true)
{
var frame = await ReadFrameAsync(timeoutCts.Token).ConfigureAwait(false);
if (TryMatch(frame, cmd, connected, ctx, seq, out var reply)) return reply;
}
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
Fault();
throw new AbTransportException(AbStatus.Timeout,
$"No reply to command 0x{cmd:X4} within {timeout.TotalMilliseconds:F0} ms.");
}
catch (AbTransportException)
{
Fault();
throw;
}
catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException
or EndOfStreamException or InvalidOperationException)
{
Fault();
throw new AbTransportException(AbStatus.BadConnection, $"Connection lost: {ex.Message}", ex);
}
}
finally
{
_gate.Release();
}
}
/// <summary>Does <paramref name="frame"/> answer the request in flight? Unconnected replies match on
/// command and sender context, tolerating a zeroed context (ab_server, R5-2); connected replies match
/// on the echoed sequence count. Anything else is skipped. A matching frame is fully validated, so an
/// encapsulation error throws BadReply.</summary>
private static bool TryMatch(byte[] frame, ushort cmd, bool connected, ulong ctx, ushort seq,
out (EncapsulationHeader, ReadOnlyMemory<byte>) reply)
{
reply = default;
var h = EncapsulationCodec.ReadHeader(frame);
if (h.Command != cmd) return false;
if (!connected)
{
// "== 0" is load-bearing, not just ab_server tolerance: RegisterSession is sent with a ZERO
// sender context (ab_server refuses non-zero), so its reply echoes 0 and matches ONLY here.
if (h.SenderContext != ctx && h.SenderContext != 0) return false;
var p = EncapsulationCodec.ParseReply(frame, expectConnected: false);
reply = (p.Header, p.Cip);
return true;
}
// A connected error reply (status != 0) carries no readable sequence; with one request in flight
// and abandoned exchanges drained, it can only be ours — ParseReply throws BadReply for it.
var c = EncapsulationCodec.ParseReply(frame, expectConnected: true);
if (c.Sequence != seq) return false;
reply = (c.Header, c.Cip);
return true;
}
private async Task<byte[]> ReadFrameAsync(CancellationToken ct)
{
var head = new byte[EncapsulationHeader.Size];
await _stream.ReadExactlyAsync(head, ct).ConfigureAwait(false);
var len = head[2] | (head[3] << 8);
var frame = new byte[EncapsulationHeader.Size + len];
head.CopyTo(frame, 0);
await _stream.ReadExactlyAsync(frame.AsMemory(EncapsulationHeader.Size), ct).ConfigureAwait(false);
return frame;
}
private void ThrowIfUnusable()
{
if (Volatile.Read(ref _disposed) != 0)
throw new AbTransportException(AbStatus.BadConnection, "Session is disposed.");
if (_faulted)
throw new AbTransportException(AbStatus.BadConnection, "Session is faulted; reconnect.");
}
private void Fault()
{
_faulted = true;
try { _tcp.Close(); } catch { /* already closed */ }
}
/// <summary>Close without UnRegisterSession (registration never completed).</summary>
private void Abort()
{
Interlocked.Exchange(ref _disposed, 1);
Fault();
}
/// <summary>Best-effort UnRegisterSession, then close. Waits briefly for an in-flight exchange so the
/// UnRegisterSession frame is never interleaved with another frame; if the gate is not free in time
/// the socket is closed without it.</summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
var acquired = await _gate.WaitAsync(UnregisterBudget).ConfigureAwait(false);
try
{
if (acquired && !_faulted && _tcp.Connected)
{
try
{
var bytes = EncapsulationCodec.UnRegisterSession(SessionHandle, (ulong)Interlocked.Increment(ref _ctx));
using var cts = new CancellationTokenSource(UnregisterBudget);
await _stream.WriteAsync(bytes, cts.Token).ConfigureAwait(false);
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
}
catch { /* best effort: the socket is closed next either way */ }
}
}
finally
{
Fault();
_tcp.Dispose();
if (acquired) _gate.Release();
}
}
}
@@ -0,0 +1,115 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
/// <summary>Builds and parses EtherNet/IP encapsulation frames, including the Common Packet Format
/// (CPF) items SendRRData / SendUnitData carry.</summary>
public static class EncapsulationCodec
{
/// <summary>CPF item: Null Address (unconnected messaging).</summary>
public const ushort ItemNullAddress = 0x0000;
/// <summary>CPF item: Connected Address (carries the O→T connection id).</summary>
public const ushort ItemConnectedAddress = 0x00A1;
/// <summary>CPF item: Connected Data (sequence count + CIP).</summary>
public const ushort ItemConnectedData = 0x00B1;
/// <summary>CPF item: Unconnected Data (CIP).</summary>
public const ushort ItemUnconnectedData = 0x00B2;
/// <summary>Largest CIP payload one frame can carry: the encapsulation length field (u16) also
/// covers the CPF overhead, so anything near 64 KiB cannot be framed.</summary>
public const int MaxCipPayload = ushort.MaxValue - 64;
private static CipWriter Header(ushort cmd, uint session, ulong ctx) =>
new CipWriter().U16(cmd).U16(0).U32(session).U32(0).U64(ctx).U32(0);
private static byte[] Finish(CipWriter w)
{
w.PatchU16(2, (ushort)(w.Length - EncapsulationHeader.Size));
return w.ToArray();
}
/// <summary>RegisterSession: protocol version 1, option flags 0, and a <b>zero</b> sender context.</summary>
/// <remarks>The sender context is always zero, as libplctag sends it (<c>logix-register-session.hex</c>):
/// ab_server's RegisterSession sanity check rejects any non-zero context with encapsulation status 1
/// and a header-only reply, so a per-request context here failed every native session against the
/// simulator. The reply is still matched — unconnected matching tolerates a zero context.</remarks>
public static byte[] RegisterSession() =>
Finish(Header(EncapCommand.RegisterSession, 0, 0).U16(1).U16(0));
/// <summary>UnRegisterSession: header only, no data.</summary>
public static byte[] UnRegisterSession(uint session, ulong ctx) =>
Finish(Header(EncapCommand.UnRegisterSession, session, ctx));
/// <summary>SendRRData (unconnected): Null Address item + Unconnected Data item carrying <paramref name="cip"/>.</summary>
public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan<byte> cip, ushort timeoutSeconds) =>
Finish(Header(EncapCommand.SendRRData, session, ctx)
.U32(0).U16(timeoutSeconds).U16(2)
.U16(ItemNullAddress).U16(0)
.U16(ItemUnconnectedData).U16(CheckedLength(cip.Length)).Bytes(cip));
/// <summary>SendUnitData (connected): Connected Address item + Connected Data item carrying the
/// sequence count followed by <paramref name="cip"/>.</summary>
public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan<byte> cip) =>
Finish(Header(EncapCommand.SendUnitData, session, ctx)
.U32(0).U16(0).U16(2)
.U16(ItemConnectedAddress).U16(4).U32(connectionId)
.U16(ItemConnectedData).U16((ushort)(CheckedLength(cip.Length) + 2)).U16(sequence).Bytes(cip));
private static ushort CheckedLength(int length)
{
if (length > MaxCipPayload)
throw new AbTransportException(AbStatus.TooLarge, $"CIP payload of {length} bytes cannot be framed (max {MaxCipPayload}).");
return (ushort)length;
}
/// <summary>Read the 24-byte header at the start of <paramref name="b"/>.</summary>
public static EncapsulationHeader ReadHeader(ReadOnlySpan<byte> b)
{
var r = new CipReader(b);
return new EncapsulationHeader(r.U16(), r.U16(), r.U32(), r.U32(), r.U64(), r.U32());
}
/// <summary>Parse a reply frame. Returns the header plus the CIP payload of the data item
/// (0x00B2 unconnected / 0x00B1 connected, sequence count stripped) and, for a connected reply,
/// the echoed sequence count. RegisterSession / UnRegisterSession replies return an empty payload.
/// Throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadReply"/>) on a non-zero
/// encapsulation status, a length mismatch, a truncated item list or a missing data item.</summary>
public static (EncapsulationHeader Header, ReadOnlyMemory<byte> Cip, ushort Sequence) ParseReply(
ReadOnlyMemory<byte> frame, bool expectConnected)
{
var h = ReadHeader(frame.Span);
if (h.Status != 0)
throw new AbTransportException(AbStatus.BadReply, $"Encapsulation status 0x{h.Status:X8} on command 0x{h.Command:X4}.");
if (h.Length != frame.Length - EncapsulationHeader.Size)
throw new AbTransportException(AbStatus.BadReply,
$"Encapsulation length mismatch: header says {h.Length}, frame carries {frame.Length - EncapsulationHeader.Size}.");
if (h.Command is EncapCommand.RegisterSession or EncapCommand.UnRegisterSession)
return (h, ReadOnlyMemory<byte>.Empty, 0);
var r = new CipReader(frame.Span[EncapsulationHeader.Size..]);
r.U32(); r.U16(); // interface handle, timeout
var items = r.U16();
var offset = EncapsulationHeader.Size + 8;
var wanted = expectConnected ? ItemConnectedData : ItemUnconnectedData;
for (var i = 0; i < items; i++)
{
var type = r.U16();
var len = r.U16();
offset += 4;
r.Bytes(len); // bounds-checks the item against the frame
if (type == wanted)
{
if (!expectConnected) return (h, frame.Slice(offset, len), 0);
if (len < 2)
throw new AbTransportException(AbStatus.BadReply, "Connected data item shorter than its sequence count.");
var seq = (ushort)(frame.Span[offset] | (frame.Span[offset + 1] << 8));
return (h, frame.Slice(offset + 2, len - 2), seq);
}
offset += len;
}
throw new AbTransportException(AbStatus.BadReply,
$"Reply to command 0x{h.Command:X4} carried no 0x{wanted:X4} data item.");
}
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
/// <summary>EtherNet/IP encapsulation command codes (CIP Vol. 2, Table 2-3.2).</summary>
public static class EncapCommand
{
public const ushort ListIdentity = 0x0063, RegisterSession = 0x0065, UnRegisterSession = 0x0066,
SendRRData = 0x006F, SendUnitData = 0x0070;
}
/// <summary>The fixed 24-byte EtherNet/IP encapsulation header (all fields little-endian).</summary>
public readonly record struct EncapsulationHeader(
ushort Command, ushort Length, uint SessionHandle, uint Status, ulong SenderContext, uint Options)
{
public const int Size = 24;
}
@@ -0,0 +1,39 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
/// <summary>Logix Read/Write Tag (plain and fragmented) builders and the read-reply parser.</summary>
public static class LogixServices
{
private static CipWriter Start(byte service, ReadOnlySpan<byte> path) =>
new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);
public static byte[] ReadTag(ReadOnlySpan<byte> path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
public static byte[] ReadTagFragmented(ReadOnlySpan<byte> path, ushort elements, uint offset) =>
Start(0x52, path).U16(elements).U32(offset).ToArray();
public static byte[] WriteTag(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, ReadOnlySpan<byte> data)
{
var w = Start(0x4D, path).U16(type);
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
return w.U16(elements).Bytes(data).ToArray();
}
public static byte[] WriteTagFragmented(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan<byte> data)
{
var w = Start(0x53, path).U16(type);
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
return w.U16(elements).U32(offset).Bytes(data).ToArray();
}
public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data) ParseReadReply(CipReply reply)
{
if (reply.GeneralStatus is not (0x00 or 0x06))
throw new AbTransportException(reply.Status, $"Read failed: general 0x{reply.GeneralStatus:X2}.");
var r = new CipReader(reply.Data.Span);
var type = r.U16();
ushort? handle = type == LogixTypeCode.Struct ? r.U16() : null;
return (type, handle, reply.Data[r.Position..]);
}
}
@@ -0,0 +1,164 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
/// <summary>
/// Logix tag read/write over a connection's <see cref="RequestScheduler"/>, including the fragment
/// loops for values that do not fit in one frame.
/// </summary>
/// <remarks>
/// Reads are issued as Read Tag Fragmented (0x52) starting at offset 0 — exactly what libplctag puts
/// on the wire (golden captures, Task 4) — so the native and libplctag frames stay byte-comparable,
/// and the continuation requests are the same service at a non-zero offset.
/// </remarks>
public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel channel)
{
private const byte StatusSuccess = 0x00;
private const byte StatusPartial = 0x06;
/// <summary>Fixed bytes of a Write Tag Fragmented request besides path and data: service, path
/// size, type, struct handle, element count, offset.</summary>
private const int FragmentedWriteOverhead = 12;
/// <summary>Upper bound on one read's accumulated data, so a controller that keeps answering
/// Partial (0x06) cannot grow memory without limit. Far above any real Logix tag.</summary>
internal const int MaxReadBytes = 2 * 1024 * 1024;
/// <summary>Chunk alignment when one element is wider than a frame (Logix lays structures out on
/// at most 8-byte boundaries).</summary>
private const int WideElementAlignment = 8;
private readonly RequestScheduler _scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
private readonly ICipChannel _channel = channel ?? throw new ArgumentNullException(nameof(channel));
/// <summary>
/// Read <paramref name="elements"/> elements of the tag at the encoded <paramref name="path"/>.
/// A Partial (0x06) reply keeps its data and the read continues at <c>offset = bytes received so
/// far</c> until a reply reports success. The type code and struct handle come from the first
/// reply; each fragment reply's repeated type header is stripped before its data is appended.
/// </summary>
/// <remarks>Packing bounds only the request size, so a packed batch of large reads can come back
/// with 0x06 per service; that is expected, and this loop recovers it.</remarks>
/// <param name="path">The encoded symbolic path.</param>
/// <param name="elements">Element count to read.</param>
/// <param name="ct">Cancels this read.</param>
/// <param name="elementBytes">Upper bound on one element's data bytes (<see cref="LogixTypeCode.StringElementBytes"/>
/// for a STRING), or 0 when unknown. The scheduler budgets the expected reply against the connection
/// size when packing (#582); unknown sends the read alone.</param>
public async Task<(ushort TypeCode, ushort? StructHandle, byte[] Data)> ReadAsync(
byte[] path, ushort elements, CancellationToken ct, int elementBytes = 0)
{
ArgumentNullException.ThrowIfNull(path);
var reply = await _scheduler.SendAsync(LogixServices.ReadTagFragmented(path, elements, 0), ct,
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, 0)).ConfigureAwait(false);
var (type, handle, first) = LogixServices.ParseReadReply(reply); // throws on any status but 0x00/0x06
var data = new List<byte>(first.Length);
data.AddRange(first.Span);
var lastChunk = first.Length;
while (reply.GeneralStatus == StatusPartial)
{
if (lastChunk == 0)
throw new AbTransportException(AbStatus.BadReply, "Partial read reply carried no data; the fragment loop cannot progress.");
reply = await _scheduler.SendAsync(
LogixServices.ReadTagFragmented(path, elements, (uint)data.Count), ct,
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, data.Count)).ConfigureAwait(false);
var (fragmentType, fragmentHandle, chunk) = LogixServices.ParseReadReply(reply);
if (fragmentType != type)
throw new AbTransportException(AbStatus.BadReply,
$"Read fragment type 0x{fragmentType:X4} differs from the first reply's 0x{type:X4}.");
// A mid-read change of the structure handle means the tag's type changed under us (a
// download / online edit); stitching the halves would yield a value of neither type.
if (fragmentHandle != handle)
throw new AbTransportException(AbStatus.BadReply,
$"Read fragment structure handle {FormatHandle(fragmentHandle)} differs from the first reply's {FormatHandle(handle)}; the tag changed mid-read.");
data.AddRange(chunk.Span);
lastChunk = chunk.Length;
if (data.Count > MaxReadBytes)
throw new AbTransportException(AbStatus.TooLarge,
$"Fragmented read exceeded {MaxReadBytes} bytes; the controller kept answering Partial.");
}
return (type, handle, data.ToArray());
}
private static string FormatHandle(ushort? handle) => handle is { } h ? $"0x{h:X4}" : "(none)";
/// <summary>Whole-reply bound for a Read Tag Fragmented at <paramref name="alreadyRead"/> bytes in:
/// reply header, type (+ struct handle), then the remaining data. Unknown element size, or an
/// unrepresentable total, is <see cref="RequestScheduler.UnknownReply"/>.</summary>
internal static int ExpectedReadReplyBytes(ushort elements, int elementBytes, int alreadyRead)
{
if (elementBytes <= 0) return RequestScheduler.UnknownReply;
var remaining = (long)elements * elementBytes - alreadyRead;
if (remaining < 0) remaining = 0;
// 4 header + 2 type + 2 struct handle (counted for every type: a safe over-estimate).
var total = RequestScheduler.ReplyHeaderBytes + 4 + remaining;
return total > int.MaxValue ? RequestScheduler.UnknownReply : (int)total;
}
/// <summary>
/// Write <paramref name="data"/> to the tag at <paramref name="path"/>. One Write Tag when the
/// request fits <see cref="ICipChannel.MaxRequestBytes"/>; otherwise Write Tag Fragmented chunks of
/// <c>MaxRequestBytes - 12 - path.Length</c> bytes aligned down to the element width (or to 8 bytes
/// when a single element is wider than a frame).
/// </summary>
/// <remarks>A fragmented write is NOT atomic across fragments — the controller applies each
/// fragment as it arrives, so a reader can observe a partly-written value. libplctag behaves the
/// same way.</remarks>
/// <exception cref="ArgumentException">For an atomic type, <paramref name="data"/> is not exactly
/// <paramref name="elements"/> × the type's width.</exception>
public async Task WriteAsync(byte[] path, ushort type, ushort? handle, ushort elements,
ReadOnlyMemory<byte> data, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(path);
var atomicWidth = LogixTypeCode.ByteWidth(type);
if (atomicWidth > 0 && data.Length != elements * atomicWidth)
throw new ArgumentException(
$"Write of {elements} element(s) of type 0x{type:X4} needs {elements * atomicWidth} bytes, got {data.Length}.",
nameof(data));
var max = _channel.MaxRequestBytes;
var isStruct = type == LogixTypeCode.Struct;
var singleSize = 2 + path.Length + 2 + (isStruct ? 2 : 0) + 2 + data.Length;
if (singleSize <= max)
{
var reply = await _scheduler.SendAsync(LogixServices.WriteTag(path, type, handle, elements, data.Span), ct).ConfigureAwait(false);
EnsureSuccess(reply);
return;
}
var chunkSize = AlignedChunkSize(max - FragmentedWriteOverhead - path.Length, ElementWidth(type, elements, data.Length));
for (var offset = 0; offset < data.Length; offset += chunkSize)
{
var length = Math.Min(chunkSize, data.Length - offset);
var request = LogixServices.WriteTagFragmented(path, type, handle, elements, (uint)offset, data.Span.Slice(offset, length));
var reply = await _scheduler.SendAsync(request, ct).ConfigureAwait(false);
EnsureSuccess(reply);
}
}
private static int ElementWidth(ushort type, ushort elements, int dataLength)
{
var width = LogixTypeCode.ByteWidth(type);
if (width > 0) return width;
return elements > 0 ? Math.Max(1, dataLength / elements) : 1;
}
private static int AlignedChunkSize(int room, int width)
{
// A structure element can be wider than one frame. Write Tag Fragmented's offset is a byte
// offset and the controller reassembles, so this is allowed: align to 8 bytes instead of
// refusing the write.
var alignment = width <= room ? width : WideElementAlignment;
var chunk = room / alignment * alignment;
if (chunk <= 0)
throw new AbTransportException(AbStatus.TooLarge,
$"No room for write data: MaxRequestBytes leaves {room} byte(s) after the path.");
return chunk;
}
private static void EnsureSuccess(CipReply reply)
{
if (reply.GeneralStatus != StatusSuccess)
throw new AbTransportException(reply.Status, $"Write failed: general 0x{reply.GeneralStatus:X2}.");
}
}
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
public static class LogixTypeCode
{
public const ushort Bool = 0xC1, SInt = 0xC2, Int = 0xC3, DInt = 0xC4, LInt = 0xC5, USInt = 0xC6,
UInt = 0xC7, UDInt = 0xC8, ULInt = 0xC9, Real = 0xCA, LReal = 0xCB, BoolArray = 0xD3, Struct = 0x02A0;
/// <summary>Bytes of one Logix STRING element on the wire: 4-byte length + 82 characters + 2 pad.</summary>
public const int StringElementBytes = 88;
/// <summary>Byte width of an atomic type, or -1 when the code is not atomic (e.g. <see cref="Struct"/>).</summary>
public static int ByteWidth(ushort code) => code switch
{
Bool or SInt or USInt => 1,
Int or UInt => 2,
DInt or UDInt or Real or BoolArray => 4,
LInt or ULInt or LReal => 8,
_ => -1,
};
}
@@ -0,0 +1,51 @@
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
/// <summary>Symbol Object (class 0x6B) instance listing — the <c>@tags</c> enumeration.</summary>
public static class SymbolObjectServices
{
/// <summary>Request attributes: 2 = type, 7 = element length, 8 = array dims, 1 = name. Identical
/// to what libplctag sends (<c>logix-tags-listing.hex</c>).</summary>
public static byte[] GetInstanceAttributeList(string? programName, uint startInstance)
{
var path = new CipWriter();
if (!string.IsNullOrEmpty(programName))
{
var a = Encoding.ASCII.GetBytes(programName.StartsWith("Program:", StringComparison.Ordinal) ? programName : "Program:" + programName);
path.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
}
path.U8(0x20).U8(0x6B);
// 16-bit instance segment even for small values (libplctag's form, see capture); 32-bit above 0xFFFF.
if (startInstance <= 0xFFFF) path.U8(0x25).U8(0).U16((ushort)startInstance);
else path.U8(0x26).U8(0).U32(startInstance);
var p = path.ToArray();
return new CipWriter().U8(0x55).U8((byte)(p.Length / 2)).Bytes(p)
.U16(4).U16(2).U16(7).U16(8).U16(1).ToArray();
}
/// <summary>Entries are <c>u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name</c>.
/// <c>MaxInstance</c> is the HIGHEST instance id in the page, not the last entry's: the next start is
/// MaxInstance + 1, which does not rely on the controller listing a page in ascending order.
/// <c>More</c> is true on general status 0x06 (partial transfer).</summary>
public static (byte[] Entries, uint MaxInstance, bool More) ParseListingReply(CipReply reply)
{
if (reply.GeneralStatus is not (0x00 or 0x06))
throw new AbTransportException(reply.Status, $"Symbol listing failed: general 0x{reply.GeneralStatus:X2}.");
var span = reply.Data.Span;
var r = new CipReader(span);
uint max = 0;
while (r.Remaining > 0)
{
max = Math.Max(max, r.U32());
r.Bytes(2 + 2 + 12);
var nameLen = r.U16();
r.Bytes(nameLen);
}
var more = reply.GeneralStatus == 0x06;
if (more && span.IsEmpty)
throw new AbTransportException(AbStatus.BadReply, "Symbol listing reported more data but returned no entries.");
return (span.ToArray(), max, more);
}
}
@@ -0,0 +1,52 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
public sealed record TemplateAttributes(uint DefinitionWords, uint StructureBytes, ushort Members, ushort Handle);
/// <summary>Template Object (class 0x6C) attribute query and definition read.</summary>
public static class TemplateObjectServices
{
/// <summary>Get Attribute List for attributes 4 (definition words), 5 (structure bytes),
/// 2 (member count) and 1 (handle).</summary>
public static byte[] GetAttributes(ushort templateId) =>
new CipWriter().U8(0x03).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId)
.U16(4).U16(4).U16(5).U16(2).U16(1).ToArray();
public static TemplateAttributes ParseAttributes(CipReply reply)
{
if (reply.GeneralStatus != 0)
throw new AbTransportException(reply.Status, $"Template attributes failed: general 0x{reply.GeneralStatus:X2}.");
var r = new CipReader(reply.Data.Span);
var count = r.U16();
uint defWords = 0, structBytes = 0; ushort members = 0, handle = 0;
for (var i = 0; i < count; i++)
{
var id = r.U16(); var status = r.U16();
if (status != 0)
throw new AbTransportException(AbStatus.BadReply, $"Template attribute {id} status 0x{status:X4}.");
switch (id)
{
case 4: defWords = r.U32(); break;
case 5: structBytes = r.U32(); break;
case 2: members = r.U16(); break;
case 1: handle = r.U16(); break;
default: throw new AbTransportException(AbStatus.BadReply, $"Unexpected template attribute {id}.");
}
}
return new TemplateAttributes(defWords, structBytes, members, handle);
}
/// <summary>Read Template: <c>4C 03 20 6C 25 00 id offset bytes</c>.</summary>
public static byte[] ReadTemplate(ushort templateId, uint offset, ushort bytes) =>
new CipWriter().U8(0x4C).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId).U32(offset).U16(bytes).ToArray();
/// <summary>More ⇔ general status 0x06.</summary>
public static bool ReadHasMore(CipReply reply) => reply.GeneralStatus == 0x06;
/// <summary>The buffer <c>CipTemplateObjectDecoder</c> expects: <c>u16 id, u32 defWords,
/// u32 structBytes, u16 members, u16 handle</c> then the raw template bytes.</summary>
public static byte[] BuildDecoderBuffer(ushort templateId, TemplateAttributes attrs, ReadOnlySpan<byte> rawTemplate) =>
new CipWriter().U16(templateId).U32(attrs.DefinitionWords).U32(attrs.StructureBytes)
.U16(attrs.Members).U16(attrs.Handle).Bytes(rawTemplate).ToArray();
}
@@ -0,0 +1,108 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
/// <summary>Transport-side PCCC data-table address. Independent of the driver's AbLegacyAddress.</summary>
/// <param name="FileLetter">File letter(s): N, F, B, T, C, R, ST, L, A, I, O, S.</param>
/// <param name="FileNumber">Explicit file number; null for O/I/S (implicit 0/1/2).</param>
/// <param name="Element">Element (word) number.</param>
/// <param name="SubElement">Word offset within a structured element (T/C: PRE=1, ACC=2; R: LEN=1, POS=2); null = 0.</param>
/// <param name="Bit">Bit within the addressed word (<c>/n</c> or a status-bit sub-element), else null.</param>
public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement, int? Bit = null)
{
/// <summary>The PCCC file-type byte; throws Unsupported for an unknown letter.</summary>
public byte FileTypeCode =>
PcccFileType.CodeFor(FileLetter)
?? throw new AbTransportException(AbStatus.Unsupported, $"Unknown PCCC file type '{FileLetter}'.");
/// <summary>Explicit file number, else the implicit one for O/I/S.</summary>
public int EffectiveFileNumber =>
FileNumber ?? PcccFileType.ImplicitFileNumber(FileLetter)
?? throw new AbTransportException(AbStatus.Unsupported, $"File type '{FileLetter}' needs an explicit file number.");
/// <summary>The word offset to read within the element, and the bit within that word if any.</summary>
public (int Word, int? Bit) ResolveSubElement() => (SubElement ?? 0, Bit);
/// <summary>Builds an address from a sub-element mnemonic (PRE, ACC, LEN, POS, or a status bit such as DN).</summary>
public static PcccAddress WithSubElementName(string letter, int? fileNumber, int element, string subName)
{
var l = letter.ToUpperInvariant();
var s = subName.ToUpperInvariant();
(int Word, int? Bit)? r = (l, s) switch
{
("T" or "C", "PRE") => (1, null),
("T" or "C", "ACC") => (2, null),
("R", "LEN") => (1, null),
("R", "POS") => (2, null),
("T", "EN") => (0, 15),
("T", "TT") => (0, 14),
("T", "DN") => (0, 13),
("C", "CU") => (0, 15),
("C", "CD") => (0, 14),
("C", "DN") => (0, 13),
("C", "OV") => (0, 12),
("C", "UN") => (0, 11),
("C", "UA") => (0, 10),
("R", "EN") => (0, 15),
("R", "EU") => (0, 14),
("R", "DN") => (0, 13),
("R", "EM") => (0, 12),
("R", "ER") => (0, 11),
("R", "UL") => (0, 10),
("R", "IN") => (0, 9),
("R", "FD") => (0, 8),
_ => null,
};
if (r is not { } v)
throw new AbTransportException(AbStatus.Unsupported, $"Unknown sub-element '{subName}' for file type '{letter}'.");
return new PcccAddress(l, fileNumber, element, v.Word, v.Bit);
}
/// <summary>Parses <c>N7:0</c>, <c>B3:1/4</c>, <c>T4:0.ACC</c>, <c>I:0/1</c>, <c>ST9:2</c>; null when malformed.</summary>
public static PcccAddress? TryParse(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var src = text.Trim();
int? bit = null;
var slash = src.IndexOf('/');
if (slash >= 0)
{
if (!int.TryParse(src[(slash + 1)..], out var b) || b < 0) return null;
bit = b;
src = src[..slash];
}
string? sub = null;
var dot = src.LastIndexOf('.');
if (dot >= 0)
{
sub = src[(dot + 1)..];
if (sub.Length == 0 || !sub.All(char.IsLetter)) return null;
src = src[..dot];
}
var colon = src.IndexOf(':');
if (colon <= 0 || !int.TryParse(src[(colon + 1)..], out var element) || element < 0) return null;
var filePart = src[..colon];
if (!char.IsLetter(filePart[0])) return null;
var end = 1;
while (end < filePart.Length && char.IsLetter(filePart[end])) end++;
var letter = filePart[..end].ToUpperInvariant();
if (PcccFileType.CodeFor(letter) is null) return null;
int? file = null;
if (end < filePart.Length)
{
if (!int.TryParse(filePart[end..], out var fn) || fn < 0) return null;
file = fn;
}
var implicitFile = PcccFileType.ImplicitFileNumber(letter);
if (implicitFile is not null && file is not null) return null;
if (implicitFile is null && file is null) return null;
if (sub is not null && bit is not null) return null;
if (bit is int bb)
{
// Same limits as AbLegacyAddress: 16-bit words N/B/I/O/S/A 0-15, L 0-31; no bits on F/T/C/R/ST.
var max = letter switch { "L" => 31, "N" or "B" or "I" or "O" or "S" or "A" => 15, _ => -1 };
if (bb > max) return null;
}
if (sub is null) return new PcccAddress(letter, file, element, null, bit);
try { return WithSubElementName(letter, file, element, sub); }
catch (AbTransportException) { return null; }
}
}
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
/// <summary>PCCC file-type codes for the protected typed logical read/write commands (0xA2/0xAA).
/// <para>Wire evidence (final review I3), all against the <c>ab_server</c> simulator, never a real PLC:
/// N (0x89) — byte parity with libplctag captures (SLC 500 read + write, MicroLogix read, PLC-5 read) and
/// native ab_server round trips (N7:0 read on all three profiles, N7:5 write on SLC 500); F (0x8A) — one
/// SLC 500 F8 read capture only (no native write on a wire).</para>
/// <para>S, B, T, C, R, ST, A, L, O and I are encoded from the PCCC data-table file-type table and are
/// UNVERIFIED natively — no capture and no simulator test. That includes S:0, the default probe address,
/// and the T/C/R sub-element (PRE/ACC/LEN/POS) word offsets.</para></summary>
public static class PcccFileType
{
/// <summary>Returns the file-type code for a file letter (case-insensitive), or null when unknown.</summary>
public static byte? CodeFor(string fileLetter) => fileLetter.ToUpperInvariant() switch
{
"O" => 0x8B,
"I" => 0x8C,
"S" => 0x84,
"B" => 0x85,
"T" => 0x86,
"C" => 0x87,
"R" => 0x88,
"N" => 0x89,
"F" => 0x8A,
"ST" => 0x8D,
"A" => 0x8E,
"L" => 0x91,
_ => null,
};
/// <summary>The implicit file number for the single-letter system files (O=0, I=1, S=2), else null.</summary>
public static int? ImplicitFileNumber(string fileLetter) => fileLetter.ToUpperInvariant() switch
{
"O" => 0,
"I" => 1,
"S" => 2,
_ => null,
};
}
@@ -0,0 +1,120 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
/// <summary>Pure builders/parsers for Execute PCCC (CIP 0x4B on class 0x67) carrying SLC/MicroLogix
/// protected typed logical commands and PLC-5 range reads. Framing is taken from the golden captures.
/// The Execute PCCC request is sent bare (no Unconnected Send, no route) per the captures.</summary>
public static class PcccServices
{
/// <summary>Largest data byte count one typed read/write may carry (single-byte count on the wire).</summary>
public const int MaxDataBytes = 236;
/// <summary>True: PLC-5 range READ (cmd 0x0F fnc 0x01) is capture-verified.</summary>
public static bool Plc5Verified { get; } = true;
/// <summary>False: no PLC-5 write capture exists, so no PLC-5 write builder ships.</summary>
public static bool Plc5WriteVerified { get; } = false;
/// <summary>Requestor vendor id the native transport puts in Execute PCCC: libplctag's, from the captures
/// (<c>07 3D F3 45 43 50 21</c> in <c>slc500-read-n7.hex</c>).</summary>
/// <remarks>Not the CIP originator vendor (<see cref="ConnectionOptions.VendorId"/>). ab_server answers every
/// PCCC request with this fixed requestor id (<c>PCCC_RESP_PREFIX</c> in its <c>pccc.c</c>) instead of
/// echoing the request's, while real controllers echo whatever was sent. Sending libplctag's identity is
/// the only choice that lets the echo check in <see cref="ParseReply"/> pass against both (found by
/// Task 17 slc500: with the 0x1337 originator identity every native PCCC reply was BadReply).</remarks>
public const ushort RequestorVendor = 0xF33D;
/// <summary>Requestor serial number paired with <see cref="RequestorVendor"/> (libplctag's, from the captures).</summary>
public const uint RequestorSerial = 0x21504345;
/// <summary>Wraps a PCCC command in Execute PCCC: <c>4B 02 20 67 24 01</c> + requestor id (7, vendor, serial) + PCCC.</summary>
public static byte[] ExecutePccc(ushort requestorVendor, uint requestorSerial, ReadOnlySpan<byte> pccc) =>
new CipWriter().U8(0x4B).U8(2).U8(0x20).U8(0x67).U8(0x24).U8(0x01)
.U8(7).U16(requestorVendor).U32(requestorSerial).Bytes(pccc).ToArray();
private static void Field(CipWriter w, int v)
{
if (v < 0) throw new AbTransportException(AbStatus.OutOfBounds, $"Negative PCCC field {v}.");
if (v > ushort.MaxValue) throw new AbTransportException(AbStatus.OutOfBounds, $"PCCC field {v} exceeds 65535.");
if (v < 0xFF) w.U8((byte)v); else w.U8(0xFF).U16((ushort)v);
}
private static void CheckCount(int n)
{
if (n < 0 || n > MaxDataBytes)
throw new AbTransportException(AbStatus.TooLarge, $"PCCC byte count {n} exceeds {MaxDataBytes}.");
}
private static void Logical(CipWriter w, PcccAddress a)
{
Field(w, a.EffectiveFileNumber);
w.U8(a.FileTypeCode);
Field(w, a.Element);
Field(w, a.ResolveSubElement().Word);
}
/// <summary>Protected typed logical read with 3 address fields (0x0F/0xA2).</summary>
public static byte[] SlcTypedRead(ushort tns, PcccAddress addr, int byteCount)
{
CheckCount(byteCount);
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xA2).U8((byte)byteCount);
Logical(w, addr);
return w.ToArray();
}
/// <summary>Protected typed logical write with 3 address fields (0x0F/0xAA). SLC/MicroLogix/LogixPccc
/// only: it must not be used for PLC-5 (the family guard lives in the AbLegacy native runtime, Task 14).</summary>
public static byte[] SlcTypedWrite(ushort tns, PcccAddress addr, ReadOnlySpan<byte> data)
{
CheckCount(data.Length);
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xAA).U8((byte)data.Length);
Logical(w, addr);
return w.Bytes(data).ToArray();
}
/// <summary>PLC-5 range read (0x0F/0x01), as captured from libplctag:
/// <c>offset u16 (0), totalTrans u16, levelMask u8 (0x06 = file+element), file*, element*, byteCount u8</c>.
/// <c>totalTrans</c> counts 16-bit WORDS (DF1 word range read; the capture shows 1 for one 2-byte N element).
/// Only N-file reads are capture-verified, other PLC-5 file types are unverified; sub-element addressing is not and throws Unsupported.</summary>
public static byte[] Plc5RangeRead(ushort tns, PcccAddress addr, ushort totalTrans, int byteCount)
{
CheckCount(byteCount);
if (addr.ResolveSubElement().Word != 0)
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 sub-element addressing is not capture-verified.");
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0x01).U16(0).U16(totalTrans).U8(0x06);
Field(w, addr.EffectiveFileNumber);
Field(w, addr.Element);
return w.U8((byte)byteCount).ToArray();
}
/// <summary>Validates an Execute PCCC reply and returns the PCCC data bytes.</summary>
/// <param name="reply">The parsed CIP reply.</param>
/// <param name="expectedTns">The request's TNS; checked before STS so a stale error reply is BadReply.</param>
/// <param name="expectedRequestorId">Optional full echoed requestor id (length byte included) to compare.</param>
public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns, byte[]? expectedRequestorId = null)
{
if (reply.GeneralStatus != 0)
throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
var r = new CipReader(reply.Data.Span);
var idLen = r.U8();
if (idLen < 1) throw new AbTransportException(AbStatus.BadReply, "Empty requestor id in PCCC reply.");
var idBody = r.Bytes(idLen - 1);
if (expectedRequestorId is not null
&& !(expectedRequestorId.Length == idLen && expectedRequestorId[0] == idLen
&& idBody.SequenceEqual(expectedRequestorId.AsSpan(1))))
throw new AbTransportException(AbStatus.BadReply, "PCCC reply requestor id does not match the request.");
var cmd = r.U8();
if (cmd != 0x4F)
throw new AbTransportException(AbStatus.BadReply, $"PCCC reply command 0x{cmd:X2} != 0x4F.");
var sts = r.U8();
var tns = r.U16();
if (tns != expectedTns)
throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
byte? ext = sts == 0xF0 ? r.U8() : null;
if (sts != 0)
throw new AbTransportException(AbStatusCodes.FromPccc(sts, ext),
$"PCCC STS 0x{sts:X2}{(ext is { } e ? $" EXT 0x{e:X2}" : "")}.");
return reply.Data[r.Position..];
}
}
@@ -0,0 +1,26 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
/// <summary>Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply).</summary>
public ref struct CipReader(ReadOnlySpan<byte> data)
{
private readonly ReadOnlySpan<byte> _data = data;
public int Position { get; private set; }
public readonly int Remaining => _data.Length - Position;
private ReadOnlySpan<byte> Take(int n)
{
if (n < 0 || Position + n > _data.Length)
throw new AbTransportException(AbStatus.BadReply, $"Reply truncated: need {n} bytes at {Position}, have {Remaining}.");
var s = _data.Slice(Position, n);
Position += n;
return s;
}
public byte U8() => Take(1)[0];
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8));
public ReadOnlySpan<byte> Bytes(int n) => Take(n);
public ReadOnlySpan<byte> Rest() => Take(Remaining);
}
@@ -0,0 +1,36 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
/// <summary>Growable little-endian writer for CIP / EIP frames.</summary>
public sealed class CipWriter
{
private byte[] _buf = new byte[256];
public int Length { get; private set; }
private Span<byte> Take(int n)
{
if (Length + n > _buf.Length) Array.Resize(ref _buf, Math.Max(_buf.Length * 2, Length + n));
var s = _buf.AsSpan(Length, n);
Length += n;
return s;
}
public CipWriter U8(byte v) { Take(1)[0] = v; return this; }
public CipWriter U16(ushort v) { BinaryPrimitives.WriteUInt16LittleEndian(Take(2), v); return this; }
public CipWriter U32(uint v) { BinaryPrimitives.WriteUInt32LittleEndian(Take(4), v); return this; }
public CipWriter U64(ulong v) { BinaryPrimitives.WriteUInt64LittleEndian(Take(8), v); return this; }
public CipWriter Bytes(ReadOnlySpan<byte> v) { v.CopyTo(Take(v.Length)); return this; }
public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; }
/// <summary>Overwrite a u16 already written at <paramref name="offset"/> (length back-patching).</summary>
/// <exception cref="ArgumentOutOfRangeException">The two bytes at <paramref name="offset"/> are not already
/// written (an offset in the spare buffer capacity would otherwise be patched silently and lost).</exception>
public void PatchU16(int offset, ushort v)
{
if (offset < 0 || offset > Length - 2)
throw new ArgumentOutOfRangeException(nameof(offset), offset, $"Cannot patch 2 bytes at {offset}: only {Length} bytes written.");
BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
}
public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Host.Tests"/>
</ItemGroup>
</Project>
@@ -32,6 +32,23 @@ public sealed class AbCipDriverOptions
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
/// <c>transport</c> key absent) means "use the default": the node-level
/// <c>Drivers:AbCip:DefaultTransport</c> appsetting, which itself defaults to <see cref="AbCipTransport.Native"/>;
/// an explicit value always wins. The effective transport is resolved in one place,
/// <c>AbCipTransportResolver</c>. Set <see cref="AbCipTransport.Libplctag"/> to fall back.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
/// <c>ReinitializeAsync</c>: the factories are chosen once in the constructor and held across
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
/// </summary>
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public AbCipTransport? Transport { get; init; }
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -50,6 +67,15 @@ public sealed class AbCipDriverOptions
/// </summary>
public bool EnableControllerBrowse { get; init; }
/// <summary>
/// Pause between the pages of a symbol listing, NATIVE transport only (libplctag pages inside its
/// own library). A large controller's listing is hundreds of pages; unpaced they are a back-to-back
/// burst on the controller's message-processing slice. Default 5 ms — small next to a page's own
/// round-trip, but it breaks up the burst. <see cref="TimeSpan.Zero"/> restores back-to-back paging.
/// Config key <c>browsePageDelayMs</c>; a negative value is treated as zero.
/// </summary>
public TimeSpan BrowsePageDelay { get; init; } = TimeSpan.FromMilliseconds(5);
/// <summary>
/// When <c>true</c>, declared ALMD tags are surfaced as alarm conditions
/// via <c>IAlarmSource</c>; the driver polls each subscribed
@@ -119,11 +145,9 @@ public sealed class AbCipDriverOptions
/// 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>
/// profile's <c>DefaultConnectionSize</c>. The native transport honours it on the wire (its Forward
/// Open size). The libplctag transport ignores it: the libplctag 1.5.2 wrapper has no
/// <c>ConnectionSize</c> property, so libplctag opens with its own size for the <c>plc</c> attribute.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Which CIP transport stack an AbCip driver instance (and its Test Connect probe / CLI run)
/// talks to the controller through. Serialises as a STRING in driver config JSON.
/// </summary>
public enum AbCipTransport
{
/// <summary>The libplctag-backed transport. The fallback; selected explicitly or via the node-level <c>Drivers:AbCip:DefaultTransport</c>.</summary>
Libplctag,
/// <summary>The native managed EtherNet/IP + CIP transport (shared Driver.Ab.Transport stack). The default.</summary>
Native,
}
@@ -1,4 +1,4 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
@@ -40,6 +40,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly IAbCipTagFactory _tagFactory;
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
/// <summary>
/// The batch template reader of the discovery run in progress on this async flow, if any. While set,
/// <see cref="FetchUdtShapeAsync"/> reads through it so a browse that fetches many templates holds ONE
/// device lease across them instead of opening a connection per template (Gitea #572).
/// </summary>
private readonly AsyncLocal<IAbCipTemplateReader?> _batchTemplateReader = new();
private readonly AbCipTemplateCache _templateCache = new();
/// <summary>
@@ -137,22 +144,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>Initializes a new instance of the <see cref="AbCipDriver"/> class.</summary>
/// <param name="options">The driver configuration options.</param>
/// <param name="driverInstanceId">A unique identifier for this driver instance.</param>
/// <param name="tagFactory">Optional factory for creating tag runtimes; uses libplctag default if null.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; uses libplctag default if null.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; uses libplctag default if null.</param>
/// <param name="tagFactory">Optional factory for creating tag runtimes; when null the resolved transport's (see <see cref="AbCipTransportResolver"/>) is used.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; when null the resolved transport's is used.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; when null the resolved transport's is used.</param>
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key
/// (<c>Drivers:AbCip:DefaultTransport</c>); null means the compiled <see cref="AbCipTransportResolver.Default"/>.</param>
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
IAbCipTemplateReaderFactory? templateReaderFactory = null,
ILogger<AbCipDriver>? logger = null)
ILogger<AbCipDriver>? logger = null,
AbCipTransport? defaultTransport = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagTagFactory();
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
var nodeDefault = defaultTransport ?? AbCipTransportResolver.Default;
_transport = AbCipTransportResolver.Resolve(options, nodeDefault);
_transportDescription = AbCipTransportResolver.Describe(options.Transport, nodeDefault);
var defaults = AbCipTransportFactories.For(_transport, options.BrowsePageDelay);
_tagFactory = tagFactory ?? defaults.Tags;
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
@@ -172,6 +188,50 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
}
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
internal IAbCipTagFactory TagFactory => _tagFactory;
/// <summary>The effective transport resolved at construction (see <see cref="AbCipTransportResolver"/>).
/// Describes the OPTION only: injected factories (tests) are used regardless.</summary>
public AbCipTransport EffectiveTransport => _transport;
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
// from the node log which stack serves this instance.
private readonly AbCipTransport _transport;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Native (default)".
private readonly string _transportDescription;
// Tags whose type-mismatch Warning has already been logged (final review I4): a mis-authored tag is
// polled forever, so the Warning is once per tag per driver instance, not once per poll.
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _typeMismatchWarned =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Logs a type-mismatch Warning the first time <paramref name="tagName"/> reports one on this instance.</summary>
private void WarnTypeMismatchOnce(string tagName, string message, params object?[] args)
{
if (_typeMismatchWarned.TryAdd(tagName, 0))
_logger.LogWarning(message, args);
}
// Tags whose malformed-name Warning has already been logged (Gitea #570) — polled forever, warned once.
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _configErrorWarned =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Logs the Warning for a tag whose own configuration cannot become a request
/// (<see cref="AbCipTagConfigurationException"/>), the first time <paramref name="tagName"/> reports
/// one on this instance.
/// </summary>
private void WarnConfigurationErrorOnce(string tagName, string reference, string device, AbCipTagConfigurationException ex)
{
if (_configErrorWarned.TryAdd(tagName, 0))
_logger.LogWarning(
"AbCip tag {Tag} on device {Device} is misconfigured: {Reason}; reporting BadConfigurationError " +
"until it is corrected — the device and its other tags are unaffected (logged once per tag)",
reference, device, ex.Message);
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -212,7 +272,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try
{
using var reader = _templateReaderFactory.Create();
var batch = _batchTemplateReader.Value;
using var own = batch is null ? _templateReaderFactory.Create() : null;
var reader = batch ?? own!;
var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
var shape = CipTemplateObjectDecoder.Decode(buffer);
if (shape is not null)
@@ -276,6 +338,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
@@ -538,7 +603,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try { probeRuntime?.Dispose(); } catch { }
probeRuntime = null;
state.ProbeInitialized = false;
backoffVerdict = false; // 05/STAB-8: keep the data-path window open
// 05/STAB-8: keep the data-path window open — unless the probe tag's own name is malformed
// (Gitea #570): that never reached the device, so it is no evidence either way.
backoffVerdict = ex is AbCipTagConfigurationException ? null : false;
}
// ConnectionBackoff is caller-serialized, and the data path now records under the
@@ -781,6 +848,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: the controller ANSWERED with a type that disagrees with the authored
// one — a deterministic config error, not a comms fault. Keep the handle (evicting would
// re-create it — and, for a sole lease holder, re-Forward-Open — on every poll), leave
// health alone, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
WarnTypeMismatchOnce(def.Name,
"AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " +
"{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)",
reference, def.DeviceHostAddress, def.DataType);
return ReadOutcome.None;
}
if (status != 0)
{
// Evict the stale handle so the next call re-creates it.
@@ -789,12 +869,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// mirrors the probe loop's recreate-on-failure behaviour.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
AbCipStatusMapper.MapStatus(status), null, now);
_logger.LogWarning(
"AbCip read returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
"AbCip read returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, reference, def.DeviceHostAddress);
return ReadOutcome.Degraded($"libplctag status {status} reading {reference}");
return ReadOutcome.Degraded($"transport status {status} reading {reference}");
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
@@ -808,11 +888,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
&& ExpectedScalarWidth(def.DataType) is { } width
&& runtime.GetSize() is var size && size >= 0 && size != width)
{
// Same class as the native TypeMismatch above (final review I4): deterministic, device
// answered — no Degraded, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
_logger.LogWarning(
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType",
WarnTypeMismatchOnce(def.Name,
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)",
reference, def.DeviceHostAddress, size, def.DataType, width);
return ReadOutcome.Degraded($"type mismatch reading {reference}: {size} bytes for {def.DataType}");
return ReadOutcome.None;
}
// Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
// element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
@@ -832,10 +914,33 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
throw;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
// health signal (Task 22 review r1).
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
return ReadOutcome.None;
}
catch (AbCipTagConfigurationException ace)
{
// Gitea #570: this tag's name/path cannot become a request — nothing reached the device, so
// no evict (nothing was cached), no health signal, no backoff; siblings keep reading.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadConfigurationError, null, now);
WarnConfigurationErrorOnce(def.Name, reference, def.DeviceHostAddress, ace);
return ReadOutcome.None;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Final review I4: deterministic, keep the handle, no health signal, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} refused: {Reason} (logged once per tag)",
reference, def.DeviceHostAddress, ate.Message);
return ReadOutcome.None;
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle. A libplctag
// exception carries its Status, so a tag the controller does not have reads
// Transport exception — evict so the next read creates a fresh handle. An AbTransportException
// carries its AbStatus, so a tag the controller does not have reads
// BadNodeIdUnknown and a decode past the buffer BadOutOfRange, not a comms error.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
@@ -850,7 +955,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Perform one whole-UDT read on the parent tag, then decode each
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
/// failure (parent read raised, non-zero transport status, or missing device) stamps
/// the mapped fault across every grouped member only — sibling groups + the
/// per-tag fallback list are unaffected.
/// </summary>
@@ -878,16 +983,26 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: deterministic — keep the handle, no health signal, warn once.
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(parent.Name,
"AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " +
"the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)",
group.ParentName, parent.DeviceHostAddress, group.Members.Count);
return ReadOutcome.None;
}
if (status != 0)
{
EvictRuntime(device, parent.Name);
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
var mapped = AbCipStatusMapper.MapStatus(status);
StampGroupStatus(group, results, now, mapped);
_logger.LogWarning(
"AbCip whole-UDT read returned non-zero libplctag status {LibplctagStatus} for parent {Parent} " +
"AbCip whole-UDT read returned non-zero transport status {Status} for parent {Parent} " +
"on device {Device}; {MemberCount} member values stamped with mapped status",
status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
return ReadOutcome.Degraded($"libplctag status {status} reading UDT {group.ParentName}");
return ReadOutcome.Degraded($"transport status {status} reading UDT {group.ParentName}");
}
foreach (var member in group.Members)
@@ -906,6 +1021,26 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
throw;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
return ReadOutcome.None;
}
catch (AbCipTagConfigurationException ace)
{
// Gitea #570: the parent's name cannot become a request — per-group config error, device untouched.
StampGroupStatus(group, results, now, AbCipStatusMapper.BadConfigurationError);
WarnConfigurationErrorOnce(parent.Name, group.ParentName, parent.DeviceHostAddress, ace);
return ReadOutcome.None;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(parent.Name,
"AbCip whole-UDT read of {Parent} on device {Device} refused: {Reason} (logged once per tag)",
group.ParentName, parent.DeviceHostAddress, ate.Message);
return ReadOutcome.None;
}
catch (Exception ex)
{
EvictRuntime(device, parent.Name);
@@ -989,12 +1124,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
opLock.Release();
}
if (status != 0)
if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, def.DataType);
}
else if (status != 0)
{
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.MapLibplctagStatus(status));
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
_logger.LogWarning(
"AbCip write returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
"AbCip write returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, w.FullReference, def.DeviceHostAddress);
}
@@ -1042,6 +1185,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"AbCip write value out of range for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
// A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms
// fault: BadNotSupported, keep the handle, leave health alone (Task 22 review r1).
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
_logger.LogWarning(ate,
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (AbCipTagConfigurationException ace)
{
// Gitea #570: a config error on this tag — no evict, no health signal, no backoff.
results[i] = new WriteResult(AbCipStatusMapper.BadConfigurationError);
WarnConfigurationErrorOnce(def.Name, w.FullReference, def.DeviceHostAddress, ace);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
// CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave
// health alone, warn once per tag. Retrying cannot succeed until the config is corrected.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, ate.Message);
}
catch (Exception ex)
{
// Transport / wire error — evict so the next write creates a fresh handle.
@@ -1076,7 +1244,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);
if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus);
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
var updated = Convert.ToBoolean(value)
@@ -1088,7 +1256,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbCipStatusMapper.Good
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
: AbCipStatusMapper.MapStatus(writeStatus);
}
finally
{
@@ -1148,7 +1316,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
var parsed = AbCipTagPath.TryParse(def.TagPath)
?? throw new InvalidOperationException(
?? throw new AbCipTagConfigurationException(
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
@@ -1171,11 +1339,30 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// path then boxes them into a typed CLR array. Scalar tags pass count 1 + IsArray false.
var runtime = _tagFactory.Create(
device.BuildCreateParams(
parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def)));
parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def),
authoredType: def.DataType));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Gitea #570: the controller ANSWERED with a type refusal — the tag exists and the session
// works. Keep the handle (disposing it re-creates it, and for a sole lease holder
// re-Forward-Opens, on every poll) and let the caller report BadTypeMismatch. The native
// runtime completes its initialization instead of throwing this; the cache keeps any runtime
// that does throw it from churning.
device.Backoff.RecordSuccess();
if (!device.Runtimes.TryAdd(def.Name, runtime)) runtime.Dispose();
throw;
}
catch (AbCipTagConfigurationException)
{
// Gitea #570: the tag's own name cannot become a request — no I/O happened, so it says nothing
// about the device: leave the backoff window exactly as it was.
runtime.Dispose();
throw;
}
catch (Exception ex)
{
runtime.Dispose();
@@ -1183,10 +1370,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// not answer. "Tag not found" means the controller replied over a working session; it
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
// every sibling tag on the device (seen live: one typo blacked out the whole device).
if (ex is LibPlcTagException lpe
&& AbCipStatusMapper.TryGetStatus(lpe, out var status)
&& status != Status.Ok
&& AbCipDriverProbe.IsReachableStatus(status))
if (ex is AbTransportException ate
&& ate.Status != AbStatus.Ok
&& AbCipDriverProbe.IsReachableStatus(ate.Status))
device.Backoff.RecordSuccess();
else
device.Backoff.RecordFailure(DateTime.UtcNow);
@@ -1211,7 +1397,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose
/// it so the next read/write call re-creates and re-initializes a fresh handle.
/// Called from <see cref="ReadSingleAsync"/>, <see cref="ReadGroupAsync"/>, and
/// <see cref="WriteAsync"/> after a non-zero libplctag status or transport exception —
/// <see cref="WriteAsync"/> after a non-zero transport status or transport exception —
/// mirroring the probe loop's recreate-on-failure behaviour.
/// </summary>
private static void EvictRuntime(DeviceState device, string tagName)
@@ -1286,13 +1472,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
// walker (LibplctagTagEnumerator) is the factory default,
// walker (SymbolListingEnumerator) is the factory default,
// so leaving the flag off keeps the strict-config path for deployments where only
// declared tags should appear.
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
{
using var enumerator = _enumeratorFactory.Create();
var deviceParams = state.BuildCreateParams("@tags", _options.Timeout);
// One template-reader lease across every UDT shape this device's browse fetches.
using var batchReader = _templateReaderFactory.CreateBatch();
_batchTemplateReader.Value = batchReader;
using var clearBatch = new ClearOnDispose(_batchTemplateReader);
IAddressSpaceBuilder? discoveredFolder = null;
// Dedup discovered member full-names within this device's Discovered folder.
@@ -1370,6 +1560,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>Clears an <see cref="AsyncLocal{T}"/> when the scope that set it ends.</summary>
private sealed class ClearOnDispose(AsyncLocal<IAbCipTemplateReader?> slot) : IDisposable
{
public void Dispose() => slot.Value = null;
}
/// <summary>
/// <c>true</c> for a UDT member the controller generates rather than the user declares: the
/// hidden SINT/DINT hosts Logix packs BOOL members into (<c>ZZZZZZZZZZAlarms0</c>) and AOI
@@ -1687,9 +1883,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// length for a 1-D array tag (Phase 4c). Coerced to a minimum of 1.</param>
/// <param name="isArray">Review I-1 — the EXPLICIT array signal threaded through so a
/// 1-element array (<paramref name="elementCount"/> 1) is still read as an array.</param>
/// <param name="authoredType">The tag definition's authored type, for the native runtime's
/// returned-type check; null skips it.</param>
/// <returns>The computed tag creation parameters.</returns>
public AbCipTagCreateParams BuildCreateParams(
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false) => new(
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false,
AbCipDataType? authoredType = null) => new(
Gateway: ParsedAddress.Gateway,
Port: ParsedAddress.Port,
CipPath: ParsedAddress.CipPath,
@@ -1699,7 +1898,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize,
ElementCount: elementCount < 1 ? 1 : elementCount,
IsArray: isArray);
IsArray: isArray,
AuthoredType: authoredType,
UseConnectedMessaging: Profile.SupportsConnectedMessaging);
/// <summary>Disposes all runtime tag handles and clears the caches.</summary>
public void DisposeHandles()
@@ -1,4 +1,5 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
@@ -15,13 +16,22 @@ public static class AbCipDriverFactoryExtensions
public const string DriverTypeName = "AbCip";
/// <summary>
/// Registers the AB CIP driver factory with the driver registry.
/// Registers the AB CIP driver factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct an
/// <see cref="ILogger{AbCipDriver}"/> per driver instance — without it the driver runs with the
/// null logger, so none of its diagnostics (including the per-init transport line) reach the
/// node log. Mirrors the AbLegacy / Modbus registration pattern.
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
public static void Register(DriverFactoryRegistry registry)
/// <param name="loggerFactory">Optional logger factory for driver instances.</param>
/// <param name="defaultTransport">The node-level transport for drivers whose config has no
/// <c>transport</c> key (<c>Drivers:AbCip:DefaultTransport</c>, final review I1). An explicit
/// per-driver key still wins.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, CreateInstance);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, defaultTransport));
}
/// <summary>
@@ -31,15 +41,27 @@ public static class AbCipDriverFactoryExtensions
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>Creates an instance of the AB CIP driver with an optional logger.</summary>
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key.</param>
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
internal static AbCipDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
var options = ParseOptions(driverInstanceId, driverConfigJson);
return new AbCipDriver(options, driverInstanceId);
return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger<AbCipDriver>(),
defaultTransport: defaultTransport);
}
/// <summary>
/// Deserialise an AB CIP driver-config JSON document into <see cref="AbCipDriverOptions"/>.
/// Shared by <see cref="CreateInstance"/> (first construction) and
/// Shared by <see cref="CreateInstance(string, string, ILoggerFactory?, AbCipTransport)"/> (first construction) and
/// <see cref="AbCipDriver.InitializeAsync"/> / <see cref="AbCipDriver.ReinitializeAsync"/>
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
/// actually takes effect rather than being silently discarded.
@@ -62,7 +84,7 @@ public static class AbCipDriverFactoryExtensions
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, tagName: null, driverInstanceId, "PlcFamily",
fallback: AbCipPlcFamily.ControlLogix),
DeviceName: d.DeviceName,
AllowPacking: d.AllowPacking,
@@ -81,7 +103,13 @@ public static class AbCipDriverFactoryExtensions
ProbeTagPath = dto.Probe?.ProbeTagPath,
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
// Absent ⇒ null ("use the default", resolved by AbCipTransportResolver); a bad value still
// fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
? null
: ParseEnum<AbCipTransport>(dto.Transport, tagName: null, driverInstanceId, "Transport"),
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
BrowsePageDelay = TimeSpan.FromMilliseconds(Math.Max(0, dto.BrowsePageDelayMs ?? 5)),
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
EnableDeclarationOnlyUdtGrouping = dto.EnableDeclarationOnlyUdtGrouping ?? false,
@@ -103,19 +131,26 @@ public static class AbCipDriverFactoryExtensions
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
// tagName null = a driver-level (or device-level) config field: the error text names the driver config,
// not a tag. Never pass a placeholder like "device" as tagName — it renders as "tag 'device'" (#574).
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
T? fallback = null) where T : struct, Enum
{
var subject = tagName is null ? $"config in '{driverInstanceId}'" : $"tag '{tagName}'";
if (string.IsNullOrWhiteSpace(raw))
{
if (fallback.HasValue) return fallback.Value;
throw new InvalidOperationException(
$"AB CIP tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
tagName is null
? $"AB CIP {subject} missing {field}"
: $"AB CIP {subject} in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
// Enum.TryParse also accepts any integer string ("7"), defined or not; an undefined value would
// otherwise fall through to a switch's default arm silently (Task 22 review r1 M2).
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
? v
: throw new InvalidOperationException(
$"AB CIP tag '{tagName}' has unknown {field} '{raw}'. " +
$"AB CIP {subject} has unknown {field} '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
}
@@ -128,6 +163,11 @@ public static class AbCipDriverFactoryExtensions
internal sealed class AbCipDriverConfigDto
{
/// <summary>
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means the node default.
/// </summary>
public string? Transport { get; init; }
/// <summary>
/// Gets or sets the timeout in milliseconds for operations.
/// </summary>
@@ -138,6 +178,11 @@ public static class AbCipDriverFactoryExtensions
/// </summary>
public bool? EnableControllerBrowse { get; init; }
/// <summary>
/// Gets or sets the pause in milliseconds between symbol-listing pages (native transport); absent means 5.
/// </summary>
public int? BrowsePageDelayMs { get; init; }
/// <summary>
/// Gets or sets whether alarm projection is enabled.
/// </summary>
@@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
@@ -11,26 +11,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Two-phase Test-Connect probe for the <see cref="AbCipDriverOptions"/>-shaped driver config.
/// Phase 1: bare TCP connect to the first device's gateway host + EtherNet/IP port to
/// quickly reject unreachable targets. Phase 2: initialises a libplctag <see cref="Tag"/>
/// quickly reject unreachable targets. Phase 2: initialises a tag runtime
/// against the same device to open a real EIP session and perform a CIP Forward Open —
/// confirming the remote endpoint actually speaks EtherNet/IP CIP, not just accepts TCP.
/// A device that accepts the TCP connection but is not a CIP controller (wrong CIP path,
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
/// a false-positive green tick.
/// <para>
/// Status-code discrimination: libplctag statuses that indicate the controller answered
/// the CIP session but could not resolve the probe tag
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
/// a non-existent tag name proves CIP connectivity. Statuses that indicate a transport or
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
/// "handshake failed — Ok = false". All other error statuses default to
/// "handshake failed" (conservative).
/// Status-code discrimination: <c>AbStatus.Ok</c> and <c>AbStatus.NotFound</c> mean the controller
/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are
/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply,
/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false".
/// </para>
/// </summary>
public sealed class AbCipDriverProbe : IDriverProbe
@@ -42,6 +33,24 @@ public sealed class AbCipDriverProbe : IDriverProbe
Converters = { new JsonStringEnumConverter() },
};
private readonly IAbCipTagFactory? _tagFactory;
private readonly AbCipTransport _defaultTransport;
/// <summary>Initializes a new instance of the <see cref="AbCipDriverProbe"/> class.</summary>
/// <param name="tagFactory">Factory for the probe's tag runtime; when null the config's
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
/// <param name="defaultTransport">The node-level default for a config with no <c>transport</c> key — the
/// SAME value the node's driver factory uses, so Test Connect probes the stack the driver would run.</param>
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
_tagFactory = tagFactory;
_defaultTransport = defaultTransport;
}
/// <summary>The node-level default this probe resolves an absent key to (wiring tests).</summary>
public AbCipTransport DefaultTransport => _defaultTransport;
/// <inheritdoc />
public string DriverType => "AbCip";
@@ -122,9 +131,10 @@ public sealed class AbCipDriverProbe : IDriverProbe
TagName: tagName,
Timeout: timeout,
AllowPacking: firstDevice?.AllowPacking ?? profile.SupportsRequestPacking,
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize);
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize,
UseConnectedMessaging: profile.SupportsConnectedMessaging);
var rt = new LibplctagTagRuntime(p);
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts, _defaultTransport)).Tags).Create(p);
try
{
await rt.InitializeAsync(cts.Token);
@@ -132,10 +142,10 @@ public sealed class AbCipDriverProbe : IDriverProbe
// InitializeAsync completed without throwing — either the tag was found (Ok) or
// libplctag returned a non-exception status. Check via GetStatus().
var statusCode = (Status)rt.GetStatus();
var statusCode = (AbStatus)rt.GetStatus();
if (IsReachableStatus(statusCode))
{
var msg = statusCode == Status.Ok
var msg = statusCode == AbStatus.Ok
? "CIP session OK"
: "CIP session OK (controller reachable; probe tag not found)";
return new(true, msg, sw.Elapsed);
@@ -148,9 +158,9 @@ public sealed class AbCipDriverProbe : IDriverProbe
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (LibPlcTagException ex)
catch (AbTransportException ex)
{
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
// AbTransportException carries the Status; classify as reachable vs transport failure.
if (IsReachableException(ex))
{
sw.Stop();
@@ -170,42 +180,23 @@ public sealed class AbCipDriverProbe : IDriverProbe
}
/// <summary>
/// Returns <c>true</c> for libplctag statuses that indicate the CIP controller answered
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
/// </summary>
internal static bool IsReachableStatus(Status s) => s is
Status.Ok or
Status.ErrorNotFound or
Status.ErrorNoMatch or
Status.ErrorBadDevice;
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
/// <summary>
/// Decides whether a <see cref="LibPlcTagException"/> is a "controller answered CIP but the
/// Decides whether an <see cref="AbTransportException"/> is a "controller answered CIP but the
/// probe tag did not resolve" condition (reachable) or a session / transport failure
/// (unreachable), by recovering the <see cref="Status"/> the wrapper threw on and deferring
/// to <see cref="IsReachableStatus"/> — one classification, not two that can drift.
/// (unreachable), deferring to <see cref="IsReachableStatus"/> — one classification, not two.
/// </summary>
/// <param name="ex">The exception libplctag threw out of <c>InitializeAsync</c>.</param>
/// <param name="ex">The exception thrown out of <c>InitializeAsync</c>.</param>
/// <returns><c>true</c> when the controller answered; <c>false</c> for a transport/session failure.</returns>
/// <remarks>
/// <b>The status arrives as the managed enum NAME.</b> <c>NativeTagWrapper.ThrowIfStatusNotOk</c>
/// constructs the exception with <c>Status.ToString()</c>, so the message is exactly
/// <c>"ErrorNotFound"</c> — never the native C spelling <c>"STATUS_NOT_FOUND"</c> this method
/// used to look for. Those substrings could not match, so the reachable classification was
/// unreachable on this path — and this IS the path a probe takes whenever its tag does not
/// resolve, which is every Test Connect against a device with no authored tags. The native
/// spellings are kept as a secondary match in case a future wrapper reports them.
/// </remarks>
internal static bool IsReachableException(LibPlcTagException ex)
internal static bool IsReachableException(AbTransportException ex)
{
var msg = ex.Message ?? string.Empty;
if (AbCipStatusMapper.TryGetStatus(ex, out var status))
return IsReachableStatus(status);
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
ArgumentNullException.ThrowIfNull(ex);
return IsReachableStatus(ex.Status);
}
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
@@ -1,37 +1,14 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Maps libplctag / CIP General Status codes to OPC UA StatusCodes. Mirrors the shape of
/// <c>ModbusDriver.MapModbusExceptionToStatus</c> so Admin UI status displays stay
/// uniform across drivers.
/// Maps transport <see cref="AbStatus"/> values to OPC UA StatusCodes through the table shared with AbLegacy
/// (<see cref="AbOpcUaStatusMap"/>): timeout, not-found, not-allowed, out-of-bounds, unsupported, type-mismatch
/// and device-state-conflict get their specific codes; the remaining transport errors fold into
/// <c>BadCommunicationError</c>. (The raw CIP general-status mapper that used to live here had no callers — the
/// transports surface an <see cref="AbStatus"/>, not the status byte — and was removed, #580.)
/// </summary>
/// <remarks>
/// <para>Coverage: the CIP general-status values an AB PLC actually returns during normal
/// driver operation. Full CIP Volume 1 Appendix B lists 50+ codes; the ones here are the
/// ones that move the driver's status needle:</para>
/// <list type="bullet">
/// <item>0x00 success — OPC UA <c>Good (0)</c>.</item>
/// <item>0x04 path segment error / 0x05 path destination unknown — <c>BadNodeIdUnknown</c>
/// (tag doesn't exist).</item>
/// <item>0x06 partial data transfer — <c>GoodMoreData</c> (fragmented read underway).</item>
/// <item>0x08 service not supported — <c>BadNotSupported</c> (e.g. write on a safety
/// partition tag from a non-safety task).</item>
/// <item>0x0A / 0x13 attribute-list error / insufficient data — <c>BadOutOfRange</c>
/// (type mismatch or truncated buffer).</item>
/// <item>0x0B already in requested mode — benign, treated as <c>Good</c>.</item>
/// <item>0x0E attribute not settable — <c>BadNotWritable</c>.</item>
/// <item>0x10 device state conflict — <c>BadDeviceFailure</c> (program-mode protected
/// writes during download / test-mode transitions).</item>
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
/// <item>libplctag.NET <see cref="Status"/> errors — mapped per-member by
/// <see cref="MapLibplctagStatus(Status)"/>: timeout, not-found, not-allowed, and
/// out-of-bounds get their specific OPC UA codes; the remaining transport errors
/// fold into <c>BadCommunicationError</c>.</item>
/// </list>
/// </remarks>
public static class AbCipStatusMapper
{
public const uint Good = 0u;
@@ -45,85 +22,33 @@ public static class AbCipStatusMapper
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
/// <param name="status">The CIP general-status byte value.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapCipGeneralStatus(byte status) => status switch
{
0x00 => Good,
0x04 or 0x05 => BadNodeIdUnknown,
0x06 => GoodMoreData,
0x08 => BadNotSupported,
0x0A or 0x13 => BadOutOfRange,
0x0B => Good,
0x0E => BadNotWritable,
0x10 => BadDeviceFailure,
0x16 => BadNodeIdUnknown,
_ => BadInternalError,
};
public const uint BadConfigurationError = 0x80890000u;
/// <summary>
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
/// is <c>(int)Tag.GetStatus()</c> — i.e. the underlying value of the libplctag.NET
/// <see cref="Status"/> enum, NOT a raw native <c>PLCTAG_ERR_*</c> constant. The wrapper
/// renumbers the native codes into a contiguous enum, so this method switches on the
/// <see cref="Status"/> members directly to stay correct if the wrapper renumbers again.
/// <see cref="Status.Ok"/> is success; <see cref="Status.Pending"/> is an in-flight
/// operation; every other (negative) member is an error.
/// Map a transport status code to an OPC UA StatusCode. The integer is
/// <see cref="IAbCipTagRuntime.GetStatus"/>, i.e. the underlying value of <see cref="AbStatus"/>.
/// </summary>
/// <param name="status">The libplctag status code as an integer.</param>
/// <param name="status">The <see cref="AbStatus"/> as an integer.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
/// <summary>
/// Recover the libplctag <see cref="Status"/> a <see cref="LibPlcTagException"/> was thrown on.
/// The wrapper builds the exception from <c>Status.ToString()</c>, so the message is the enum
/// name (<c>"ErrorNotFound"</c>).
/// Map an <see cref="AbStatus"/> to an OPC UA StatusCode. Timeout, not-found, not-allowed and
/// out-of-bounds get their specific codes; the remaining transport errors fold into
/// <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <param name="status">The recovered status.</param>
/// <returns><c>true</c> when the message names a defined <see cref="Status"/>.</returns>
public static bool TryGetStatus(LibPlcTagException ex, out Status status)
{
ArgumentNullException.ThrowIfNull(ex);
return Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out status)
&& Enum.IsDefined(status);
}
/// <param name="status">The transport status.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapStatus(AbStatus status) => AbOpcUaStatusMap.MapStatus(status);
/// <summary>
/// Status for an exception thrown while creating / reading a tag: a libplctag exception maps
/// through its <see cref="Status"/> (so a tag the controller does not have reads
/// Status for an exception thrown while creating / reading a tag: a transport exception maps
/// through its <see cref="AbStatus"/> (so a tag the controller does not have reads
/// <see cref="BadNodeIdUnknown"/>, not a comms error); anything else is
/// <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The OPC UA status code.</returns>
public static uint MapException(Exception ex) =>
ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status)
? MapLibplctagStatus(status)
: BadCommunicationError;
/// <summary>
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
/// <see cref="IAbCipTagRuntime.GetStatus"/> seam, which returns the boxed-as-int value.
/// </summary>
/// <param name="status">The libplctag Status enum value.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapLibplctagStatus(Status status) => status switch
{
Status.Ok => Good,
Status.Pending => GoodMoreData,
Status.ErrorTimeout => BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
Status.ErrorNotAllowed => BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
_ => BadCommunicationError,
};
AbOpcUaStatusMap.MapException(ex);
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// A tag's own configuration cannot be turned into a request at all — a malformed tag path, or a
/// symbolic name the native CIP encoder refuses (Gitea #570). Raised before any I/O, so it says nothing
/// about the device: the driver reports it per tag as <c>BadConfigurationError</c> and never counts it
/// against the device's connect backoff or its health, so healthy tags on the same device keep reading.
/// </summary>
internal sealed class AbCipTagConfigurationException : Exception
{
/// <summary>Creates the exception.</summary>
/// <param name="message">What is wrong with the tag's configuration.</param>
/// <param name="innerException">The encoder's own exception, if any.</param>
public AbCipTagConfigurationException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>Maps an <see cref="AbCipTransport"/> to the matching trio of runtime factories.</summary>
internal static class AbCipTransportFactories
{
/// <summary>Returns the tag, enumerator and template-reader factories for <paramref name="transport"/>.</summary>
/// <param name="transport">The transport.</param>
/// <param name="browsePageDelay">Pause between native symbol-listing pages; null is the compiled default.</param>
public static (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)
For(AbCipTransport transport, TimeSpan? browsePageDelay = null) => transport switch
{
AbCipTransport.Native => (new NativeAbCipTagFactory(),
new NativeAbCipTagEnumeratorFactory(browsePageDelay ?? NativeListingReader.DefaultPageDelay),
new NativeAbCipTemplateReaderFactory()),
_ => (new LibplctagTagFactory(), new LibplctagTagEnumeratorFactory(), new LibplctagTemplateReaderFactory()),
};
}
@@ -0,0 +1,65 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// The ONE place an AbCip driver's effective transport is decided from its configured
/// <see cref="AbCipDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
/// CLI (through the driver) all resolve through here, so they cannot disagree about what an absent
/// <c>transport</c> key means.
/// <para>Precedence (final review I1): an explicit per-driver <c>transport</c> key wins; otherwise the
/// node-level default (<c>Drivers:AbCip:DefaultTransport</c>, read in the Host composition root and passed
/// in as <c>nodeDefault</c>); otherwise the compiled <see cref="Default"/>.</para>
/// </summary>
public static class AbCipTransportResolver
{
/// <summary>The compiled default: the transport an absent key selects when no node default is configured.</summary>
public const AbCipTransport Default = AbCipTransport.Native;
/// <summary>Returns the effective transport: an explicit value always wins; null means <paramref name="nodeDefault"/>.</summary>
/// <param name="configured">The configured transport, or null for "use the default".</param>
/// <param name="nodeDefault">The node-level default for drivers with no key (the compiled <see cref="Default"/> unless pinned).</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
configured ?? nodeDefault;
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
/// <param name="options">The driver options.</param>
/// <param name="nodeDefault">The node-level default for drivers with no key.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipDriverOptions options, AbCipTransport nodeDefault = Default)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport, nodeDefault);
}
/// <summary>
/// Parses a transport name the way the driver-config <c>transport</c> key and the node-level default
/// are parsed: a defined enum NAME, case-insensitive. Numeric strings ("0", "7") and the flags form
/// ("Native,Libplctag") are rejected — <c>Enum.TryParse</c> alone accepts both.
/// </summary>
/// <param name="raw">The raw value.</param>
/// <param name="transport">The parsed transport.</param>
/// <returns>True when <paramref name="raw"/> names a transport.</returns>
public static bool TryParse(string? raw, out AbCipTransport transport)
{
transport = Default;
if (string.IsNullOrWhiteSpace(raw)) return false;
// Exact name match only: Enum.TryParse would also accept "1" and the flags form "Native,Libplctag".
var name = Enum.GetNames<AbCipTransport>()
.FirstOrDefault(n => string.Equals(n, raw.Trim(), StringComparison.OrdinalIgnoreCase));
return name is not null && Enum.TryParse(name, out transport);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line:
/// <c>Native (default)</c> when the key was absent and the compiled default applies,
/// <c>Libplctag (node default)</c> when the key was absent and the node pinned a different default,
/// <c>Native (configured)</c> when the key was explicit.
/// </summary>
/// <param name="configured">The configured transport, or null.</param>
/// <param name="nodeDefault">The node-level default.</param>
/// <returns>A short description.</returns>
public static string Describe(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
configured is not null ? $"{configured} (configured)"
: nodeDefault == Default ? $"{nodeDefault} (default)"
: $"{nodeDefault} (node default)";
}
@@ -25,10 +25,10 @@ public interface IAbCipTagRuntime : IDisposable
Task WriteAsync(CancellationToken cancellationToken);
/// <summary>
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>. Zero on success, negative on error.
/// Raw transport status code (an <c>AbStatus</c> value) — mapped to an OPC UA StatusCode via
/// <see cref="AbCipStatusMapper.MapStatus(int)"/>. Zero on success, negative on error.
/// </summary>
/// <returns>The raw libplctag status code (zero on success, negative on error).</returns>
/// <returns>The <c>AbStatus</c> value (zero on success, negative on error).</returns>
int GetStatus();
/// <summary>
@@ -119,6 +119,13 @@ public interface IAbCipTagFactory
/// element count for an <c>isArray</c> tag — including a 1-element array.</param>
/// <param name="IsArray">Review I-1 — the EXPLICIT array signal threaded from the tag definition so
/// a 1-element array (<see cref="ElementCount"/> 1) is still read as an array, not a scalar.</param>
/// <param name="AuthoredType">The tag definition's authored data type, when known. The native runtime
/// checks the CIP type code the controller returns against it (a REAL authored as DINT is
/// <c>TypeMismatch</c>); libplctag ignores it. Null (enumeration, probes, BOOL-in-DINT parents)
/// skips the check.</param>
/// <param name="UseConnectedMessaging">From <c>AbCipPlcFamilyProfile.SupportsConnectedMessaging</c> at
/// every construction site, so the family profile stays the single authority for the native
/// transport's routing choice. libplctag ignores it.</param>
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
@@ -129,7 +136,9 @@ public sealed record AbCipTagCreateParams(
bool AllowPacking = true,
int ConnectionSize = 4002,
int ElementCount = 1,
bool IsArray = false)
bool IsArray = false,
AbCipDataType? AuthoredType = null,
bool UseConnectedMessaging = true)
{
/// <summary>
/// The value for libplctag's <c>gateway</c> attribute: <c>host:port</c>, or bare <c>host</c> when
@@ -29,4 +29,13 @@ public interface IAbCipTemplateReaderFactory
/// <summary>Creates a new template reader instance.</summary>
/// <returns>A new <see cref="IAbCipTemplateReader"/> instance.</returns>
IAbCipTemplateReader Create();
/// <summary>
/// Creates a reader meant to serve MANY <see cref="IAbCipTemplateReader.ReadAsync"/> calls: the native
/// reader then holds one device lease from its first read until it is disposed, so a caller fetching
/// N templates opens one connection rather than one per template. Dispose it when the batch ends.
/// The default is <see cref="Create"/> (a transport whose reader has no per-read connection cost).
/// </summary>
/// <returns>A new <see cref="IAbCipTemplateReader"/> instance.</returns>
IAbCipTemplateReader CreateBatch() => Create();
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Normalizes a parsed CIP route for libplctag's <c>Path</c> attribute. libplctag rejects an
/// empty-string path with <c>ErrorBadData</c>; "no route" (Micro800, unrouted MicroLogix) must be
/// expressed by omitting the attribute, i.e. <see langword="null"/>.
/// </summary>
internal static class LibplctagPath
{
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
/// <param name="path">The parsed CIP path.</param>
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
}
@@ -0,0 +1,76 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>The ONLY place libplctag's <see cref="Status"/> meets <see cref="AbStatus"/>.</summary>
internal static class LibplctagStatusTranslation
{
/// <summary>Translates a libplctag status into the driver-owned <see cref="AbStatus"/>.</summary>
/// <param name="s">The libplctag status.</param>
/// <returns>The equivalent transport-neutral status.</returns>
public static AbStatus ToAbStatus(Status s) => s switch
{
Status.Ok => AbStatus.Ok,
Status.Pending => AbStatus.Pending,
Status.ErrorTimeout => AbStatus.Timeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
Status.ErrorNotAllowed => AbStatus.NotAllowed,
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
Status.ErrorTooLarge => AbStatus.TooLarge,
Status.ErrorTooSmall => AbStatus.TooSmall,
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
_ => AbStatus.BadConnection,
};
/// <summary>
/// Recovers the status a <see cref="LibPlcTagException"/> was thrown on. libplctag builds the
/// exception from <c>Status.ToString()</c>, so the message is the enum name.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The translated status, or <see cref="AbStatus.BadConnection"/> when the message names no status.</returns>
public static AbStatus ToAbStatus(LibPlcTagException ex) =>
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
? ToAbStatus(s)
: AbStatus.BadConnection;
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <param name="op">The libplctag operation.</param>
/// <returns>A task completing when the operation does.</returns>
public static async Task Wrap(Func<Task> op)
{
try { await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="op">The libplctag operation.</param>
/// <returns>The operation result.</returns>
public static async Task<T> Wrap<T>(Func<Task<T>> op)
{
try { return await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="op">The libplctag call.</param>
/// <returns>The call result.</returns>
public static T WrapSync<T>(Func<T> op)
{
try { return op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <param name="op">The libplctag call.</param>
public static void WrapSync(Action op)
{
try { op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
}
@@ -1,85 +1,25 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Real <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table by
/// reading the <c>@tags</c> pseudo-tag via libplctag + decoding the CIP Symbol Object
/// response with <see cref="CipSymbolObjectDecoder"/>.
/// libplctag listing reader: reads a <c>@tags</c> / <c>Program:X.@tags</c> pseudo-tag and returns
/// its raw Symbol Object bytes for <see cref="SymbolListingEnumerator"/>.
/// </summary>
/// <remarks>
/// <para>libplctag's <c>Tag.GetBuffer()</c> returns the raw Symbol Object bytes when the
/// tag name is <c>@tags</c>. The decoder walks the concatenated entries + emits
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
///
/// <para><see cref="EmptyAbCipTagEnumerator"/> remains available for tests that don't want
/// to touch the native library; the production factory default
/// (<see cref="LibplctagTagEnumeratorFactory"/>) wires this implementation in.</para>
/// </remarks>
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
internal static class LibplctagListingReader
{
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
/// <summary>Production enumerator: reads each listing through libplctag.</summary>
public LibplctagTagEnumerator() : this(ReadListingAsync) { }
/// <summary>Enumerator over an injected listing reader (unit-test seam).</summary>
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
/// returns its raw Symbol Object bytes.</param>
internal LibplctagTagEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
=> _readListing = readListing;
/// <inheritdoc />
/// <remarks>
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
/// <c>Program:&lt;name&gt;.@tags</c>. So the controller listing is read first, then each program's
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
/// skipped — browse is best-effort and one unreadable program must not cost the others.
/// </remarks>
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
// The decoder already surfaces each struct tag's CIP template instance id on
// AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
// re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
var programs = new List<string>();
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
{
if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
yield return tag;
}
foreach (var program in programs)
{
byte[] programBuffer;
try
{
programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception)
{
continue;
}
foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
yield return tag;
}
}
private static async Task<byte[]> ReadListingAsync(
internal static async Task<byte[]> ReadAsync(
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
{
using var tag = BuildSymbolListTag(deviceParams, listingName);
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
})).ConfigureAwait(false);
}
/// <summary>
@@ -99,16 +39,13 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams, string listingName = "@tags") => new()
{
Gateway = deviceParams.LibplctagGateway,
Path = deviceParams.CipPath,
Path = LibplctagPath.Normalize(deviceParams.CipPath),
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = listingName,
Timeout = deviceParams.Timeout,
};
/// <summary>Nothing to release — each listing's libplctag tag is disposed after its read.</summary>
public void Dispose() { }
private static PlcType MapPlcType(string attribute) => attribute switch
{
"controllogix" => PlcType.ControlLogix,
@@ -118,9 +55,9 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
};
}
/// <summary>Factory for <see cref="LibplctagTagEnumerator"/>.</summary>
/// <summary>Factory for the libplctag-backed <see cref="SymbolListingEnumerator"/>.</summary>
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
{
/// <inheritdoc />
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
public IAbCipTagEnumerator Create() => new SymbolListingEnumerator(LibplctagListingReader.ReadAsync);
}
@@ -1,4 +1,6 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -13,16 +15,31 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
private readonly Tag _tag;
/// <summary>
/// The status the native tag held when a failed initialisation destroyed it (see
/// <see cref="LibplctagOrphanRelease"/>); null while the wrapper's own handle is authoritative.
/// </summary>
private Status? _releasedStatus;
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
private int _releasedHandle;
/// <summary>Initializes a new instance of the LibplctagTagRuntime class.</summary>
/// <param name="p">The tag creation parameters.</param>
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
_tag = new Tag
_tag = BuildTag(p);
}
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
internal static Tag BuildTag(AbCipTagCreateParams p) =>
new Tag
{
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
Gateway = p.LibplctagGateway,
Path = p.CipPath,
Path = LibplctagPath.Normalize(p.CipPath),
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = p.TagName,
@@ -32,28 +49,73 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
// count so libplctag pulls every element in one CIP read; scalar tags pass 1.
ElementCount = p.ElementCount > 1 ? p.ElementCount : 1,
};
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
/// <inheritdoc />
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
public Task ReadAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
/// <summary>
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
/// </summary>
private async Task RunReleasingOrphanAsync(Func<Task> op)
{
try
{
await LibplctagStatusTranslation.Wrap(op).ConfigureAwait(false);
_releasedStatus = null;
_releasedHandle = 0;
}
catch
{
var handle = LibplctagOrphanRelease.HandleOf(_tag);
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
{
_releasedStatus = status;
_releasedHandle = handle;
}
throw;
}
}
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
/// (0 if none was created). Tests only.</summary>
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
/// <inheritdoc />
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
public int GetStatus() =>
(int)LibplctagStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
/// <inheritdoc />
public int GetStatus() => (int)_tag.GetStatus();
/// <inheritdoc />
public int GetSize() => _tag.GetSize();
/// <summary>
/// A copy of the bytes of the last read, for the read-only differential harness (Task 18) to compare
/// against the native runtime byte for byte. Not part of <see cref="IAbCipTagRuntime"/>.
/// </summary>
internal ReadOnlyMemory<byte> RawBuffer => LibplctagStatusTranslation.WrapSync(() => _tag.GetBuffer());
/// <inheritdoc />
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
/// <inheritdoc />
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) =>
LibplctagStatusTranslation.WrapSync(() => DecodeValueAtCore(type, offset, bitIndex));
private object? DecodeValueAtCore(AbCipDataType type, int offset, int? bitIndex) => type switch
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
@@ -75,7 +137,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
};
/// <inheritdoc />
public object? DecodeArray(AbCipDataType type, int count)
public object? DecodeArray(AbCipDataType type, int count) =>
LibplctagStatusTranslation.WrapSync(() => DecodeArrayCore(type, count));
private object? DecodeArrayCore(AbCipDataType type, int count)
{
if (count < 1) count = 1;
// libplctag reports the per-element byte size once the tag has been read; it correctly
@@ -156,7 +221,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
};
/// <inheritdoc />
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
private void EncodeValueCore(AbCipDataType type, int? bitIndex, object? value)
{
switch (type)
{
@@ -1,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -20,9 +21,14 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
{
_tag?.Dispose();
_tag = BuildTemplateTag(deviceParams, templateInstanceId);
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return _tag.GetBuffer();
var tag = _tag;
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
})).ConfigureAwait(false);
}
/// <summary>Builds the (uninitialized) libplctag tag for the <c>@udt/{id}</c> pseudo.</summary>
@@ -35,7 +41,7 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
internal static Tag BuildTemplateTag(AbCipTagCreateParams deviceParams, uint templateInstanceId) => new()
{
Gateway = deviceParams.LibplctagGateway,
Path = deviceParams.CipPath,
Path = LibplctagPath.Normalize(deviceParams.CipPath),
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = $"@udt/{templateInstanceId}",
@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Maps <see cref="AbCipTagCreateParams"/> onto the native transport's connection identity and
/// settings. Shared by the native tag runtime, enumerator and template reader so every handle on
/// one device leases the same pooled connection.
/// </summary>
internal static class NativeAbCipDevice
{
/// <summary>The EtherNet/IP default TCP port, used when no usable port was configured.</summary>
internal const int DefaultPort = 44818;
/// <summary>The <c>plc=</c> attribute of the Micro800 family profile.</summary>
private const string Micro800Attribute = "micro800";
/// <summary>The pool key: one CIP connection per <c>(host, port, cipPath, plcAttribute)</c>.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The device key.</returns>
public static DeviceKey Key(AbCipTagCreateParams p) =>
new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
/// <summary>The connection settings for the device <paramref name="p"/> addresses.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The connection options.</returns>
public static ConnectionOptions Options(AbCipTagCreateParams p) => new()
{
Routing = RoutingFor(p),
Timeout = p.Timeout,
ConnectionSize = p.ConnectionSize,
AllowPacking = p.AllowPacking,
};
/// <summary>
/// How requests reach the controller's message router.
/// </summary>
/// <remarks>
/// Micro800 is <b>always</b> <see cref="CipRouting.Connected"/> with an empty route, even though its
/// family profile says <c>SupportsConnectedMessaging: false</c>: libplctag opens a Large Forward
/// Open with connection path <c>20 02 24 01</c> and reads connected (Captures/micro800-read-dint.hex,
/// Captures/README.md "Routing observed"; ruling R12-1 — the capture beats the profile flag).
/// Every other family follows the profile: connected-capable ⇒ Forward Open; otherwise
/// Unconnected Send when a route is configured, bare SendRRData when it is not.
/// </remarks>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The routing mode.</returns>
internal static CipRouting RoutingFor(AbCipTagCreateParams p)
{
if (string.Equals(p.LibplctagPlcAttribute, Micro800Attribute, StringComparison.OrdinalIgnoreCase))
return CipRouting.Connected;
if (p.UseConnectedMessaging) return CipRouting.Connected;
return string.IsNullOrWhiteSpace(p.CipPath) ? CipRouting.Direct : CipRouting.UnconnectedSend;
}
}
@@ -0,0 +1,173 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Native <see cref="IAbCipTagEnumerator"/>: one <see cref="SymbolListingEnumerator"/> whose listing
/// reader pages the Symbol Object Get Instance Attribute List service. The whole enumeration (the
/// controller listing plus every program listing) runs on one leased device connection, sent through
/// the lease's shared scheduler, and the lease is released when the enumeration ends.
/// </summary>
internal sealed class NativeAbCipTagEnumerator : IAbCipTagEnumerator
{
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> _acquire;
private readonly ILogger _logger;
private readonly TimeSpan _pageDelay;
private readonly TimeProvider _time;
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
/// <param name="pageDelay">Pause between listing pages (see <see cref="NativeListingReader.DefaultPageDelay"/>).</param>
public NativeAbCipTagEnumerator(DeviceSessionPool pool, TimeSpan pageDelay = default)
: this(p =>
{
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
return (lease.Scheduler, lease.DisposeAsync);
}, pageDelay: pageDelay)
{
}
/// <summary>Test seam: supplies the scheduler and its release directly.</summary>
/// <param name="acquire">Acquires the scheduler for a device, with the callback that releases it.</param>
/// <param name="logger">Receives release failures; null discards them.</param>
/// <param name="pageDelay">Pause between listing pages; zero is back-to-back.</param>
/// <param name="time">Clock for the pause; null is the system clock.</param>
internal NativeAbCipTagEnumerator(
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> acquire,
ILogger? logger = null, TimeSpan pageDelay = default, TimeProvider? time = null)
{
_acquire = acquire;
_logger = logger ?? NullLogger.Instance;
_pageDelay = pageDelay;
_time = time ?? TimeProvider.System;
}
/// <inheritdoc />
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var (scheduler, release) = _acquire(deviceParams);
try
{
using var inner = new SymbolListingEnumerator(
(_, name, ct) => NativeListingReader.ReadAsync(scheduler, name, ct, _pageDelay, _time));
await foreach (var tag in inner.EnumerateAsync(deviceParams, cancellationToken).ConfigureAwait(false))
yield return tag;
}
finally
{
NativeLease.ReleaseBounded(release, _logger);
}
}
/// <summary>Nothing to release — each enumeration owns and releases its own lease.</summary>
public void Dispose() { }
}
/// <summary>Pages a symbol listing off the controller into the concatenated Symbol Object entry bytes.</summary>
internal static class NativeListingReader
{
private const string TagsSuffix = ".@tags";
private const string ProgramPrefix = "Program:";
/// <summary>Page cap for one listing; a real controller needs a small fraction of this.</summary>
internal const int MaxPages = 10_000;
/// <summary>Byte cap for one listing (64 MiB).</summary>
internal const long MaxListingBytes = 64L * 1024 * 1024;
/// <summary>
/// Default pause between listing pages. A big controller's listing is hundreds of pages and, unpaced,
/// a back-to-back burst (one request in flight, roughly a Studio 5000 online browse). 5 ms is small
/// against a page's own round-trip (tens of ms on a real network), so a 50k-symbol browse gains a few
/// seconds, yet it yields the controller's message-processing slice between pages. Zero disables it.
/// Operator knob: <c>browsePageDelayMs</c> on the driver config.
/// </summary>
internal static readonly TimeSpan DefaultPageDelay = TimeSpan.FromMilliseconds(5);
/// <summary>Reads <c>@tags</c> or <c>Program:X.@tags</c> in full.</summary>
/// <param name="scheduler">The device's shared scheduler.</param>
/// <param name="listingName">The listing pseudo-tag name.</param>
/// <param name="cancellationToken">Cancels the read.</param>
/// <param name="pageDelay">Pause before each page after the first; zero is back-to-back.</param>
/// <param name="time">Clock for the pause; null is the system clock.</param>
/// <returns>The raw entries, byte for byte what <see cref="CipSymbolObjectDecoder"/> expects.</returns>
public static async Task<byte[]> ReadAsync(
RequestScheduler scheduler, string listingName, CancellationToken cancellationToken,
TimeSpan pageDelay = default, TimeProvider? time = null)
{
string? program = null;
if (listingName != "@tags")
{
if (!listingName.StartsWith(ProgramPrefix, StringComparison.Ordinal)
|| !listingName.EndsWith(TagsSuffix, StringComparison.Ordinal))
throw new ArgumentException($"Unsupported symbol listing '{listingName}'.", nameof(listingName));
program = listingName[ProgramPrefix.Length..^TagsSuffix.Length];
}
using var all = new MemoryStream();
uint start = 0;
for (var page = 0; page < MaxPages; page++)
{
if (page > 0 && pageDelay > TimeSpan.Zero)
await Task.Delay(pageDelay, time ?? TimeProvider.System, cancellationToken).ConfigureAwait(false);
var request = SymbolObjectServices.GetInstanceAttributeList(program, start);
// Exclusive: the reply can fill the connection, and the packer budgets request bytes only.
var reply = await scheduler.SendAsync(request, cancellationToken, exclusive: true).ConfigureAwait(false);
var (entries, pageMax, more) = SymbolObjectServices.ParseListingReply(reply);
all.Write(entries);
if (all.Length > MaxListingBytes)
throw new AbTransportException(AbStatus.BadReply,
$"Symbol listing '{listingName}' exceeded {MaxListingBytes} bytes.");
if (!more) return all.ToArray();
// Continue past the HIGHEST instance seen so far, so the order a controller lists a page in does
// not matter. A page whose highest instance is still below its start made no progress, and a
// highest id that would wrap cannot be continued: both would loop forever against the controller.
if (pageMax < start || pageMax == uint.MaxValue)
throw new AbTransportException(AbStatus.BadReply,
$"Symbol listing '{listingName}' made no progress (start {start}, highest instance {pageMax}).");
start = pageMax + 1;
}
throw new AbTransportException(AbStatus.BadReply,
$"Symbol listing '{listingName}' exceeded {MaxPages} pages.");
}
}
/// <summary>Factory for <see cref="NativeAbCipTagEnumerator"/>.</summary>
internal sealed class NativeAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory
{
private readonly TimeSpan _pageDelay;
/// <summary>Factory with the compiled default pacing.</summary>
public NativeAbCipTagEnumeratorFactory() : this(NativeListingReader.DefaultPageDelay) { }
/// <summary>Factory pacing listing pages by <paramref name="pageDelay"/>.</summary>
/// <param name="pageDelay">Pause between pages; zero is back-to-back.</param>
public NativeAbCipTagEnumeratorFactory(TimeSpan pageDelay) => _pageDelay = pageDelay;
/// <inheritdoc />
public IAbCipTagEnumerator Create() => new NativeAbCipTagEnumerator(DeviceSessionPool.Shared, _pageDelay);
}
/// <summary>Bounded lease release shared by the native enumerator and template reader.</summary>
internal static class NativeLease
{
/// <summary>Upper bound on waiting for the lease release (and its Forward Close).</summary>
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
/// <summary>Releases, waiting at most 2 s; never throws, whatever the release delegate does.</summary>
public static void ReleaseBounded(Func<ValueTask> release, ILogger logger)
{
try { release().AsTask().Wait(DisposeWait); }
catch (Exception ex)
{
logger.LogWarning(ex, "Releasing the native AbCip device lease failed.");
}
}
}
@@ -0,0 +1,18 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// <see cref="IAbCipTagFactory"/> for the native transport: every runtime leases its device's
/// connection from <see cref="DeviceSessionPool.Shared"/>, so all tags on one device share one CIP
/// connection and one request scheduler. Stateless; safe to share across devices.
/// </summary>
internal sealed class NativeAbCipTagFactory(DeviceSessionPool? pool = null) : IAbCipTagFactory
{
// Tests pass a private pool (and a fake target); production uses the process-wide one.
private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared;
/// <inheritdoc />
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
new NativeAbCipTagRuntime(createParams, _pool);
}
@@ -0,0 +1,570 @@
using System.Buffers.Binary;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Native <see cref="IAbCipTagRuntime"/> over the managed EtherNet/IP + CIP stack. Leases the
/// device's shared connection from <see cref="DeviceSessionPool.Shared"/> and reads/writes through a
/// per-runtime <see cref="LogixTagClient"/> over the lease's <b>shared</b> scheduler, so concurrent
/// tags on one device coalesce into Multiple Service Packets (review B1). Decoding mirrors
/// <see cref="LibplctagTagRuntime"/> byte for byte; unlike libplctag it also checks the CIP type
/// code the controller returned against the authored type.
/// </summary>
/// <remarks>
/// Not thread-safe: the driver serialises Read → GetStatus → Decode (and Encode → Write → GetStatus)
/// per runtime under its per-runtime lock (05/STAB-4), exactly as it does for libplctag.
/// </remarks>
internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
{
/// <summary>Bytes of a Logix STRING's length word.</summary>
private const int StringLengthBytes = 4;
/// <summary>
/// Read size of the built-in Logix STRING: <c>LEN</c> (4) + <c>DATA</c> SINT[82] + 2 bytes of
/// alignment padding to the structure's 4-byte boundary.
/// </summary>
private const int BuiltInStringBytes = 88;
/// <summary>Characters the built-in Logix STRING's <c>DATA</c> member holds.</summary>
private const int BuiltInStringCapacity = 82;
private readonly AbCipTagCreateParams _p;
private readonly DeviceSessionPool? _pool;
private readonly ushort _elements;
// Encoded in InitializeAsync, never in the constructor: a malformed name must surface from
// Initialize (inside the driver's / probe's try), not from IAbCipTagFactory.Create — as libplctag.
private int? _implicitBit;
private byte[]? _path;
private ConnectionLease? _lease;
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
private LogixTagClient? _client;
private byte[] _data = [];
private ushort? _typeCode;
private ushort? _structHandle;
private AbStatus _status = AbStatus.Pending;
private byte[]? _writeBuffer;
private string? _pendingString;
private bool _disposed;
/// <summary>Production constructor; <see cref="InitializeAsync"/> leases the connection from <paramref name="pool"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <param name="pool">The connection pool (the driver passes <see cref="DeviceSessionPool.Shared"/>).</param>
internal NativeAbCipTagRuntime(AbCipTagCreateParams p, DeviceSessionPool pool)
: this(p)
{
_pool = pool ?? throw new ArgumentNullException(nameof(pool));
}
private NativeAbCipTagRuntime(AbCipTagCreateParams p)
{
_p = p ?? throw new ArgumentNullException(nameof(p));
_elements = (ushort)Math.Clamp(p.ElementCount, 1, ushort.MaxValue);
}
/// <summary>
/// Test seam: a runtime over <paramref name="channel"/> with a private scheduler and no pool
/// lease. Production code always goes through <see cref="NativeAbCipTagFactory"/>.
/// </summary>
/// <param name="channel">The channel to send on.</param>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The runtime.</returns>
internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)
{
ArgumentNullException.ThrowIfNull(channel);
var rt = new NativeAbCipTagRuntime(p);
rt._privateScheduler = new RequestScheduler(channel, p.AllowPacking);
rt._client = new LogixTagClient(rt._privateScheduler, channel);
return rt;
}
/// <summary>The CIP type code of the last successful read, if any.</summary>
internal ushort? TypeCode => _typeCode;
/// <summary>The structure handle of the last successful read, if the controller returned one.</summary>
internal ushort? StructHandle => _structHandle;
/// <summary>The trailing bit selector split off the tag name (<c>Tag.5</c>), if any.</summary>
internal int? ImplicitBit => _implicitBit;
/// <inheritdoc />
/// <remarks>
/// <para>Performs one read to prove the tag exists: libplctag's <c>InitializeAsync</c> errors for an
/// unknown tag, and the driver's 05/STAB-8 backoff classification depends on create throwing
/// <see cref="AbStatus.NotFound"/>.</para>
/// <para>A name the encoder refuses throws <see cref="AbCipTagConfigurationException"/> before any
/// lease (Gitea #570): it is this tag's configuration, not the device, and must not black out the
/// device's other tags. A read the controller answers with a deterministic refusal
/// (<see cref="AbStatus.TypeMismatch"/> / <see cref="AbStatus.Unsupported"/>) proves the tag exists,
/// so it completes the initialization with that status rather than throwing: a throw here makes the
/// driver dispose the runtime and re-create it — and, for a sole lease holder, re-Forward-Open — on
/// every poll (the mistype churn of #570). The driver's own read reports the refusal.</para>
/// </remarks>
public async Task InitializeAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_path is null)
{
// Before any lease: a malformed name costs no connection.
(string Name, int? Bit) split;
byte[] path;
try
{
split = CipPath.SplitBitSelector(_p.TagName);
path = CipPath.EncodeSymbolic(split.Name);
}
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
{
throw new AbCipTagConfigurationException(
$"AbCip tag name '{_p.TagName}' cannot be encoded as a CIP symbolic path: {ex.Message}", ex);
}
_implicitBit = split.Bit;
_path = path;
}
if (_client is null)
{
var lease = _pool!.Acquire(NativeAbCipDevice.Key(_p), NativeAbCipDevice.Options(_p));
_lease = lease;
// The lease's shared scheduler — never a private one (B1: MSP coalescing across tags).
_client = new LogixTagClient(lease.Scheduler, lease.Channel);
}
try
{
await ReadAsync(cancellationToken).ConfigureAwait(false);
}
catch (AbTransportException ex) when (ex.Status is AbStatus.TypeMismatch or AbStatus.Unsupported)
{
// The controller answered — the tag exists. _status already holds the refusal.
}
}
/// <inheritdoc />
public async Task ReadAsync(CancellationToken cancellationToken)
{
var client = Client();
var path = Path();
try
{
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
_data = data;
_typeCode = type;
_structHandle = handle;
_status = IsTypeMismatch(type) ? AbStatus.TypeMismatch : AbStatus.Ok;
}
catch (AbTransportException ex)
{
// libplctag reports a failed operation by exception AND leaves the status readable.
_status = ex.Status;
throw;
}
}
/// <summary>
/// Upper bound on one element's wire bytes for the scheduler's reply budget (#582); 0 when the
/// authored type is unknown or a structure, which sends the read alone rather than guess.
/// BOOL counts 4 (a packed BOOL array reads back as DWORDs).
/// </summary>
internal static int ExpectedElementBytes(AbCipDataType? type) => type switch
{
AbCipDataType.Bool or AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4,
AbCipDataType.SInt or AbCipDataType.USInt => 1,
AbCipDataType.Int or AbCipDataType.UInt => 2,
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
AbCipDataType.String => LogixTypeCode.StringElementBytes,
_ => 0,
};
/// <inheritdoc />
public async Task WriteAsync(CancellationToken cancellationToken)
{
var client = Client();
var path = Path();
// A bit tag ("Flags.3") names one bit of its host word. Writing any encoded value to it would
// write the WHOLE host and wipe its other bits on the controller; BOOL bit writes are an RMW the
// driver performs on a parent-DINT runtime (WriteBitInDIntAsync), so anything reaching here is refused.
if (_implicitBit is int implicitBit)
throw new NotSupportedException(
$"Write to bit tag '{_p.TagName}' (bit {implicitBit}) is not supported here — bit writes go through AbCipDriver.WriteBitInDIntAsync.");
try
{
if (_pendingString is { } text)
{
// A STRING writes as the type the controller reported — a Logix STRING is a structure
// (0x02A0 + handle); ab_server reports 0x00D0 with no handle. Learn it by reading first
// when it is not yet known (we run under the driver's per-runtime lock).
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
await ReadAsync(cancellationToken).ConfigureAwait(false);
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
throw new AbTransportException(AbStatus.Unsupported,
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
var payload = EncodeStringPayload(text);
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
.ConfigureAwait(false);
}
else if (_writeBuffer is { } buffer)
{
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
?? throw new AbTransportException(AbStatus.Unsupported,
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
// would otherwise send the int bit pattern as a REAL and report Good.
if (_typeCode is { } returned && IsTypeMismatch(returned))
throw new AbTransportException(AbStatus.TypeMismatch,
$"Write to '{_p.TagName}': the controller's type 0x{returned:X4} does not match the authored {_p.AuthoredType}.");
// Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today.
var width = LogixTypeCode.ByteWidth(type);
if (width > 0 && width != buffer.Length)
{
// An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the
// 31 sibling bools (libplctag does exactly that), so refuse it as a config error.
if (type == LogixTypeCode.BoolArray)
throw new NotSupportedException(
$"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL.");
throw new AbTransportException(AbStatus.TypeMismatch,
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
}
await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false);
}
else
{
throw new InvalidOperationException("WriteAsync called before EncodeValue.");
}
_status = AbStatus.Ok;
}
catch (AbTransportException ex)
{
_status = ex.Status;
throw;
}
}
/// <inheritdoc />
public int GetStatus() => (int)_status;
/// <inheritdoc />
public int GetSize() => _data.Length;
/// <summary>
/// The bytes of the last read, for the read-only differential harness (Task 18) to compare against
/// libplctag byte for byte. Not part of <see cref="IAbCipTagRuntime"/>.
/// </summary>
internal ReadOnlyMemory<byte> RawBuffer => _data;
/// <inheritdoc />
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
/// <inheritdoc />
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) =>
_status == AbStatus.TypeMismatch ? null : DecodeAt(type, offset, bitIndex);
private object? DecodeAt(AbCipDataType type, int offset, int? bitIndex)
{
// libplctag on a bit tag ("Tag.5"): every integer getter returns that bit, and GetBit ignores
// the index it is given in favour of the tag's own bit.
if (_implicitBit is int implicitBit && IsIntegerShaped(type))
return BoxBit(type, GetBit(implicitBit));
return type switch
{
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0,
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
AbCipDataType.USInt => (int)Span(offset, 1)[0],
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
AbCipDataType.UInt => (int)BinaryPrimitives.ReadUInt16LittleEndian(Span(offset, 2)),
AbCipDataType.DInt or AbCipDataType.Dt => BinaryPrimitives.ReadInt32LittleEndian(Span(offset, 4)),
AbCipDataType.UDInt => BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, 4)),
AbCipDataType.LInt => BinaryPrimitives.ReadInt64LittleEndian(Span(offset, 8)),
AbCipDataType.ULInt => BinaryPrimitives.ReadUInt64LittleEndian(Span(offset, 8)),
AbCipDataType.Real => BinaryPrimitives.ReadSingleLittleEndian(Span(offset, 4)),
AbCipDataType.LReal => BinaryPrimitives.ReadDoubleLittleEndian(Span(offset, 8)),
AbCipDataType.String => DecodeString(offset),
_ => null,
};
}
/// <inheritdoc />
public object? DecodeArray(AbCipDataType type, int count)
{
if (_status == AbStatus.TypeMismatch) return null;
if (count < 1) count = 1;
// Same stride rule as libplctag's runtime (which never has ElementSize after a read).
var stride = LibplctagTagRuntime.ResolveArrayStride(null, _data.Length, count, type);
return type switch
{
// A Logix BOOL array is bit-packed: element i is bit i of the buffer.
AbCipDataType.Bool => BuildBoolArray(count),
AbCipDataType.SInt or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt
or AbCipDataType.DInt or AbCipDataType.Dt => BuildArray<int>(count, stride, type),
AbCipDataType.UDInt => BuildArray<uint>(count, stride, type),
AbCipDataType.LInt => BuildArray<long>(count, stride, type),
AbCipDataType.ULInt => BuildArray<ulong>(count, stride, type),
AbCipDataType.Real => BuildArray<float>(count, stride, type),
AbCipDataType.LReal => BuildArray<double>(count, stride, type),
AbCipDataType.String => BuildArray<string>(count, stride, type),
_ => null,
};
}
/// <inheritdoc />
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
_writeBuffer = null;
_pendingString = null;
switch (type)
{
case AbCipDataType.Bool:
if (bitIndex is int)
{
// Same contract as LibplctagTagRuntime: BOOL-within-DINT writes are an RMW the driver
// performs on a parent-DINT runtime (AbCipDriver.WriteBitInDIntAsync).
throw new NotSupportedException(
"BOOL-with-bitIndex writes must go through AbCipDriver.WriteBitInDIntAsync, not NativeAbCipTagRuntime.");
}
_writeBuffer = [Convert.ToBoolean(value) ? (byte)1 : (byte)0];
break;
case AbCipDataType.SInt:
_writeBuffer = [(byte)Convert.ToSByte(value)];
break;
case AbCipDataType.USInt:
_writeBuffer = [Convert.ToByte(value)];
break;
case AbCipDataType.Int:
_writeBuffer = new byte[2];
BinaryPrimitives.WriteInt16LittleEndian(_writeBuffer, Convert.ToInt16(value));
break;
case AbCipDataType.UInt:
_writeBuffer = new byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(_writeBuffer, Convert.ToUInt16(value));
break;
case AbCipDataType.DInt:
case AbCipDataType.Dt:
_writeBuffer = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(_writeBuffer, Convert.ToInt32(value));
break;
case AbCipDataType.UDInt:
_writeBuffer = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(_writeBuffer, Convert.ToUInt32(value));
break;
case AbCipDataType.LInt:
_writeBuffer = new byte[8];
BinaryPrimitives.WriteInt64LittleEndian(_writeBuffer, Convert.ToInt64(value));
break;
case AbCipDataType.ULInt:
_writeBuffer = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(_writeBuffer, Convert.ToUInt64(value));
break;
case AbCipDataType.Real:
_writeBuffer = new byte[4];
BinaryPrimitives.WriteSingleLittleEndian(_writeBuffer, Convert.ToSingle(value));
break;
case AbCipDataType.LReal:
_writeBuffer = new byte[8];
BinaryPrimitives.WriteDoubleLittleEndian(_writeBuffer, Convert.ToDouble(value));
break;
case AbCipDataType.String:
// The payload needs the capacity learned from a read, which WriteAsync may have to do.
_pendingString = Convert.ToString(value) ?? string.Empty;
break;
case AbCipDataType.Structure:
throw new NotSupportedException(
"Whole-UDT writes are not supported — address individual member paths instead.");
default:
throw new NotSupportedException($"AbCipDataType {type} not writable.");
}
}
/// <summary>
/// Releases the connection lease without blocking (Gitea #579) — the driver calls this under the
/// tag's lock, which used to be held for up to 2 s while the last release's Forward Close ran. An
/// immediate re-init still cannot open a second session while that close is in flight (review
/// finding 22): the pool registers the close synchronously and holds any successor connection
/// behind it — see <see cref="ConnectionLease.ReleaseInBackground"/>. Never disposes the shared
/// scheduler; the pool does that on the last release. Same release shape as the native AbLegacy runtime.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
TeardownTask = _lease?.ReleaseInBackground() ?? DisposePrivateScheduler(_privateScheduler);
_lease = null;
_privateScheduler = null;
}
/// <summary>The background teardown <see cref="Dispose"/> started, for tests; null before Dispose.</summary>
internal Task? TeardownTask { get; private set; }
private static async Task DisposePrivateScheduler(RequestScheduler? scheduler)
{
if (scheduler is null) return;
try { await scheduler.DisposeAsync().ConfigureAwait(false); }
catch (Exception) { /* ForTest only; Dispose must not throw */ }
}
private byte[] Path() =>
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
private LogixTagClient Client()
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _client ?? throw new InvalidOperationException("InitializeAsync has not been called.");
}
/// <summary>
/// True when the controller returned an atomic type other than the authored one. Exempt:
/// no authored type, String/Structure, a non-atomic return, a BOOL bit selector against an integer
/// host (<c>Flags.3</c> on a DINT), and an authored BOOL answered as 0xD3 (a packed BOOL-array
/// DWORD, which is how Logix returns a BOOL-array element), and same-width integers that differ only
/// in signedness or are the Micro800 bit-string type of that width (SINT/USINT/BYTE, INT/UINT/WORD,
/// DINT/UDINT/DWORD, LINT/ULINT/LWORD): libplctag reads those Good today and a transport cutover
/// must not turn them Bad. Different widths and integer↔real still mismatch.
/// </summary>
private bool IsTypeMismatch(ushort returned)
{
if (AtomicCode(_p.AuthoredType) is not { } expected) return false;
if (returned == expected) return false;
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's width table but are
// integers of a known width: they are checked like any integer, so a REAL / LREAL / narrower
// integer authored over an LWORD is a mismatch rather than garbage decoded as Good (Gitea #570).
// Any other code without a known width (DATE, DATE_AND_TIME, structures) stays exempt.
if (LogixTypeCode.ByteWidth(returned) < 0 && IntegerWidth(returned) is null) return false;
if (_p.AuthoredType == AbCipDataType.Bool)
{
if (returned == LogixTypeCode.BoolArray) return false;
if (_implicitBit is not null && IsIntegerCode(returned)) return false;
}
if (IntegerWidth(expected) is int width && IntegerWidth(returned) == width) return false;
return true;
}
/// <summary>Micro800 bit-string type codes (DWORD is <see cref="LogixTypeCode.BoolArray"/>, 0xD3).</summary>
private const ushort ByteCode = 0xD1, WordCode = 0xD2, LWordCode = 0xD4;
/// <summary>Width of an integer or bit-string code; null for anything else (BOOL, REAL, ...).</summary>
private static int? IntegerWidth(ushort code) => code switch
{
LogixTypeCode.SInt or LogixTypeCode.USInt or ByteCode => 1,
LogixTypeCode.Int or LogixTypeCode.UInt or WordCode => 2,
LogixTypeCode.DInt or LogixTypeCode.UDInt or LogixTypeCode.BoolArray => 4,
LogixTypeCode.LInt or LogixTypeCode.ULInt or LWordCode => 8,
_ => null,
};
/// <summary>The Logix type code an authored type expects, or null when it is exempt from checking.</summary>
private static ushort? AtomicCode(AbCipDataType? type) => type switch
{
AbCipDataType.Bool => LogixTypeCode.Bool,
AbCipDataType.SInt => LogixTypeCode.SInt,
AbCipDataType.USInt => LogixTypeCode.USInt,
AbCipDataType.Int => LogixTypeCode.Int,
AbCipDataType.UInt => LogixTypeCode.UInt,
AbCipDataType.DInt or AbCipDataType.Dt => LogixTypeCode.DInt,
AbCipDataType.UDInt => LogixTypeCode.UDInt,
AbCipDataType.LInt => LogixTypeCode.LInt,
AbCipDataType.ULInt => LogixTypeCode.ULInt,
AbCipDataType.Real => LogixTypeCode.Real,
AbCipDataType.LReal => LogixTypeCode.LReal,
_ => null, // String, Structure
};
private static bool IsIntegerCode(ushort code) => code is LogixTypeCode.SInt or LogixTypeCode.Int
or LogixTypeCode.DInt or LogixTypeCode.LInt or LogixTypeCode.USInt or LogixTypeCode.UInt
or LogixTypeCode.UDInt or LogixTypeCode.ULInt;
private static bool IsIntegerShaped(AbCipDataType type) => type is AbCipDataType.Bool or AbCipDataType.SInt
or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt or AbCipDataType.DInt
or AbCipDataType.UDInt or AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.Dt;
private static object BoxBit(AbCipDataType type, bool bit) => type switch
{
AbCipDataType.Bool => bit,
AbCipDataType.UDInt => bit ? 1u : 0u,
AbCipDataType.LInt => bit ? 1L : 0L,
AbCipDataType.ULInt => bit ? 1UL : 0UL,
_ => bit ? 1 : 0,
};
/// <summary>Bit <paramref name="bit"/> of the little-endian buffer.</summary>
private bool GetBit(int bit)
{
if (bit < 0 || bit / 8 >= _data.Length)
throw new AbTransportException(AbStatus.OutOfBounds,
$"Bit {bit} is outside the {_data.Length}-byte buffer of '{_p.TagName}'.");
return (_data[bit / 8] & (1 << (bit % 8))) != 0;
}
/// <summary>The <paramref name="length"/> bytes at <paramref name="offset"/>; OutOfBounds like libplctag.</summary>
private ReadOnlySpan<byte> Span(int offset, int length)
{
if (offset < 0 || length > _data.Length - offset)
throw new AbTransportException(AbStatus.OutOfBounds,
$"Offset {offset} + {length} byte(s) is outside the {_data.Length}-byte buffer of '{_p.TagName}'.");
return _data.AsSpan(offset, length);
}
/// <summary>
/// Characters a STRING of <paramref name="structureBytes"/> holds. The built-in 88-byte STRING holds
/// 82 — its last 2 bytes are alignment padding, not DATA (Gitea #570). Any other size is a user
/// string type whose DATA length the read alone cannot separate from its padding (up to 3 bytes),
/// so it keeps the buffer-derived <c>size − 4</c>.
/// </summary>
private static int StringCapacity(int structureBytes) => structureBytes == BuiltInStringBytes
? BuiltInStringCapacity
: structureBytes - StringLengthBytes;
/// <summary>Logix STRING: <c>u32 LEN</c> then ASCII DATA, LEN clamped to what the buffer holds.</summary>
private string DecodeString(int offset)
{
var len = BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, StringLengthBytes));
var available = _data.Length - offset - StringLengthBytes;
// A scalar built-in STRING never yields its alignment padding as characters.
if (offset == 0 && _data.Length == BuiltInStringBytes) available = BuiltInStringCapacity;
var count = (int)Math.Min(len, (uint)available);
return Encoding.ASCII.GetString(_data, offset + StringLengthBytes, count);
}
/// <summary>
/// <c>u32 len</c> + DATA, zero-padded to the size learned from the last read, so the write carries the
/// whole structure as the controller sized it (padding included, as libplctag sends it). The text
/// must fit the DATA capacity, which excludes the padding (<see cref="StringCapacity"/>).
/// </summary>
private byte[] EncodeStringPayload(string text)
{
var capacity = StringCapacity(_data.Length);
var bytes = Encoding.ASCII.GetBytes(text);
if (capacity < 0 || bytes.Length > capacity)
throw new AbTransportException(AbStatus.TooLarge,
$"STRING write to '{_p.TagName}': {bytes.Length} character(s) exceeds the {Math.Max(capacity, 0)}-character capacity.");
var payload = new byte[_data.Length];
BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)bytes.Length);
bytes.CopyTo(payload, StringLengthBytes);
return payload;
}
private bool[] BuildBoolArray(int count)
{
var arr = new bool[count];
for (var i = 0; i < count; i++) arr[i] = GetBit(i);
return arr;
}
private T[] BuildArray<T>(int count, int stride, AbCipDataType type)
{
var arr = new T[count];
for (var i = 0; i < count; i++)
{
var decoded = DecodeAt(type, i * stride, null);
arr[i] = decoded is null ? default! : (T)decoded;
}
return arr;
}
}
@@ -0,0 +1,147 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Native <see cref="IAbCipTemplateReader"/>: Get Attribute List then chunked Read Template on the
/// device's shared scheduler, returning the 14-byte-header buffer <see cref="CipTemplateObjectDecoder"/>
/// parses. By default the lease is held for one read and released when it completes; a reader built for a
/// batch (<see cref="IAbCipTemplateReaderFactory.CreateBatch"/>) instead keeps one lease per device from its
/// first read until it is disposed.
/// </summary>
internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
{
/// <summary>Request bytes of a Read Template that are not data room (service, path, offset, count).</summary>
private const int RequestOverhead = 16;
/// <summary>Bytes of the template definition header not counted in the Read Template length.</summary>
private const int DefinitionHeaderBytes = 23;
/// <summary>Upper bound on a template's Read Template length; Logix templates are far smaller.</summary>
internal const long MaxTemplateBytes = 64 * 1024;
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> _acquire;
private readonly ILogger _logger;
private readonly bool _holdLeases;
private readonly object _heldLock = new();
private readonly Dictionary<DeviceKey, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> _held = new();
private bool _disposed;
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
/// <param name="holdLeases">True for a batch reader: keep each device's lease until disposed.</param>
public NativeAbCipTemplateReader(DeviceSessionPool pool, bool holdLeases = false)
: this(p =>
{
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
return (lease.Scheduler, lease.Channel, lease.DisposeAsync);
}, holdLeases: holdLeases)
{
}
/// <summary>Test seam: supplies the scheduler, channel and release directly.</summary>
/// <param name="acquire">Acquires the connection parts for a device.</param>
/// <param name="logger">Receives release failures; null discards them.</param>
/// <param name="holdLeases">True for a batch reader: keep each device's lease until disposed.</param>
internal NativeAbCipTemplateReader(
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> acquire,
ILogger? logger = null, bool holdLeases = false)
{
_acquire = acquire;
_logger = logger ?? NullLogger.Instance;
_holdLeases = holdLeases;
}
/// <summary>The connection parts for one read, and whether this read owns (must release) them.</summary>
private (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release, bool Owned) Lease(AbCipTagCreateParams p)
{
if (!_holdLeases)
{
var one = _acquire(p);
return (one.Scheduler, one.Channel, one.Release, true);
}
var key = NativeAbCipDevice.Key(p);
lock (_heldLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_held.TryGetValue(key, out var held))
_held[key] = held = _acquire(p);
return (held.Scheduler, held.Channel, held.Release, false);
}
}
/// <inheritdoc />
public async Task<byte[]> ReadAsync(
AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
{
if (templateInstanceId > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(templateInstanceId), "Template ids are 16-bit.");
var id = (ushort)templateInstanceId;
var (scheduler, channel, release, owned) = Lease(deviceParams);
try
{
var attrReply = await scheduler.SendAsync(TemplateObjectServices.GetAttributes(id), cancellationToken)
.ConfigureAwait(false);
var attrs = TemplateObjectServices.ParseAttributes(attrReply);
// The CIP-documented Read Template length: definition words * 4 - 23.
var total = checked((long)attrs.DefinitionWords * 4 - DefinitionHeaderBytes);
if (total < 0 || total > MaxTemplateBytes)
throw new AbTransportException(AbStatus.BadReply,
$"Template {id} reports an impossible definition size of {attrs.DefinitionWords} words.");
using var raw = new MemoryStream();
long offset = 0;
while (offset < total)
{
var chunk = (ushort)Math.Min(total - offset, channel.MaxRequestBytes - RequestOverhead);
var reply = await scheduler
.SendAsync(TemplateObjectServices.ReadTemplate(id, (uint)offset, chunk), cancellationToken, exclusive: true)
.ConfigureAwait(false);
if (reply.GeneralStatus is not (0x00 or 0x06))
throw new AbTransportException(reply.Status,
$"Read Template {id} failed: general 0x{reply.GeneralStatus:X2}.");
var more = TemplateObjectServices.ReadHasMore(reply);
if (more && reply.Data.IsEmpty)
throw new AbTransportException(AbStatus.BadReply, $"Read Template {id} reported more data but returned none.");
raw.Write(reply.Data.Span);
offset += reply.Data.Length;
if (!more) break;
}
return TemplateObjectServices.BuildDecoderBuffer(id, attrs, raw.ToArray());
}
finally
{
if (owned) NativeLease.ReleaseBounded(release, _logger);
}
}
/// <summary>Releases the leases a batch reader held; a per-read reader has none.</summary>
public void Dispose()
{
List<Func<ValueTask>> releases;
lock (_heldLock)
{
_disposed = true;
releases = [.. _held.Values.Select(h => h.Release)];
_held.Clear();
}
foreach (var release in releases) NativeLease.ReleaseBounded(release, _logger);
}
}
/// <summary>Factory for <see cref="NativeAbCipTemplateReader"/>.</summary>
internal sealed class NativeAbCipTemplateReaderFactory : IAbCipTemplateReaderFactory
{
/// <inheritdoc />
public IAbCipTemplateReader Create() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared);
/// <inheritdoc />
public IAbCipTemplateReader CreateBatch() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared, holdLeases: true);
}
@@ -41,7 +41,12 @@ public sealed record AbCipPlcFamilyProfile(
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
LibplctagPlcAttribute: "compactlogix",
DefaultConnectionSize: 504, // 5069-L3x narrower buffer; safe baseline that never over-shoots
// Large Forward Open, matching libplctag: `plc=compactlogix` opens Large (0x5B, 4002) and the
// native transport must not pack into a third of the window (T21 latency harness: 504 cost ~3x
// the round trips per poll). A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses the Large open
// is covered by CipConnection's fallback — a regular open at min(size, 504) inside the same
// throttled attempt. An explicit ConnectionSize (driver options or per device) still wins.
DefaultConnectionSize: 4002,
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
@@ -0,0 +1,74 @@
using System.Runtime.CompilerServices;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Transport-neutral <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table
/// by reading the <c>@tags</c> listing through an injected reader + decoding the CIP Symbol Object
/// bytes with <see cref="CipSymbolObjectDecoder"/>.
/// </summary>
/// <remarks>
/// <para>The reader returns the raw Symbol Object bytes for a listing name (libplctag's
/// <c>Tag.GetBuffer()</c> for <c>@tags</c>, or the native paged Instance Attribute List). The decoder walks the concatenated entries + emits
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
///
/// <para><see cref="EmptyAbCipTagEnumerator"/> remains available for tests that don't want
/// to touch the native library; the production factory default
/// (<see cref="LibplctagTagEnumeratorFactory"/>) wires this implementation in.</para>
/// </remarks>
internal sealed class SymbolListingEnumerator : IAbCipTagEnumerator
{
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
/// <summary>Enumerator over a transport-specific listing reader.</summary>
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
/// returns its raw Symbol Object bytes.</param>
public SymbolListingEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
=> _readListing = readListing;
/// <inheritdoc />
/// <remarks>
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
/// <c>Program:&lt;name&gt;.@tags</c>. So the controller listing is read first, then each program's
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
/// skipped — browse is best-effort and one unreadable program must not cost the others.
/// </remarks>
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
// The decoder already surfaces each struct tag's CIP template instance id on
// AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
// re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
var programs = new List<string>();
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
{
if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
yield return tag;
}
foreach (var program in programs)
{
byte[] programBuffer;
try
{
programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception)
{
continue;
}
foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
yield return tag;
}
}
/// <summary>Nothing to release — the reader owns any per-listing resources.</summary>
public void Dispose() { }
}
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
@@ -23,10 +24,14 @@
Decision #11 — EtherNet/IP + CIP + Logix symbolic against ControlLogix / CompactLogix /
Micro800 / SLC500 / PLC-5. -->
<PackageReference Include="libplctag"/>
<!-- #580: orphan-release helper shared with the sibling AB driver (source-linked; keeps a Libplctag*.cs name). -->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared\LibplctagOrphanRelease.cs" Link="LibplctagOrphanRelease.cs"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.AdminUI.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
</ItemGroup>
</Project>
@@ -25,6 +25,23 @@ public sealed class AbLegacyDriverOptions
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
/// <c>transport</c> key absent) means "use the default": <see cref="AbLegacyTransport.Libplctag"/> for
/// every family. <see cref="AbLegacyTransport.Native"/> is opt-in (final review I3: on a wire it is proven
/// only for N-file reads/writes and one F8 read). An explicit value always wins. The
/// effective transport is resolved in one place, <c>AbLegacyTransportResolver</c>.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
/// <c>ReinitializeAsync</c>: the tag factory is chosen once in the constructor and held across
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
/// </summary>
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public AbLegacyTransport? Transport { get; init; }
/// <summary>Gets or sets the probe (connectivity check) options.</summary>
public AbLegacyProbeOptions Probe { get; init; } = new();
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Which PCCC transport stack an AbLegacy driver instance (and its Test Connect probe / CLI run)
/// talks to the controller through. Serialises as a STRING in driver config JSON.
/// </summary>
public enum AbLegacyTransport
{
/// <summary>The libplctag-backed transport. The default.</summary>
Libplctag,
/// <summary>
/// The native managed EtherNet/IP + PCCC transport. Known gaps: PLC-5 supports N-file reads only,
/// writes to status-bit sub-elements are unsupported, and LogixPccc framing is unverified.
/// </summary>
Native,
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
@@ -68,7 +69,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
_transport = AbLegacyTransportResolver.Resolve(options);
_transportDescription = AbLegacyTransportResolver.Describe(options.Transport);
_tagFactory = tagFactory ?? (_transport == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
_logger = logger ?? NullLogger<AbLegacyDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbLegacyTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
@@ -85,6 +92,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
}
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
internal IAbLegacyTagFactory TagFactory => _tagFactory;
/// <summary>The effective transport resolved at construction (see <see cref="AbLegacyTransportResolver"/>).
/// Describes the OPTION only: an injected <c>tagFactory</c> (tests) is used regardless.</summary>
internal AbLegacyTransport EffectiveTransport => _transport;
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
// from the node log which stack serves this instance.
private readonly AbLegacyTransport _transport;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Libplctag (default)".
private readonly string _transportDescription;
// Set once the explicit-Native-on-a-limited-family Warning has been logged for this instance.
private bool _limitedFamilyWarned;
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -122,6 +146,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
@@ -130,6 +157,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
if (HasConfigBody(driverConfigJson))
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly
// even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance —
// not on every init, which includes every comms-loss reconnect retry (Task 22 review r1 M4).
if (!_limitedFamilyWarned
&& _transport == AbLegacyTransport.Native
&& AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited)
{
_limitedFamilyWarned = true;
_logger.LogWarning(
"AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " +
"support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " +
"\"transport\": \"Libplctag\" to use the fallback stack.",
_driverInstanceId, string.Join(", ", limited));
}
foreach (var device in _options.Devices)
{
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
@@ -315,14 +357,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
EvictRuntime(device, def.Name);
results[i] = new DataValueSnapshot(null,
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
AbLegacyStatusMapper.MapStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
$"transport status {status} reading {reference}");
if (!device.FirstNonZeroStatusLogged)
{
device.FirstNonZeroStatusLogged = true;
_logger.LogWarning(
"AbLegacy non-zero libplctag status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
"AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
_driverInstanceId, def.DeviceHostAddress, reference, status);
}
continue;
@@ -335,6 +377,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException) { throw; }
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
// The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read, #571).
// Not a comms fault: BadNotSupported, keep the handle, leave health alone.
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
@@ -416,7 +464,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbLegacyStatusMapper.MapLibplctagStatus(status));
results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status));
}
else
{
@@ -437,6 +485,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
}
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
// The runtime refused the write before the wire — a native status-bit sub-element write or a
// PLC-5 write (#571). Not a comms fault: BadNotSupported, keep the handle, leave health alone.
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
}
catch (Exception ex)
{
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
@@ -654,7 +708,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
if (readStatus != 0) return AbLegacyStatusMapper.MapLibplctagStatus(readStatus);
if (readStatus != 0) return AbLegacyStatusMapper.MapStatus(readStatus);
// Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a
// sign-extended int, so a word with bit 15 set decodes negative. Masking pins the
@@ -673,7 +727,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbLegacyStatusMapper.Good
: AbLegacyStatusMapper.MapLibplctagStatus(writeStatus);
: AbLegacyStatusMapper.MapStatus(writeStatus);
}
finally
{
@@ -703,16 +757,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentName,
Timeout: _options.Timeout));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.ParentRuntimes[parentName] = runtime;
await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct)
.ConfigureAwait(false);
return runtime;
}
finally
@@ -739,6 +785,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;
/// <summary>
/// True for a deterministic refusal of the operation itself (<see cref="AbStatus.Unsupported"/>) — the
/// native runtime raises it BEFORE anything reaches the wire (status-bit writes, PLC-5 non-N reads and
/// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the
/// device's reachability, so it must not evict the handle or degrade driver health.
/// </summary>
internal static bool IsLocalRefusal(Exception ex) =>
ex is AbTransportException { Status: AbStatus.Unsupported };
/// <summary>
/// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is
/// still KEPT: the refusal is deterministic and per-operation, so caching it avoids re-creating — and
/// re-leasing the pooled device connection — on every poll. Any other failure disposes it, as before.
/// </summary>
private static async Task InitializeOrKeepRefusedAsync(IAbLegacyTagRuntime runtime, Action keep, CancellationToken ct)
{
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (IsLocalRefusal(ex) && runtime is NativeAbLegacyTagRuntime)
{
keep();
throw;
}
catch
{
runtime.Dispose();
throw;
}
keep();
}
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
@@ -765,16 +844,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout,
ElementCount: EffectiveArrayLength(def)));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.Runtimes[def.Name] = runtime;
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
.ConfigureAwait(false);
return runtime;
}
finally
@@ -97,6 +97,11 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
? null
: ParseEnum<AbLegacyTransport>(dto.Transport, driverInstanceId, "Transport"),
};
return options;
@@ -125,7 +130,8 @@ public static class AbLegacyDriverFactoryExtensions
throw new InvalidOperationException(
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
// Enum.TryParse also accepts any integer string ("7"), defined or not (Task 22 review r1 M2).
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
? v
: throw new InvalidOperationException(
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} has unknown {field} '{raw}'. " +
@@ -146,6 +152,11 @@ public static class AbLegacyDriverFactoryExtensions
/// </summary>
public int? TimeoutMs { get; init; }
/// <summary>
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag (the default).
/// </summary>
public string? Transport { get; init; }
/// <summary>
/// Gets or sets the list of devices to connect to.
/// </summary>
@@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
@@ -18,23 +18,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// non-PCCC server) returns <c>Ok = false</c> with a "handshake failed" message instead of
/// a false-positive green tick.
/// <para>
/// Status-code discrimination: libplctag statuses that indicate the controller answered
/// Status-code discrimination: transport statuses that indicate the controller answered
/// the PCCC session but could not resolve the probe tag
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
/// (<see cref="AbStatus.NotFound"/>) are treated as "reachable — Ok = true" because
/// a non-existent tag name proves PCCC connectivity. Statuses that indicate a transport or
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
/// session failure (<see cref="AbStatus.Timeout"/>, <see cref="AbStatus.BadConnection"/>,
/// <see cref="AbStatus.BadReply"/>, <see cref="AbStatus.Partial"/>, <see cref="AbStatus.Abort"/>) are treated as
/// "handshake failed — Ok = false". All other error statuses default to
/// "handshake failed" (conservative).
/// </para>
/// </summary>
public sealed class AbLegacyDriverProbe : IDriverProbe
{
private readonly IAbLegacyTagFactory? _tagFactory;
/// <summary>Initializes a new instance of the <see cref="AbLegacyDriverProbe"/> class.</summary>
/// <param name="tagFactory">The tag factory used for the PCCC handshake; when null the config's
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
public AbLegacyDriverProbe(IAbLegacyTagFactory? tagFactory = null) => _tagFactory = tagFactory;
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
@@ -104,7 +106,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
TagName: tagName,
Timeout: timeout);
var rt = new LibplctagLegacyTagRuntime(p);
var factory = _tagFactory ?? (AbLegacyTransportResolver.Resolve(opts) == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
var rt = factory.Create(p);
try
{
await rt.InitializeAsync(cts.Token);
@@ -112,10 +117,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
// InitializeAsync completed without throwing — either the tag was found (Ok) or
// libplctag returned a non-exception status. Check via GetStatus().
var statusCode = (Status)rt.GetStatus();
var statusCode = (AbStatus)rt.GetStatus();
if (IsReachableStatus(statusCode))
{
var msg = statusCode == Status.Ok
var msg = statusCode == AbStatus.Ok
? "PCCC session OK"
: "PCCC session OK (controller reachable; probe tag not found)";
return new(true, msg, sw.Elapsed);
@@ -128,10 +133,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (LibPlcTagException ex)
catch (AbTransportException ex)
{
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
if (IsReachableException(ex))
// AbTransportException carries the Status; classify as reachable vs transport failure.
if (IsReachableStatus(ex.Status))
{
sw.Stop();
return new(true, "PCCC session OK (controller reachable; probe tag not found)", sw.Elapsed);
@@ -150,30 +155,13 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
}
/// <summary>
/// Returns <c>true</c> for libplctag statuses that indicate the PCCC controller answered
/// but the probe tag name was not found or did not match — sufficient to confirm PCCC
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
/// Returns <c>true</c> for statuses that indicate the PCCC controller answered but the
/// probe tag was not found — sufficient to confirm PCCC reachability. Also <c>true</c> for
/// <see cref="AbStatus.Ok"/> (tag was found).
/// </summary>
private static bool IsReachableStatus(Status s) => s is
Status.Ok or
Status.ErrorNotFound or
Status.ErrorNoMatch or
Status.ErrorBadDevice;
/// <summary>
/// Inspects a <see cref="LibPlcTagException"/> message to decide whether the error is a
/// "controller answered PCCC but tag not found" condition (reachable) versus a session /
/// transport failure (unreachable). Falls back to checking the exception message for
/// known tag-not-found phrases used by the libplctag native library.
/// </summary>
private static bool IsReachableException(LibPlcTagException ex)
{
// libplctag wraps the Status in the message as "STATUS_NOT_FOUND", "STATUS_NO_MATCH", etc.
var msg = ex.Message ?? string.Empty;
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
}
/// <param name="s">The transport status.</param>
/// <returns>Whether the status proves the controller answered.</returns>
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
{
@@ -1,11 +1,10 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Maps libplctag status codes + PCCC STS/EXT_STS bytes to OPC UA StatusCodes. Mirrors the
/// AbCip mapper — PCCC errors roughly align with CIP general-status in shape but with a
/// different byte vocabulary (PCCC STS nibble-low + EXT_STS on code 0x0F).
/// Maps transport status codes to OPC UA StatusCodes via the table shared with AbCip
/// (<see cref="AbOpcUaStatusMap"/>).
/// </summary>
public static class AbLegacyStatusMapper
{
@@ -22,58 +21,15 @@ public static class AbLegacyStatusMapper
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
/// is <c>(int)Tag.GetStatus()</c> — the underlying value of the libplctag.NET
/// <see cref="Status"/> enum. Delegates to the strongly-typed overload so the mapping
/// stays correct regardless of how the wrapper renumbers native PLCTAG_ERR_* constants
/// in future releases.
/// Map the integer form of an <see cref="AbStatus"/> (what <see cref="IAbLegacyTagRuntime.GetStatus"/>
/// returns) to an OPC UA StatusCode.
/// </summary>
/// <param name="status">The integer status code from libplctag.</param>
/// <param name="status">The integer transport status.</param>
/// <returns>The corresponding OPC UA status code.</returns>
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
/// <summary>
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
/// the canonical core; the <c>int</c> overload exists only for the
/// <see cref="IAbLegacyTagRuntime.GetStatus"/> seam which boxes the enum as an int.
/// </summary>
/// <param name="status">The libplctag Status enum value.</param>
/// <summary>Map a transport-neutral <see cref="AbStatus"/> to an OPC UA StatusCode.</summary>
/// <param name="status">The transport status.</param>
/// <returns>The corresponding OPC UA status code.</returns>
public static uint MapLibplctagStatus(Status status) => status switch
{
Status.Ok => Good,
Status.Pending => GoodMoreData,
Status.ErrorTimeout => BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
Status.ErrorNotAllowed => BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
_ => BadCommunicationError,
};
/// <summary>
/// Map a PCCC STS (status) byte. Common codes per AB PCCC reference:
/// 0x00 = success, 0x10 = illegal command, 0x20 = bad address, 0x30 = protected,
/// 0x40 = programmer busy, 0x50 = file locked, 0xF0 = extended status follows.
/// libplctag surfaces only its own <see cref="Status"/> enum rather than exposing
/// the raw STS byte, so this method is not wired into the current read/write path.
/// It is retained as the reference mapping for future PCCC-STS inspection.
/// </summary>
/// <param name="sts">The PCCC STS byte.</param>
/// <returns>The corresponding OPC UA status code.</returns>
public static uint MapPcccStatus(byte sts) => sts switch
{
0x00 => Good,
0x10 => BadNotSupported,
0x20 => BadNodeIdUnknown,
0x30 => BadNotWritable,
0x40 => BadDeviceFailure,
0x50 => BadDeviceFailure,
0xF0 => BadInternalError, // extended status not inspected at this layer
_ => BadCommunicationError,
};
public static uint MapStatus(AbStatus status) => AbOpcUaStatusMap.MapStatus(status);
}
@@ -0,0 +1,57 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// The ONE place an AbLegacy driver's effective transport is decided from its configured
/// <see cref="AbLegacyDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
/// CLI (through the driver) all resolve through here.
/// <para>An absent key means <see cref="Default"/> = <see cref="AbLegacyTransport.Libplctag"/> for EVERY
/// family (final review I3): on a wire the native PCCC path is proven only for N-file reads and writes
/// and one F8 read, so Native stays opt-in. An explicit value always wins (an operator choice). An
/// explicit Native on a PLC-5 / LogixPccc device is allowed but logs a once-per-instance Warning
/// (<see cref="NativeLimitedFamilies"/>).</para>
/// </summary>
public static class AbLegacyTransportResolver
{
/// <summary>Returns true for a family whose native transport support is limited.</summary>
/// <param name="family">The PLC family.</param>
/// <returns>True for PLC-5 and LogixPccc.</returns>
public static bool IsNativeLimited(AbLegacyPlcFamily family) =>
family is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc;
/// <summary>Returns the distinct native-limited families among <paramref name="devices"/>.</summary>
/// <param name="devices">The configured devices.</param>
/// <returns>The limited families present, in declaration order.</returns>
public static IReadOnlyList<AbLegacyPlcFamily> NativeLimitedFamilies(IEnumerable<AbLegacyDeviceOptions> devices)
{
ArgumentNullException.ThrowIfNull(devices);
return [.. devices.Select(d => d.PlcFamily).Where(IsNativeLimited).Distinct()];
}
/// <summary>The transport an absent / null <c>transport</c> key selects (all families).</summary>
public const AbLegacyTransport Default = AbLegacyTransport.Libplctag;
/// <summary>Returns the effective transport: an explicit value always wins; null means <see cref="Default"/>.</summary>
/// <param name="configured">The configured transport, or null for "use the default".</param>
/// <returns>The transport the driver runs on.</returns>
public static AbLegacyTransport Resolve(AbLegacyTransport? configured) => configured ?? Default;
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
/// <param name="options">The driver options.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbLegacyTransport Resolve(AbLegacyDriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line:
/// <c>Libplctag (default)</c> when the key was absent, <c>Native (configured)</c> when explicit.
/// </summary>
/// <param name="configured">The configured transport, or null.</param>
/// <returns>A short description.</returns>
public static string Describe(AbLegacyTransport? configured) =>
configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)";
}
@@ -0,0 +1,45 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>The ONLY place libplctag's <see cref="Status"/> meets <see cref="AbStatus"/> in AbLegacy.</summary>
internal static class LibplctagLegacyStatusTranslation
{
/// <summary>Translates a libplctag status into the transport-neutral <see cref="AbStatus"/>.</summary>
/// <param name="s">The libplctag status.</param>
/// <returns>The corresponding <see cref="AbStatus"/>.</returns>
public static AbStatus ToAbStatus(Status s) => s switch
{
Status.Ok => AbStatus.Ok,
Status.Pending => AbStatus.Pending,
Status.ErrorTimeout => AbStatus.Timeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
Status.ErrorNotAllowed => AbStatus.NotAllowed,
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
Status.ErrorTooLarge => AbStatus.TooLarge,
Status.ErrorTooSmall => AbStatus.TooSmall,
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
_ => AbStatus.BadConnection,
};
/// <summary>libplctag builds the exception from <c>Status.ToString()</c>, so the message is the enum name.</summary>
/// <param name="ex">The libplctag exception.</param>
/// <returns>The corresponding <see cref="AbStatus"/>.</returns>
public static AbStatus ToAbStatus(LibPlcTagException ex) =>
Enum.TryParse<Status>((ex.Message ?? "").Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
? ToAbStatus(s)
: AbStatus.BadConnection;
/// <summary>Runs <paramref name="op"/>, rethrowing libplctag failures as <see cref="AbTransportException"/>.</summary>
/// <param name="op">The libplctag operation.</param>
/// <returns>A task completing with the operation.</returns>
public static async Task Wrap(Func<Task> op)
{
try { await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
}
@@ -1,4 +1,6 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
@@ -12,35 +14,86 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
/// <summary>
/// The status the native tag held when a failed initialisation destroyed it (see
/// <see cref="LibplctagOrphanRelease"/>); null while the wrapper's own handle is authoritative.
/// </summary>
private Status? _releasedStatus;
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
private int _releasedHandle;
/// <summary>Initializes a new instance of the <see cref="LibplctagLegacyTagRuntime"/> class.</summary>
/// <param name="p">The parameters for tag creation.</param>
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
{
_tag = new Tag
_tag = BuildTag(p);
}
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
internal static Tag BuildTag(AbLegacyTagCreateParams p) =>
new Tag
{
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
Gateway = p.LibplctagGateway,
Path = p.CipPath,
Path = LibplctagPath.Normalize(p.CipPath),
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
Name = p.TagName,
Timeout = p.Timeout,
ElementCount = p.ElementCount > 1 ? p.ElementCount : null,
};
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
/// <inheritdoc />
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
public Task ReadAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
/// <summary>
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
/// </summary>
private async Task RunReleasingOrphanAsync(Func<Task> op)
{
try
{
await LibplctagLegacyStatusTranslation.Wrap(op).ConfigureAwait(false);
_releasedStatus = null;
_releasedHandle = 0;
}
catch
{
var handle = LibplctagOrphanRelease.HandleOf(_tag);
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
{
_releasedStatus = status;
_releasedHandle = handle;
}
throw;
}
}
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
/// (0 if none was created). Tests only.</summary>
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
/// <inheritdoc />
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
/// <inheritdoc />
public int GetStatus() => (int)_tag.GetStatus();
public int GetStatus() =>
(int)LibplctagLegacyStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
/// <inheritdoc />
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Normalizes a parsed CIP route for libplctag's <c>Path</c> attribute. libplctag rejects an
/// empty-string path with <c>ErrorBadData</c>; "no route" (Micro800, unrouted MicroLogix) must be
/// expressed by omitting the attribute, i.e. <see langword="null"/>.
/// </summary>
internal static class LibplctagPath
{
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
/// <param name="path">The parsed CIP path.</param>
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
}
@@ -0,0 +1,14 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>Creates <see cref="NativeAbLegacyTagRuntime"/> instances over a <see cref="DeviceSessionPool"/>
/// (the process-wide shared pool by default).</summary>
internal sealed class NativeAbLegacyTagFactory(DeviceSessionPool? pool = null) : IAbLegacyTagFactory
{
private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared;
/// <inheritdoc />
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
new NativeAbLegacyTagRuntime(createParams, _pool);
}
@@ -0,0 +1,395 @@
using System.Buffers.Binary;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Native <see cref="IAbLegacyTagRuntime"/>: PCCC data-table reads and writes carried in Execute PCCC
/// through the device's shared <see cref="RequestScheduler"/> (one per device connection, never per tag).
/// Decoded values, exceptions and statuses match <see cref="LibplctagLegacyTagRuntime"/>.
/// </summary>
internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
{
private const int DefaultPort = 44818;
private const int StringDataBytes = 82;
private const int StringElementBytes = 84;
// One process-wide TNS counter: no two in-flight requests (on any connection) share a TNS, so a stale
// reply belonging to another tag fails the TNS echo check instead of matching by accident.
private static int s_tns;
private readonly string _plc;
private readonly RequestScheduler _scheduler;
private readonly ConnectionLease? _lease;
private readonly byte[] _requestorId;
private readonly ushort _vendor;
private readonly uint _serial;
private readonly PcccAddress? _address;
private readonly Exception? _addressError;
private readonly int _elementSize;
private readonly int _count;
private readonly byte[] _buffer;
private int _status;
/// <summary>Creates a runtime that leases the shared connection for its device from <paramref name="pool"/>.</summary>
public NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, DeviceSessionPool pool)
: this(p, pool, OptionsFor(p))
{
}
private NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, DeviceSessionPool pool, ConnectionOptions options)
: this(p, pool.Acquire(KeyFor(p), options))
{
}
// The PCCC requestor id is libplctag's captured identity, not the connection's CIP originator: ab_server
// replies with that fixed id whatever was sent, and real controllers echo it (see PcccServices.RequestorVendor).
private NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, ConnectionLease lease)
: this(p, lease.Scheduler, PcccServices.RequestorVendor, PcccServices.RequestorSerial)
{
_lease = lease;
}
/// <summary>Test seam: runs over an explicit scheduler with an explicit requestor identity.</summary>
internal NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, RequestScheduler scheduler, ushort vendor, uint serial)
{
_plc = p.LibplctagPlcAttribute;
_scheduler = scheduler;
_vendor = vendor;
_serial = serial;
_requestorId = new byte[7];
_requestorId[0] = 7;
BinaryPrimitives.WriteUInt16LittleEndian(_requestorId.AsSpan(1), vendor);
BinaryPrimitives.WriteUInt32LittleEndian(_requestorId.AsSpan(3), serial);
_count = Math.Max(1, p.ElementCount);
try
{
_address = ToPcccAddress(p.TagName);
}
catch (AbTransportException ex)
{
_addressError = ex;
}
_elementSize = _address is null ? 2 : ElementSize(_address);
_buffer = new byte[_elementSize * _count];
}
/// <summary>
/// Routing per PLC family, from the golden captures (<c>Captures/README.md</c> "Routing observed"):
/// slc500 / micrologix / plc5 send Execute PCCC bare in SendRRData with no route even when a
/// <c>1,0</c> path is configured, so they are <see cref="CipRouting.Direct"/>. logixpccc sends an
/// Unconnected Send (0x52) with route <c>01 00</c> (README line ~129, scratch capture: ab_server
/// rejected it, so the shape is read from the client side), so it is <see cref="CipRouting.UnconnectedSend"/>.
/// </summary>
/// <remarks>
/// logixpccc framing is UNVERIFIED and differs from libplctag: libplctag uses PLC-5 addressing with
/// 0x68/0x67 for LGX_PCCC, while this runtime sends SLC typed 0xA2/0xAA (no capture exists either way).
/// plc5 is likewise unverified beyond N-file reads. Neither family should default to Native until proven.
/// </remarks>
internal static CipRouting RoutingFor(string plcAttribute) => plcAttribute switch
{
"logixpccc" => CipRouting.UnconnectedSend,
_ => CipRouting.Direct,
};
internal static DeviceKey KeyFor(AbLegacyTagCreateParams p) =>
new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
internal static ConnectionOptions OptionsFor(AbLegacyTagCreateParams p)
{
var timeout = p.Timeout > TimeSpan.Zero && p.Timeout != Timeout.InfiniteTimeSpan
? p.Timeout
: TimeSpan.FromSeconds(5);
return new ConnectionOptions
{
Routing = RoutingFor(p.LibplctagPlcAttribute),
ConnectionSize = 504,
AllowPacking = false, // PCCC requests are never MSP-packed
Timeout = timeout,
};
}
private static PcccAddress ToPcccAddress(string tagName)
{
var a = AbLegacyAddress.TryParse(tagName)
?? throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is not a valid PCCC address.");
return a.SubElement is not null
? PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, a.SubElement)
: new PcccAddress(a.FileLetter, a.FileNumber, a.WordNumber, null, a.BitIndex);
}
private static int ElementSize(PcccAddress a) => a.FileLetter switch
{
"F" or "L" => 4,
"ST" => StringElementBytes,
"T" or "C" or "R" => a.SubElement is null ? 6 : 2,
_ => 2,
};
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
// libplctag resolves the tag on create and reports NotFound for a missing address; one read does the same.
RunAsync(() => ReadCoreAsync(cancellationToken));
/// <inheritdoc />
public Task ReadAsync(CancellationToken cancellationToken) => RunAsync(() => ReadCoreAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) => RunAsync(() => WriteCoreAsync(cancellationToken));
/// <inheritdoc />
public int GetStatus() => Volatile.Read(ref _status);
private async Task RunAsync(Func<Task> op)
{
try
{
await op().ConfigureAwait(false);
Volatile.Write(ref _status, (int)AbStatus.Ok);
}
catch (AbTransportException ex)
{
Volatile.Write(ref _status, (int)ex.Status);
throw;
}
catch (OperationCanceledException)
{
throw;
}
catch
{
Volatile.Write(ref _status, (int)AbStatus.BadConnection);
throw;
}
}
private (int ChunkElements, PcccAddress Address) Plan()
{
var address = _address ?? throw _addressError!;
if (_count > 1 && address.SubElement is not null)
throw new AbTransportException(AbStatus.Unsupported, "Arrays of a structured-element sub-element are not supported.");
return (Math.Max(1, PcccServices.MaxDataBytes / _elementSize), address);
}
/// <summary>Bytes of an Execute PCCC reply beyond its data: CIP header, requestor id, PCCC command / status / TNS
/// (rounded up) — the scheduler budgets <c>PcccReplyOverhead + data</c> against the connection (#582).</summary>
private const int PcccReplyOverhead = 32;
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Func<ushort, byte[]> pccc, CancellationToken ct, int replyDataBytes = 0)
{
var tns = unchecked((ushort)Interlocked.Increment(ref s_tns));
var request = PcccServices.ExecutePccc(_vendor, _serial, pccc(tns));
var reply = await _scheduler.SendAsync(request, ct, expectedReplyBytes: PcccReplyOverhead + replyDataBytes).ConfigureAwait(false);
return PcccServices.ParseReply(reply, tns, _requestorId);
}
private async Task ReadCoreAsync(CancellationToken ct)
{
var (chunk, address) = Plan();
var plc5 = _plc == "plc5";
if (plc5 && (!PcccServices.Plc5Verified || address.FileLetter != "N"))
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 reads are only verified for N files.");
var staged = new byte[_buffer.Length];
for (var done = 0; done < _count; done += chunk)
{
var n = Math.Min(chunk, _count - done);
var bytes = n * _elementSize;
var at = address with { Element = address.Element + done };
var data = await ExchangeAsync(
tns => plc5
? PcccServices.Plc5RangeRead(tns, at, (ushort)(bytes / 2), bytes)
: PcccServices.SlcTypedRead(tns, at, bytes),
ct, bytes).ConfigureAwait(false);
if (data.Length < bytes)
throw new AbTransportException(AbStatus.BadReply, $"PCCC read returned {data.Length} of {bytes} bytes.");
if (data.Length > bytes)
throw new AbTransportException(AbStatus.TooLarge, $"PCCC read returned {data.Length} bytes, more than the {bytes} requested.");
data.Span[..bytes].CopyTo(staged.AsSpan(done * _elementSize));
}
// Only a fully successful read replaces the buffer: a failed chunk never leaves a half-updated value.
staged.CopyTo(_buffer, 0);
}
private async Task WriteCoreAsync(CancellationToken ct)
{
if (_plc == "plc5")
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 writes are not capture-verified.");
// libplctag does a masked bit write (0xAB) here; there is no capture of it, and a read-modify-write
// would race the PLC updating TT/DN. Writing the word instead would clobber EN/TT/DN, so refuse
// before anything reaches the wire.
if (StatusBit is not null)
throw new AbTransportException(AbStatus.Unsupported,
"Writes to a status-bit sub-element (e.g. T4:0.DN) are not supported by the native transport.");
var (chunk, address) = Plan();
for (var done = 0; done < _count; done += chunk)
{
var n = Math.Min(chunk, _count - done);
var bytes = n * _elementSize;
var at = address with { Element = address.Element + done };
var slice = _buffer.AsMemory(done * _elementSize, bytes);
// A later chunk failing throws: a partial write is reported as failed, never Good.
await ExchangeAsync(tns => PcccServices.SlcTypedWrite(tns, at, slice.Span), ct).ConfigureAwait(false);
}
}
private ReadOnlySpan<byte> Slice(int offset, int length)
{
if (offset < 0 || offset + length > _buffer.Length)
throw new AbTransportException(AbStatus.OutOfBounds, $"Buffer access {offset}+{length} exceeds {_buffer.Length} bytes.");
return _buffer.AsSpan(offset, length);
}
private Span<byte> SliceW(int offset, int length)
{
if (offset < 0 || offset + length > _buffer.Length)
throw new AbTransportException(AbStatus.OutOfBounds, $"Buffer access {offset}+{length} exceeds {_buffer.Length} bytes.");
return _buffer.AsSpan(offset, length);
}
private short GetInt16(int offset) => BinaryPrimitives.ReadInt16LittleEndian(Slice(offset, 2));
private int GetInt32(int offset) => BinaryPrimitives.ReadInt32LittleEndian(Slice(offset, 4));
private float GetFloat32(int offset) => BinaryPrimitives.ReadSingleLittleEndian(Slice(offset, 4));
/// <summary>The bit of a status-bit sub-element (T4:0.DN, C5:0.CU, R6:0.EN...), else null.</summary>
private int? StatusBit => _address is { SubElement: not null, Bit: int b } ? b : null;
private bool GetBit(int bit) => bit < 16
? (((ushort)GetInt16(0) >> bit) & 1) != 0
: (((uint)GetInt32(0) >> bit) & 1) != 0;
/// <inheritdoc />
public object? DecodeValue(AbLegacyDataType type, int? bitIndex)
{
// A status-bit sub-element is a BIT tag in libplctag: every scalar getter returns that bit (0/1).
if (StatusBit is int sb && type != AbLegacyDataType.String)
{
var v = GetBit(sb);
return type switch
{
AbLegacyDataType.Bit => v,
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long
or AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => v ? 1 : 0,
AbLegacyDataType.Float => v ? 1f : 0f,
_ => null,
};
}
return type switch
{
AbLegacyDataType.Bit => bitIndex is int bit ? GetBit(bit) : (GetInt16(0) & 1) != 0,
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)GetInt16(0),
AbLegacyDataType.Long => GetInt32(0),
AbLegacyDataType.Float => GetFloat32(0),
AbLegacyDataType.String => DecodeString(),
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => GetInt32(0),
_ => null,
};
}
// SLC ST element: u16 LEN, then 82 data bytes with each byte pair swapped.
private string DecodeString()
{
var len = Math.Clamp((int)(ushort)GetInt16(0), 0, StringDataBytes);
var chars = new char[len];
for (var i = 0; i < len; i++) chars[i] = (char)Slice(2 + (i ^ 1), 1)[0];
return new string(chars);
}
private void SetString(string s)
{
if (s.Length > StringDataBytes)
throw new AbTransportException(AbStatus.TooLarge, $"String of {s.Length} chars exceeds {StringDataBytes}.");
var dest = SliceW(0, StringElementBytes);
dest.Clear();
BinaryPrimitives.WriteUInt16LittleEndian(dest, (ushort)s.Length);
for (var i = 0; i < s.Length; i++) dest[2 + (i ^ 1)] = (byte)s[i];
}
/// <inheritdoc />
public object? DecodeArray(AbLegacyDataType type, int count)
{
// Same layout assumptions as LibplctagLegacyTagRuntime.DecodeArray: element i at i * elementSize
// (2 for N/A, 4 for L/F), B files bit-packed (element i is bit i). String and structured arrays unsupported.
switch (type)
{
case AbLegacyDataType.Int:
case AbLegacyDataType.AnalogInt:
{
var arr = new short[count];
for (var i = 0; i < count; i++) arr[i] = GetInt16(i * 2);
return arr;
}
case AbLegacyDataType.Long:
{
var arr = new int[count];
for (var i = 0; i < count; i++) arr[i] = GetInt32(i * 4);
return arr;
}
case AbLegacyDataType.Float:
{
var arr = new float[count];
for (var i = 0; i < count; i++) arr[i] = GetFloat32(i * 4);
return arr;
}
case AbLegacyDataType.Bit:
{
var arr = new bool[count];
for (var i = 0; i < count; i++) arr[i] = ((Slice(i / 8, 1)[0] >> (i % 8)) & 1) != 0;
return arr;
}
default:
throw new NotSupportedException(
$"AbLegacyDataType {type} is not supported as an array element.");
}
}
/// <inheritdoc />
public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
{
switch (type)
{
case AbLegacyDataType.Bit:
if (bitIndex is int)
throw new NotSupportedException(
"Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
BinaryPrimitives.WriteInt16LittleEndian(SliceW(0, 2), Convert.ToBoolean(value) ? (short)1 : (short)0);
break;
case AbLegacyDataType.Int:
case AbLegacyDataType.AnalogInt:
BinaryPrimitives.WriteInt16LittleEndian(SliceW(0, 2), Convert.ToInt16(value));
break;
case AbLegacyDataType.Long:
BinaryPrimitives.WriteInt32LittleEndian(SliceW(0, 4), Convert.ToInt32(value));
break;
case AbLegacyDataType.Float:
BinaryPrimitives.WriteSingleLittleEndian(SliceW(0, 4), Convert.ToSingle(value));
break;
case AbLegacyDataType.String:
SetString(Convert.ToString(value) ?? string.Empty);
break;
case AbLegacyDataType.TimerElement:
case AbLegacyDataType.CounterElement:
case AbLegacyDataType.ControlElement:
BinaryPrimitives.WriteInt32LittleEndian(SliceW(0, 4), Convert.ToInt32(value));
break;
default:
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
}
}
/// <summary>
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
/// The same release shape as the native AbCip runtime — see <see cref="ConnectionLease.ReleaseInBackground"/>
/// for why a re-create the next instant still cannot overlap the close (Gitea #579).
/// </summary>
public void Dispose() => _lease?.ReleaseInBackground();
}

Some files were not shown because too many files have changed in this diff Show More