CI Hex.pm Documentation License

Implemented by AI under the supervision of Tallak Tveide.

Yet another EtherCAT: an independent EtherCAT MainDevice in pure Elixir, for EtherCAT I/O such as a Beckhoff EK1100 coupler with its terminals.

It was written from the EtherCAT specification, ETG.1000 (parts 4 and 6 most of all; IEC 61158 has the same protocol as Type 12), not from another stack's code. The library keeps no clock of its own: process data goes out when the application asks, so that a PLC exchanges its I/O inside its own scan.

Status: scan, SII, the AL state machine, CoE SDO transfers, cyclic process data in one logical image, and cable redundancy on a ring, over raw Ethernet on Linux and macOS. Tested against a simulated segment, Wireshark's EtherCAT dissector, and a rack of ten Beckhoff terminals behind an EK1100 (EL1809, EL1088, EL2809, EL3124, EL2535 and EL7037), all of which it takes to Operational. What's left is under What's missing.

A segment

{:ok, main} = EtherCAT.MainDevice.start_link(interface: "eth1")

{:ok, subdevices} = EtherCAT.MainDevice.scan(main)
# [%EtherCAT.SubDevice{position: 0, station: 0x1000, name: "EK1100", vendor_id: 2, ...}, ...]

{:ok, image} = EtherCAT.MainDevice.map(main)
:ok = EtherCAT.MainDevice.request_state(main, :operational)

outputs = EtherCAT.ProcessImage.outputs(image)
outputs = EtherCAT.ProcessImage.put(image, outputs, 0x1002, {0x7000, 0x01}, true)
{:ok, inputs} = EtherCAT.MainDevice.exchange(main, outputs)
true = EtherCAT.ProcessImage.get(image, inputs, 0x1001, {0x6000, 0x01})

scan/2 counts the SubDevices, gives each a configured station address, 0x1000 and up by position, and reads its DL information, AL state and SII. It changes no AL state.

map/2 takes the segment to Init, then to Pre-Operational with the mailboxes set up, and reads the PDOs each SubDevice has assigned: from CoE (objects 0x1C12 and 0x1C13 and the mappings they name) where it has a mailbox, and from its SII where it hasn't. It maps their sync managers into one EtherCAT.ProcessImage with the FMMUs: every SubDevice's outputs, then every SubDevice's inputs. A part of a byte or more takes whole bytes, and smaller ones share bytes, bit by bit, where the SubDevice's FMMUs can map single bits. stations: maps some SubDevices and leaves the rest out. A SubDevice with several sync managers for its outputs or inputs, as the EL2809 has two for its sixteen outputs, has them one after the other in its part, with an FMMU for each run of them that lies together in its memory.

request_state/3 takes SubDevices to a state through the states between. On the way to Operational it sends outputs, as SubDevices want them before they go there, and from then on they must keep coming within the sync manager watchdog. sync_manager_watchdog: sets that time, in nanoseconds, on the way to Safe-Operational.

exchange/3 sends the outputs in one LRW for the image, or in as many as it takes, with a read of the AL status of every SubDevice in the same frame, and gives {:ok, inputs} only when:

  • every working counter is what the image expects, two for each SubDevice whose outputs a datagram writes and one for each whose inputs it reads, and
  • the AL states are those request_state/3 left.

Otherwise it gives {:error, {:working_counter, expected, got}}, {:error, {:state, [{station, state, al_status_code}]}} for the SubDevices out of their state, or {:error, :timeout}. send_exchange/3 sends without waiting, the result coming as {EtherCAT.MainDevice, ref, result}.

An exchange waits 20 ms for its frame, or the timeout: it's given. Keep that well within the watchdog: while an exchange waits for a frame that was lost, no outputs reach the SubDevices.

Values

An entry of the image is found by its SubDevice's station address and the object it maps, {index, subindex}. Its value is what its data type makes it, the CoE type its SubDevice's SII gives: a boolean for one bit, a signed or unsigned integer, a float for REAL32 and REAL64, a binary for strings. An entry of no known type is an unsigned integer of its bits.

Datagrams

transact/3 sends any datagrams, each a tuple named for its command, and gives a result for each:

[{:ok, <<0x08, 0>>, 1}, {:ok, <<_::binary-2>>, 9}] =
  EtherCAT.MainDevice.transact(main, [{:fprd, 0x1003, 0x0130, 2}, {:brd, 0x0000, 2}])
RequestCommand
{:aprd, position, register, length}, {:apwr, position, register, data}, {:aprw, ...}by position
{:fprd, station, register, length}, {:fpwr, station, register, data}, {:fprw, ...}by station address
{:brd, register, length}, {:bwr, register, data}, {:brw, ...}to every SubDevice
{:lrd, address, length}, {:lwr, address, data}, {:lrw, address, data}in logical memory
{:armw, position, register, length}, {:frmw, station, register, length}read at one, write at the rest

