Building the OED Dongle

A USB stick that never leaves the engine monitor and hands its logs to your phone

ESP32-S3 Arduino BLE WiFi SwiftUI Avionics
OED dongle icon: a three-blade propeller with a gear hub and radio arcs
The app icon. The propeller also spins on the dongle's tiny screen at power-up.

The idea

My plane has a JPI EDM-730 engine monitor. It records every flight, and getting that data out means carrying a thumb drive to the airport, plugging it into the front of the instrument, choosing DUMP NEW, and later walking the drive to a computer to upload it to Open Engine Data. I do that maybe one flight in five.

I wanted a stick that stays in the port. The EDM sees a thumb drive and writes to it as always. Then I press a button, and the stick sends the new files to my phone, which uploads them. No walking a drive around.

The constraints shaped everything. The stick runs on port power only, because the port is only live with the master switch on. It has to be a plain mass storage device to the EDM, because I cannot change the EDM. It has to be small enough to leave in a panel. For the prototype I used a LilyGo T-Dongle-S3: an ESP32-S3 in a USB-stick shell with a microSD slot, a 160 by 80 screen, and one button. The screen turned out to be the most useful debugging tool in the project, because the firmware runs the USB port as pure mass storage and there is no serial console.

One card, one owner at a time

A FAT file system cannot have two writers. The EDM mounts the card over USB and caches what it read. If the ESP32 mounted the same card at the same time, one side would corrupt the other. So the firmware is a two-state machine. In DRIVE, the host owns the card and the ESP32 never touches the file system. In SYNC, the USB medium reads as absent, the ESP32 mounts the card read only, and the radio comes on.

The handover signal is the host's eject. When the EDM finishes a dump it sends the USB stop-unit command, and the firmware answers by reporting no medium from then on. A button press flips it back. On a Mac this took a few tries to get right, because macOS re-probes a device after an eject, and if the device still reports a medium, macOS mounts it again.

Unmount is not eject

Disk Utility's Unmount button detaches the file system and sends nothing over USB. Only a Finder eject or diskutil eject sends the stop-unit command the firmware waits for. I spent twenty minutes staring at a dongle that said IDLE before I worked that out.

At the airport this design got a second reason to exist. The EDM wipes any drive that is present while the instrument boots. So the dongle now powers on with no medium at all and hands the card over on the button press, once the EDM is up.

The 264MB card that was 15GB

My first test card was an old Raspberry Pi card. The dongle showed it to the Mac as a 264MB disk with a partition named boot. The card is 16GB. The Arduino SD_MMC wrapper mounts the first FAT partition it finds on the ESP32 side and reports that partition's size when you ask for the sector count. The Pi image has a 260MB boot partition first, so the wrapper told USB the card was 260MB.

I dropped the wrapper and drove the card with the raw ESP-IDF sdmmc API. That also fixed a speed problem I had not noticed yet, because the wrapper reads and writes one sector per command, and the raw driver does multi-sector transfers. USB throughput through the dongle went to about 790KB/s reading and 575KB/s writing, which is near the ceiling for the ESP32-S3's full-speed USB.

A 125GB FAT32 volume takes 20 seconds to mount

The card I own is 128GB. Its FAT32 allocation table is 15MB, and after any unclean removal macOS reads all of it over the dongle's 600KB/s link before mounting. The EDM writes at most 2MB per dump, so I shrank the volume to a 4GB partition with 32KB clusters. The table dropped to 512KB and the mount to a second or two. The firmware also reports the partition size as the drive size, so the EDM never hears the number 125GB.

Bluetooth tops out at 25KB/s

The first phone path was Bluetooth Low Energy. The dongle serves a small GATT service: a status characteristic, a command characteristic that takes JSON, and a data characteristic that streams file chunks with a four-byte offset in front of each one. The app asks for a 64KB window, checks the offsets are contiguous, and asks for the next. A gap means a lost notification, and the app re-requests from the last good byte.

A 200KB file took eight seconds. A 2MB file, which is the EDM's maximum dump size, took over a minute, and the dongle's screen showed the window counter racing to 100 percent and starting over. The firmware was sending notifications with a fixed 5ms sleep between them instead of real flow control, so when the radio buffers filled, notifications dropped. Each drop made the app discard the rest of the window while the dongle streamed it into the void.

