Packages

An EtherCAT MainDevice in pure Elixir, for EtherCAT I/O such as a Beckhoff EK1100 with its terminals: scan, SII, the AL state machine, CoE, and cyclic process data at the pace of the application, over raw Ethernet on Linux and macOS, with cable redundancy on a ring.

Current section

Files

Jump to
yaethercat README.md
Raw

README.md

# yaethercat

[![CI](https://github.com/tallakt/yaethercat/actions/workflows/ci.yml/badge.svg)](https://github.com/tallakt/yaethercat/actions/workflows/ci.yml)
[![Hex.pm](https://img.shields.io/hexpm/v/yaethercat.svg)](https://hex.pm/packages/yaethercat)
[![Documentation](https://img.shields.io/badge/docs-hexdocs-purple.svg)](https://hexdocs.pm/yaethercat)
[![License](https://img.shields.io/hexpm/l/yaethercat.svg)](https://github.com/tallakt/yaethercat/blob/main/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](#whats-missing).

## A segment

```elixir
{: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:

```elixir
[{:ok, <<0x08, 0>>, 1}, {:ok, <<_::binary-2>>, 9}] =
  EtherCAT.MainDevice.transact(main, [{:fprd, 0x1003, 0x0130, 2}, {:brd, 0x0000, 2}])
```

| Request | Command |
|---|---|
| `{: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

```elixir
{: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

```elixir
{: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

| Transport | For |
|---|---|
| `EtherCAT.Transport.Packet` | raw Ethernet on Linux, through a packet socket from OTP's `:socket`; needs `CAP_NET_RAW` |
| `EtherCAT.Transport.BPF` | raw Ethernet on macOS, through a BPF device; needs read and write access to `/dev/bpf*` |
| `EtherCAT.Transport.UDP` | EtherCAT 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](https://www.wireshark.org)'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](https://github.com/whatyouhide/stream_data): 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](https://github.com/rrrene/credo) and
[Dialyzer](https://github.com/jeremyjh/dialyxir).

## Roadmap

| | Feature | Spec |
|---|---|---|
| ✅ | Frames and datagrams, all fourteen commands, in Ethernet and UDP | ETG.1000.4, 5 |
| ✅ | Raw Ethernet on Linux and macOS | |
| ✅ | Scan, station addresses, SII | ETG.1000.4, 6; ETG.1000.6, 5.4 |
| ✅ | AL state machine, AL status codes | ETG.1000.6, 5.3, 6.4.1 |
| ✅ | Mailbox, CoE SDO upload and download, emergencies | ETG.1000.4, 5.6; ETG.1000.6, 5.6 |
| ✅ | PDOs from CoE or SII, FMMUs, one process image, cyclic exchange | ETG.1000.4, 6.6, 6.7; ETG.1000.6, 5.6.7 |
| | Distributed clocks, SYNC0 | ETG.1000.4, 6.8 |
| | Hot connect, recovery in the library | ETG.1000.4, 6.1; ETG.1000.6, 5.4 |
| ✅ | Cable redundancy | ETG.1000.4, 6.1.3 |
| | FoE, SDO Information, complete access | ETG.1000.6, 5.6.3, 5.8 |

## License

Copyright 2026 Tallak Tveide. Licensed under the Apache License, Version 2.0; see
[LICENSE](LICENSE) and [NOTICE](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.