Datagrams go in one frame when they fit and in as few as they fit otherwise, all at once, and up to eight frames are on the way at once. Each frame has an index of its own, and its answer is the frame that comes back with the same index and the same datagrams. EtherCAT.Register has the addresses of the registers by their names in the spec.

CoE

{:ok, <<vendor_id::little-32>>} = EtherCAT.CoE.upload(main, 0x1003, 0x1018, 0x01)
:ok = EtherCAT.CoE.download(main, 0x1003, 0x8000, 0x06, <<1>>)
{:error, {:abort, 0x06020000}} = EtherCAT.CoE.upload(main, 0x1003, 0x9999, 0x00)

SDO transfers go expedited for up to four bytes, normal for more, and in segments when they don't fit the mailbox. One transfer goes on at a time with each SubDevice; others wait their turn. Emergency messages that come in the meantime are kept, and EtherCAT.CoE.emergencies/2 gives the latest.

A ring

{:ok, main} = EtherCAT.MainDevice.start_link(interface: "eth1", redundant: "eth2")

With redundant: the segment is a ring: a cable runs from the free port at its far end back to a second Ethernet port, and every frame goes out of both. A SubDevice processes a frame only on its way out from the primary port. While the ring is whole, the copy from the primary port goes round through every SubDevice and comes in at the secondary port, and the copy from the secondary port goes round the other way untouched. Where a cable is broken, the SubDevices on either side turn the copies back: the one from the primary port has passed the SubDevices before the break, the one from the secondary port those after it, and the MainDevice puts the two together, adding up their working counters. An exchange gives the same inputs either way, so one cable can be pulled anywhere, at a port of the MainDevice too, and the process data goes on.

The copy out of the secondary port carries the frame's index plus 128, so the MainDevice knows which port each copy that comes back left by. A frame is done as soon as the primary port's copy comes in at the secondary port. Otherwise the first copy to come waits a grace for the other, which travels the same ring: grace:, 2 ms unless start_link/1 is given another. When the other doesn't come, as with the cable to one of the ports out, the frame is done with the one, and the frames after it with the first copy from that side, until the other side answers again. An exchange waits the grace at most, not its timeout, so a pulled cable leaves the sync manager watchdogs fed.

A cable put back at one of the MainDevice's ports needs more care. The SubDevice at the other end of it sees the link at once, while the NIC may take some milliseconds more before it passes frames. A port that opened with its link would send frames into a NIC that takes none yet, and with no frames coming from that NIC either, every frame would lose both its copies until it does. So scan/2 sets the two ports at the ends of a ring to Auto close: when its cable comes back, the port stays closed, turning frames back as while the cable was out, until a frame from the NIC comes in at it and opens it. Every other port is set to Auto, opening with its link. The primary port's cable is at port 0 of the first SubDevice. The secondary port's is wherever the far end of the ring is, which the scan works out from the links of every SubDevice: a frame passes a SubDevice's ports in a fixed order, so taking the SubDevices in order, each hangs on the next free port with a link, and on a whole ring a single one is left over, by which the frame leaves for the secondary port. On the desk rack that is the coupler's port 2, its X2, not a port of the last terminal.

ring/1 says whether the ring is :intact or :broken, as the last copy to come back showed, and whether each port is up. links/1 gives the ports of every SubDevice, with their links and whether their loops are closed, which shows where a break is. On a broken ring, scan/2 addresses the SubDevices on each side from their own end. A port whose transport fails is opened again a second later, while the MainDevice goes on with the other.

  • On a whole ring, an exchange waits for the copy that comes in at the secondary port, so that port's NIC sets how long it takes. A NIC that is slow to receive, as USB adapters often are, is best at the primary port. With an RTL8153 at USB 2 speed at the secondary port on Linux, which holds received frames back about 250 µs, an exchange of the desk rack took 0.5 ms; the other way round it would be about 0.2 ms.
  • A frame whose copies come back more than the grace apart is done without the later one, and the exchange fails. On the desk rack, scanning in a node just started, the copies came more than 1 ms apart in 5 scans of 30, and never 2 ms in 60. On a busy machine, where the MainDevice may be late to read them, give it a longer grace, well under the exchange's timeout.
  • The frame that opens a port that its cable came back to is dropped, as the EK1100 does. At the secondary port that costs nothing, as the copy from the primary port carries the exchange; at the primary port it costs the exchange, as the other copy then goes round untouched.
  • The secondary port's end is found only on a whole ring. Scanned with that cable out, the MainDevice finds it the first time it sees the ring whole; until then that port is at Auto, and putting its cable back loses frames until the NIC there is up.
  • Two cables at once cost an exchange or two: going from one side answering alone straight to a break in the middle, or from the cable at one port out straight to the other's.

