Skip to content

Why Was the CircuitPython W5500 Driver Stuck at 99 Messages a Second?

A rope encoder on a moving projection screen streams OSC at 201 Hz over W5500, after the CircuitPython driver proved call-bound.

Grace_Koo

Published September 03, 2026 · Updated October 08, 2026

Original author: stanelieOriginal source (new tab)

Why Was the CircuitPython W5500 Driver Stuck at 99 Messages a Second?

Components

Hardware components

WIZnet parts

Project description

gaytencoder — Why the CircuitPython W5500 Driver Was Stuck at 99 Messages a Second

#W5500 #CircuitPython #adafruit_wiznet5k #SPI #OSC #ModbusRTU #RS485 #ESP32S3 #MACaddress

📚 Context: stanelie/gaytencoder — firmware for a Waveshare ESP32-S3-ETH that streams a rope encoder's absolute position over OSC/UDP.


01 — What the repository builds

It measures where a moving projection screen is during a performance, so the video can follow it. The rope encoder's cable is attached to the screen, the distance the screen has travelled is read straight off, and that value goes to the playback machine. The board itself rides on the screen and can end up in the audience's sightline, which is why the status LED can be switched off entirely.

The board polls the encoder over RS485 with Modbus-RTU and sends an OSC message on every change over wired Ethernet. While the rope is moving it holds 201 messages per second. A separate setting (LEAD_TIME_S) sends the position early to compensate for the video pipeline's latency.

One board per encoder. Rather than sharing an RS485 bus, each board has its own link and its own Ethernet connection.

PartDetail
BoardWaveshare ESP32-S3-ETH, onboard W5500
FirmwareCircuitPython 10.3.0
SensorBriterencoder RS485 rope encoder, 4096 counts per turn, 100-turn range
TransceiverMAX1348x family, auto direction sensing

The W5500 sits on the board's hard-wired SPI — CLK IO13, MOSI IO11, MISO IO12, CS IO14, RST IO9 — clocked at 8 MHz.

On the RS485 side, TX IO43 drives the transceiver's DI and RX IO44 comes back from RO through a resistor divider. Because the transceiver keeps its receiver enabled while transmitting, the ESP32 hears its own request echoed back before the encoder's reply. The read path is written around that.

The onboard RGB LED reports state.

ColourMeaning
red, steadybooting, or the link is down
red, blinkingthe encoder is not answering
bluenetwork up and addressed, not yet streaming
greenrunning normally

The reason for using it is written down. The RJ45 link LED on this board reads backwards — dark means the link is up. The W5500's LINKLED pin is open-drain and active-low, pulling low once a link is established, and Waveshare wired the LED between that pin and ground rather than between that pin and VCC. The PHY hardware drives it with no controlling register, so this cannot be fixed in software. The author notes the RGB LED is the more useful indicator anyway, because it distinguishes a plugged-in cable from a network that actually works.

The repository carries a separate FINDINGS.md recording what the bring-up turned up, with measurements. What follows comes from there.


02 — The limit was not bandwidth

bench_spi.py is in the repository. It only reads registers, so nothing is written to the chip, and it uses the W5500's VERSIONR (0x0039, always 0x04) as a harmless probe. Its purpose is stated plainly: separate the cost of the bus lock, configure and CS toggle from the cost of each individual busio call.

OperationCost
the with device block alone (bus lock, configure, CS)24.8 µs
read, 3-byte header split up (4 busio calls)272.5 µs
read, header combined (2 busio calls)185.0 µs
read via write_readinto (1 busio call)118.8 µs
write, 3 bytes (1 call)112.3 µs
write, 31 bytes (1 call)145.7 µs
the library's one-byte register read468.6 µs
the library's TX write-pointer read (2 reads)1175.7 µs

Two lines summarise it.

  • Each busio call carries roughly 90 µs of fixed cost, and about 1 µs per extra payload byte. A 3-byte write costs 112 µs and a 31-byte write 146 µs. The cost attaches to calls, not bytes.
  • On top of that the library adds the better part of 200 µs of Python overhead per register operation — to_bytes() and bytearray allocations.