I replaced the sleep with the NimBLE call that fails when the stack is out of buffers, and the firmware now waits on that instead. The app aborts a broken window the moment it sees a gap. That removed the drops and the 2MB file still took a minute, because the honest number for BLE between this stack and an iPhone is about 25KB/s. I tried one-buffer chunks, a 15ms connection interval, and the 2M PHY. None of it moved the needle enough to matter.

So the bulk path is WiFi. The phone asks over BLE for a network, the dongle starts an access point with a random password and hands the credentials back over BLE, the app joins with one system prompt and pulls the files over plain HTTP, then leaves and tells the dongle to turn the radio off. BLE stays as the control channel and the per-file fallback. The two radios share one antenna and one chip, and they coexist.

Chunked encoding ate my Content-Length

The HTTP server sent file bodies with the ESP-IDF chunked helper and also set a Content-Length header. The helper adds Transfer-Encoding: chunked, clients follow that and ignore the length, and the iOS download task reported an unknown total, so the progress bar had nothing to show. The fix was a hand-written header and raw socket sends for the body.

Chasing a reboot loop with one word on a screen

Once the dongle powered on in SYNC instead of DRIVE, it started rebooting in a loop: propeller animation, header line, reset. The same firmware ran fine when I entered SYNC with the button. With no serial port, I added the reset reason to the header line at boot. The chip records why it last reset, and one word told me it was the task watchdog. The main loop had stalled for 30 seconds.

The difference between boot-time SYNC and button SYNC was four seconds. During the animation the Mac had already mounted the card, and the firmware then pulled the medium and started mounting the card itself while the host was still hammering the port. Booting with no medium at all fixed the stall.

The next crash was a panic, so I added a breadcrumb: a short string in RTC memory that survives a reset, set at each risky step, shown on the header after a bad reset. It read PANIC @bleStop. Tearing the Bluetooth stack down right after WiFi had shut off trips the radio coexistence layer. The stack now stays up for the whole power-on, and DRIVE only stops advertising.

Put the diagnostics where you can see them

Reset reason, breadcrumb, USB state, read and write counters, MTU, free heap, and which SYNC step is running all live on the 160 by 80 screen. Every one of them paid for itself in the same evening. The alternative was a UART adapter on the QWIIC header I did not have with me at the airport.

The EDM will not talk to it

At the airport the port measured 4.9V and stayed powered after a dump finished, which means the sync window is the whole time the master is on. A stock thumb drive drew 44mA. The EDM rescans the port live, so hot-plugging works. All good news. Then I plugged in the dongle and the EDM ignored it. The screen said USB: init with zero reads: the EDM never sent the configure command, and never asked for a sector.

I worked through the descriptors on site with the Mac open on my lap. The Arduino default advertises a composite device class with a 500mA power request. I tried class zero at 100mA, bus-powered, like a thumb drive. Same result. I read the shipped SanDisk stick's vendor ID, product ID, strings and serial off the Mac and had the dongle present those exactly. Same result. The card layout cannot matter yet, because the EDM stops before it reads anything.

Two theories remain. Every thumb drive runs at high speed, 480Mbps, and the ESP32-S3 can only run at full speed, 12Mbps. A host that has only ever seen high-speed devices may not handle a full-speed one, and a powered hub in between would prove it, because a hub speaks high speed to the EDM and full speed to the dongle. The second theory is the device qualifier: a device that claims USB 2.0 gets asked for a descriptor only high-speed-capable devices provide, the dongle stalls the request, and a strict old host may give up there. The firmware now declares USB 1.1, which a full-speed device is, and the Mac still mounts it. The dongle also shows whether the host sent any control request at all, so the next reading at the EDM says whether it never noticed the attach or noticed and quit.

If the EDM never budges: stop emulating a drive

The fallback design puts a one-dollar USB card-reader chip on the board, the same silicon class as every thumb drive, with a bus switch on the card's SD lines. The reader owns the card in DRIVE, the ESP32 takes it in SYNC, and the ESP32 never runs a USB device stack at all. That is how the old WiFi SD cards worked. Everything else built so far carries over.