The ring was tested in the simulator, broken in every place, before a scan and while exchanging; and on the desk rack on Linux, with the coupler's ports closed from software, through DL control's temporary loop setting, while exchanging every millisecond: no exchange failed. And with the cables at the coupler's X1 and X2 pulled and put back by hand, twice each, while exchanging every 2 ms: of 23,459 exchanges, the two whose frames opened X1 again failed, each within 3 ms, and every other went through, the slowest in 4.8 ms. The terminals stayed Operational throughout.

Transports

TransportFor
EtherCAT.Transport.Packetraw Ethernet on Linux, through a packet socket from OTP's :socket; needs CAP_NET_RAW
EtherCAT.Transport.BPFraw Ethernet on macOS, through a BPF device; needs read and write access to /dev/bpf*
EtherCAT.Transport.UDPEtherCAT frames in UDP datagrams to port 0x88A4, as ETG.1000.4 allows

interface: picks the raw one for the operating system. On Linux nothing but Erlang runs. On macOS the BPF device needs ioctl calls the BEAM can't make, so a small C program, priv/bpf/ethercat_bpf.c, does them as an Erlang port: an OS process of its own, so that a crash there closes the port and leaves the BEAM running. It is built with cc when yaethercat is compiled on macOS, and nowhere else.

To give a BEAM CAP_NET_RAW without root:

beam=$(erl -noshell -eval 'io:format("~s", [filename:join([code:root_dir(), "erts-" ++ erlang:system_info(version), "bin", "beam.smp"])]), halt().')
sudo setcap cap_net_raw+ep "$beam"

Hostile networks

EtherCAT has no authentication: anything on the segment can send answers the MainDevice can't tell from those of its SubDevices, so the segment has to be trusted, on an Ethernet port of its own. What the MainDevice does is go on, in bounded time and memory, whatever comes back.

The MainDevice never logs, and a call never exits the caller: with the MainDevice gone or its transport down, the result says so. Frames that aren't answers to its own, with another index or other datagrams, are passed over, as are a flood of them; an answer that comes after its timeout answers nothing. Every decoder takes random bytes without crashing. On a ring, a copy that comes twice counts once.

A SubDevice can't hold a call past its time or fill the MainDevice's memory: an upload bigger than max_size: (1 MiB) is refused before it's read, its segments have to carry it, and its answer has to come within the timeout, however many emergencies come first. A scan reads 128 KiB of an SII at most, whatever size it says it is, and takes no more SubDevices than there are station addresses. A map takes PDO assignments and mappings of the wrong size as errors.

Mix tasks

mix ethercat.scan --interface eth1 scans a segment and prints each SubDevice: where it is, its identity and AL state, its ESC, mailbox and sync managers, and the PDOs its SII lists. With --map it maps them too, which takes them to Pre-Operational, and prints where each entry is in the image.

What's missing

  • Distributed clocks: the clocks are not set, so SubDevices that need SYNC0 don't run.
  • Hot connect, and recovery inside the library: when a SubDevice falls out of its state, exchanges say so, and the application scans and maps again.
  • Configuring PDOs: their assignment and mapping are read from the SubDevices and taken as they are. Changing them takes CoE downloads of your own, after map/2 has taken the segment to Pre-Operational, and a map of your own after that.
  • Mailbox protocols other than CoE: FoE, EoE, SoE, AoE and VoE. In CoE, SDO Information. Complete access is sent when asked, and hasn't been tried on a SubDevice.
  • The mailbox repeat request: an answer lost on its way back is lost, and the transfer times out; and the counter of the SubDevice's messages isn't checked for lost ones.
  • One process image per segment, exchanged as a whole; station aliases are read and not used.
  • Network variables and mailbox gateway frames (frame types 4 and 5) are refused.
  • The protocol enhancements of ETG.1020.
  • Tested against a simulated segment and a rack of Beckhoff terminals, not against the ETG's conformance test tool. On macOS, through BPF and a USB Ethernet adapter, an exchange of that rack's image (34 bytes of outputs and 49 of inputs) takes about 0.25 ms, 0.3 ms at the 99th percentile; on Linux, through a packet socket and an onboard port, about 0.2 ms.
  • A ring's grace is set, 2 ms unless told otherwise, not learned from how far apart the copies of frames come back.

Tests