03 — 32 SPI calls per UDP datagram

Sending one 28-byte UDP packet down adafruit_wiznet5k's stock path costs 32 busio calls.

Stepbusio calls
read TX free size (read twice, defensively)8
read TX write pointer8
write into the socket buffer4
update the TX write pointer8
write the SEND command4
total32

Two things inflate it. The library emits the 3-byte register header as three separate device.write() calls, and it reads a 16-bit register as two independent one-byte transactions. Where every call carries 90 µs, those two account for most of the bill.

The order of the changes and the send cost after each one are recorded.

ChangeSend cost
baseline: sendto per packet, reconnecting the socket each time17.3 ms
connect once, then sendabout 10 ms
bypass the forced gc.collect inside send7.6 ms
read TX free size once instead of three times5.5 ms
register operations as single transactions (32 calls to 5)0.93 ms

Three more fixes sit off the send path.

  • while eth.link_status: was doing a 470 µs register read on every loop iteration. Amortised to once a second.
  • The OSC message was rebuilt from scratch per packet. Only the trailing int32 ever changes, so it is now built once and patched in place with struct.pack_into.
  • The Modbus echo and reply were two blocking uart.read() calls, now a single 17-byte read.

The result is 99 messages per second to 201, verified by decoding packets off the wire — 2012 packets in 10 seconds, none malformed.

Little headroom is left. The loop is now poll-bound: 3.7 ms of Modbus poll against 0.93 ms of send, and 1.5 ms of that poll is irreducible wire time for 17 bytes at the encoder's maximum 115200 baud. The author writes that 250 Hz is not reachable without different sensor hardware.


04 — Register operations cannot be batched

Collapsing the split header writes into a single write() is safe, and that is where the speedup comes from. But putting several register operations inside one with device: block does not work.

The W5500's Variable Data Length Mode consumes the address and control bytes once per CS-low frame; everything after them is payload. Putting five register operations under one block meant only the first was interpreted as a register access, and the pointer update and the SEND command were written into the TX buffer as data.

The failure mode is unpleasant. Every call returns successfully, the send counter increments, errors stay at zero — and not one packet reaches the wire.

SNTX_WR after fast send: 28 (expected 56)     # the pointer never advanced

diag_send.py in the repository catches this by sending distinguishable values down the library path and the fast path and checking which arrive. The conclusion is that each operation gets its own with device: block, and at 24.8 µs there was nothing worth batching in the first place.


05 — Every board shipped with the same MAC

With two boards on one network, the control panel showed one board whose identity flipped between the two every few seconds. That reads like a bug in the panel.

adafruit_wiznet5k carries a hardcoded default.

_DEFAULT_MAC = "DE:AD:BE:EF:FE:ED"

WIZNET5K(...) was called without a mac= argument, so every board took that same address. A live board's ARP entry confirms it, as do beacons captured with both boards powered.

20 beacons from 1 address(es)

10.8.1.152
   MAC        : de:ad:be:ef:fe:ed
   prefixes   : /gaytencoder1 x10, /gaytencoder2 x10

A shared MAC on one segment means:

  • DHCP identifies clients by MAC, so both are handed the same IP.
  • The switch's address table flaps between their two ports.
  • The panel keys boards by source IP, so both beacon streams land on one entry and overwrite each other — the flipping identity.

Worse than the visible symptom: a control message sent to that IP reaches whichever board the switch saw most recently. Configuring one board silently reconfigures the other. The static-IP path was no better, since it derives the address from eth.mac_address and collided the same way.

The fix

The W5500 has no MAC of its own. It is a MAC-less controller and the host must supply one — which is why the library carries a default at all. The ESP32 does have one, burned into eFuse at the factory, and microcontroller.cpu.uid carries it, but with the nibbles of each byte swapped. Measured over the REPL on both boards:

BoardMAC in boot_out.txtmicrocontroller.cpu.uid
13C:0F:02:D8:03:3CC3:F0:20:8D:30:C3
23C:0F:02:D7:DC:E8C3:F0:20:7D:CD:8E

Undoing the swap recovers the factory address exactly.

uid = bytes(microcontroller.cpu.uid)
mac = bytearray((b >> 4) | ((b & 0x0F) << 4) for b in uid[:6])
mac[0] &= 0xFE  # a source address must be unicast

That last line is not decoration. The swap turns 0x3C into 0xC3, and 0xC3 has its low bit set — which in a MAC means multicast, illegal as a source address. Handing the uid to the W5500 raw would give every board of this family an invalid address.

wifi.radio.mac_address returns the same six bytes directly and the author used it to confirm the derivation, but importing wifi powers the radio on. Not something to do on a stage for a value already available offline.


06 — What did not help

The negative results are recorded too.

AttemptOutcome
caching the configure call in SPIDevice entryno effect. The whole with device block is 24.8 µs, so reconfiguration was never the problem
raising the SPI clockno effect. The floor is per-call software cost, and a few bytes at 8 MHz is already microseconds
the encoder's automatic return modethe datasheet warns against intervals under 20 ms, and 20 ms is 50 Hz. Polling it at 220 Hz is faster
a higher encoder baud rate115200 is its maximum

One is written up more sharply. Skipping the TX free-space check and caching the write pointer in software, to cut five SPI operations to three, dropped CircuitPython into a hard fault — memory access error, safe mode, physical reset required. The gain would have been 0.2 ms, and the free-space check is useful back-pressure anyway.


07 — Where it stands

  • There is no licence file. Reusing the code needs a word with the author.
  • The encoder counter wraps at 409600. That is 4096 by 100 turns, and the zero happens to sit on the wrap point, so ordinary movement at full retraction crosses it every cycle. Re-expressing the top half of the span as negative fixed it, verified across 35 zero crossings with a largest single jump of 38 counts. The first attempt, a delta filter, is written up as having deadlocked and as having thrown away 230 ms of valid data per crossing.
  • The measurements come from one board and one encoder. Running more than one at a time was exercised only after the MAC fix.
  • The throughput figures are the author's own, decoded off their own wire, and were not reproduced here.

This is a repository that records its measurements, its negative results and its failed attempts together. For anyone who has to get throughput out of the same driver, where to push and where not to push is left behind with numbers attached.


08 — Similar Projects on WIZnet Makers


Q&A

Q. In what way is the W5500 used?

Through the chip's hardware stack. adafruit_wiznet5k opens a socket and sends OSC out over UDP. It is on the board's hard-wired SPI at 8 MHz.

Q. Why not raise the SPI clock?

Because the floor is per-call software cost, not bit clocking. Each busio call carries about 90 µs of fixed cost against roughly 1 µs per byte, so a few-byte register operation is already dominated by overhead at 8 MHz. Raising it produced no measurable change.

Q. Why can register operations not share one transaction?

The W5500's Variable Data Length Mode interprets the address and control bytes once per CS frame. From the second operation in a shared block onwards, the bytes are not a register access but data written into the TX buffer. Every call still returns successfully and only the packets go missing, which is why SNTX_WR was read directly to show the pointer had not advanced.

Q. How does each board get its own MAC?

By reading microcontroller.cpu.uid and undoing the per-byte nibble swap, which recovers the ESP32's factory address. Because the swap can set the low bit of the first byte, & 0xFE is needed to keep the address unicast. The W5500 is a MAC-less controller, so the host has to supply one.

Q. Can it be reused?

There is no licence file. The method and the numbers can be read, but reusing the code needs the author's confirmation.


Original Link: https://github.com/stanelie/gaytencoder

Measurements: FINDINGS.md · Benchmark: bench_spi.py · Send-path diagnostic: diag_send.py

Comments

Similar projects you might like

Comments