Dual-port bus link implemented as a gen_statem.
Owns two transports (primary and secondary), scheduling (queues, batching, stale expiry), in-flight exchange tracking, and caller replies.
In production this link is raw-only: both ports are expected to be
EtherCAT.Bus.Transport.RawSocket interfaces. The transport_mod option
remains available for tests and transport fakes.
Ring topology
In a healthy EtherCAT redundant ring, frames cross between ports:
- Primary TX → slaves (forward) → Secondary RX
- Secondary TX → slaves (reverse) → Primary RX
Each received frame is classified by comparing its Ethernet source MAC against the MAC addresses of both NICs.
Exchange flow
Every exchange attempts to send on both ports, starts a timeout if at least one send succeeds, and saves the caller. Frames are classified on arrival:
- Cross-delivery — frame arrived on the opposite port (src MAC belongs to the other NIC). Ring path is healthy.
- Bounce-back — frame arrived on the same port it was sent from (src MAC matches own NIC). Ring is broken; the frame reflected at the break point.
The forward cross (primary's frame on secondary) is authoritative and
completes immediately. A single reverse cross or bounce keeps the merge
window open while the link waits for a forward cross or second frame. A
single :unknown arrival with processed datagrams (wkc > 0) can also
complete immediately when MAC classification is not trustworthy. On
timeout, partial arrivals are converted into a best-effort reply when they
still match the awaiting datagrams; pure reverse-path copies are not used
as authoritative data, so only processed or merged bounce data can rescue
a timed-out exchange. Reliable non-logical one-sided bounces may also
complete immediately when only a tiny remainder of the original frame-time
budget is left, avoiding a spurious extra millisecond timer hop near the
deadline.
flowchart TD
IDLE -->|transact| SEND
SEND[Attempt primary and secondary send] -->|any leg sent| WAIT
SEND -->|both sends fail| ERR_SEND
WAIT{Arrival or timeout}
WAIT -->|forward cross| REPLY_OK
WAIT -->|single authoritative reply on one live leg| REPLY_OK
WAIT -->|single unknown with wkc>0| REPLY_OK
WAIT -->|reverse cross / bounce / other unknown| MERGE_WAIT
WAIT -->|timeout with no arrivals| ERR_TIMEOUT
MERGE_WAIT{Merge window}
MERGE_WAIT -->|forward cross arrives| REPLY_OK
MERGE_WAIT -->|pri bounce + sec bounce| MERGE_OK
MERGE_WAIT -->|timeout with partial arrivals| BEST_EFFORT
REPLY_OK([Reply awaiting callers]) --> IDLE
MERGE_OK([Merge complementary bounces]) --> IDLE
BEST_EFFORT([Reply with best available datagrams]) --> IDLE
ERR_TIMEOUT([Reply timeout]) --> IDLE
ERR_SEND([Reply transport unavailable]) --> IDLEgen_statem states
:idle— no exchange in flight, ready to dispatch:awaiting— exchange sent, waiting for reply(ies)
The link tracks per-port transport health from open/send failures and
surfaces degraded topology and fault info through info/1. Health is
observational only: a leg marked :down is still attempted on the next
exchange, so reconnects do not wait on a separate health promotion path.
Timeout observations are logged and emitted as telemetry, but receive-side
timeout patterns do not directly mark a port down unless send/open state
also fails.
Summary
Functions
@spec start_link(keyword()) :: :gen_statem.start_ret()