test/support/simulator.ex is a segment of simulated SubDevices behind UDP, written from the spec apart from the MainDevice: each datagram passes them in order; they read and write their memory, map logical memory through their FMMUs, keep sync managers as mailboxes and buffers, read their SII from an EEPROM, run the AL state machine with its checks, and answer CoE SDO requests from an object dictionary. Its SubDevices are made after a desk rack: a coupler, digital inputs and outputs, analog inputs with CoE, and a drive that refuses Safe-Operational. test/fixtures/desk holds a real rack as recorded from it, each SubDevice's DL information, SII and the CoE objects a map reads, and test/ethercat/recorded_test.exs runs it in the simulator, to map the same image as the rack does.

test/ethercat/tshark_test.exs writes hundreds of random frames to a pcap and has Wireshark's EtherCAT dissector, a decoder written independently of this one, read them back field by field. It runs where tshark is found: TSHARK, the PATH, or Wireshark's app on macOS.

The property tests fuzz with StreamData: frames decode to what they were encoded from, random and mutated bytes go into every decoder, and a running MainDevice gets mutated answers, junk, floods and late answers. Nothing may crash. far_end/1 is checked against a model of how a frame passes random trees of SubDevices. On a ring, cables break and mend while copies are lost, repeated, held back, slowed or changed at either end, among frames that aren't answers: every exchange ends in its time, the ring goes on once it's mended, and an exchange that gives inputs gives those the SubDevices have, unless an answer was changed on the way. Scans, maps and the way to Operational go through wrong SIIs, objects that say other than they are, and changed answers. For more cases:

FUZZ_RUNS=10000 mix test test/ethercat/property_test.exs test/ethercat/hardening_test.exs \
  test/ethercat/ring_hardening_test.exs test/ethercat/hostile_test.exs

test/ethercat/packet_test.exs runs the simulated segment behind a packet socket on one end of a veth pair and a MainDevice on the other, on Linux, where ETHERCAT_VETH names the pair and the BEAM has CAP_NET_RAW; CI makes both.

The hardware tests run against a real segment, on the interface ETHERCAT_INTERFACE names, with nothing wired to it, as they switch its outputs. With ETHERCAT_REDUNDANT naming the interface at the far end of a ring, they run on the ring, and those of test/ethercat/hardware_ring_test.exs check that its ends are at Auto close, and close the port at each end from software for a second while exchanging:

ETHERCAT_INTERFACE=eth1 ETHERCAT_REDUNDANT=eth2 mix test --only hardware

The real-time tests time what the MainDevice does on a ring, at its own grace of 2 ms: that no exchange waits for a copy that won't come. The rest of the ring's tests give the copies 50 ms, so that a busy machine running them doesn't part them. The real-time ones want a machine with nothing else to do, and run when asked for:

mix test --only realtime

CI also runs Credo and Dialyzer.

Roadmap

FeatureSpec
✅Frames and datagrams, all fourteen commands, in Ethernet and UDPETG.1000.4, 5
✅Raw Ethernet on Linux and macOS
✅Scan, station addresses, SIIETG.1000.4, 6; ETG.1000.6, 5.4
✅AL state machine, AL status codesETG.1000.6, 5.3, 6.4.1
✅Mailbox, CoE SDO upload and download, emergenciesETG.1000.4, 5.6; ETG.1000.6, 5.6
✅PDOs from CoE or SII, FMMUs, one process image, cyclic exchangeETG.1000.4, 6.6, 6.7; ETG.1000.6, 5.6.7
Distributed clocks, SYNC0ETG.1000.4, 6.8
Hot connect, recovery in the libraryETG.1000.4, 6.1; ETG.1000.6, 5.4
✅Cable redundancyETG.1000.4, 6.1.3
FoE, SDO Information, complete accessETG.1000.6, 5.6.3, 5.8

License

Copyright 2026 Tallak Tveide. Licensed under the Apache License, Version 2.0; see LICENSE and NOTICE.

The specification isn't in this repository, nor is its text: the code and the docs say what the protocol does in their own words, and cite its clauses by number. What they take from it is what any implementation has to match: frame layouts, commands, register addresses, object indexes and codes.

yaethercat isn't made, endorsed or certified by Beckhoff Automation or the EtherCAT Technology Group, and it hasn't been through the ETG's conformance test.

The Apache License gives rights to this code only, not under Beckhoff's patents or trademarks. Before you build a product on yaethercat, ask Beckhoff or the ETG what license, if any, an EtherCAT MainDevice needs.

test/fixtures/desk holds what was read from a rack of Beckhoff terminals, each one's SII and some of its CoE objects, to test against. It isn't in the Hex package.

EtherCAT® is a registered trademark and patented technology, licensed by Beckhoff Automation GmbH, Germany.