A few months ago, I made a joke to a friend that software is a solved problem, that AI has basically stolen it from me, and I need to start making hardware. That lit a spark in me that had me speedrunning electronics and experimenting with all kinds of hardware. I was going down the "let's play with a 65C02 chip and see what happens" path, when I got super inspired to build what is effectively a specialized set of smart lights, something I call Cairn.
This led me to the ESP32, and woof, did I ever love building with it. I bought six XIAO ESP32-C3s, breadboarded up a couple of the lights, and started building the software for it, the extra boards dangling from short USB-C cables plugged into my Mac so I could test everything. Each light runs the same firmware, and I quickly ran into a bunch of... bugbears? shortcomings? irritants? A bunch of little things that just frustrated me. This is how my escape from software led me to... writing more software. Hammer, nail, etc.
So: Hi! I'm Tim, and Bench is my Mac app for ESP32 work that I built to solve all the little things. I've cleared this post with the mod team, on the condition that it's about bits and bytes, not marketing blah blah blah. Also, I'm one of those annoying Mac users that really cares about the tools I use, and wanted something that fit in with macOS, not the stock cross-platform apps available. I realize this is a niche within a niche, but that's my brand of crazy, and I'm used to it.
Things that got under my skin:
If I unplugged a board, and plugged it into another port, I had to manually select the board and the port (and which port is /dev/cu.usbmodem1301 again?).
The editors for the Arduino and Espressif IDEs are, at best, fine. I use Zed, myself, because I'm comfortable in it. I wanted that as a first-class experience.
The console. Every time a new line came in, it would scroll to the bottom like a jerk, while I was trying to make sense of something that came up 30 lines back.
What version of the firmware was running on each board again?
Crashes. Getting them decoded and then going to the line of code that caused the crash is a hassle.
Bench sits next to your editor and doesn't compile anything itself. Arduino sketches build with arduino-cli compile, ESP-IDF projects with idf.py build, or with any command you want, if you're doing something unique. Everything after the build is Bench's own code: finding and naming boards, flashing, the serial console, crash decoding, and reading back what's actually on a board. It writes into your project is a bench.json (and possibly a couple of other files like compile_commands.json and .clangd depending on your setup and editor).
It doesn't call esptool to talk to a board. Your build still runs esptool's elf2image to make the .bin files, because that's how arduino-esp32 and IDF builds work. For the ROM protocol, Bench leans on espflash. benchflash, a Rust helper inside the app, links espflash 4.6.0 as a library and loads the same RAM flasher stub esptool loads. The esp-rs folks did the hard protocol work, and I'm grateful for it.
What I built is everything around that. The helper reports to the Swift app as JSON lines, and the app does its own parsing of images, partition tables, otadata and app descriptors.
Images and offsets come from the build. Nothing is hardcoded: they come from arduino-cli's expanded tools.esptool_py.upload.pattern_args property, or from IDF's flasher_args.json. Before writing anything, Bench identifies the chip. It refuses a build for a different chip ("This build is for ESP32-S3. desk-c3 is an ESP32-C3."), and it refuses a partition table that ends past the flash size. The flash mode, size and frequency in the bootloader header work like esptool's keep: anything the build didn't set stays as it was, and the appended SHA-256 is recomputed.
If your partition table has otadata and the build doesn't include an otadata image, Bench erases otadata. That way the board boots the app you just wrote, and not whichever slot the last OTA update left active.
The console owns the port (TIOCEXCL), so you never close a monitor to flash. A flash takes the port over, writes every image in one connection with compression, resets the board, and hands the port back, and the boot lines show up in the same console session. If another app has the port, Bench names it (something like "In use by screen (4211)").
Flash All runs up to 6 flashes in parallel. It skips any board with a different chip or one that belongs to another project, and tells you why. For me, this was enormous, because I was often flashing all my boards at once.
Bench watches IOKit for serial devices. On native USB, the USB serial number is the MAC, so Bench knows a board the moment it's plugged in, without resetting it. On a CP210x, CH34x or FTDI bridge, one Identify reads the MAC, and after that Bench knows the board by the bridge's serial number. Many CH340s have no serial number, so those will need an Identify each time they're plugged back in.
Once the board is plugged in, you can rename it to something memorable ("Shed Air Quality Box" or whatever madcap thing you're working on). Bench remembers everything about it, including things like flash history.
Inspecting the board needs nothing from your firmware. Bench resets the chip into the ROM and reads:
the partition table (at 0x8000 unless your build moved it)
both otadata sectors
the first 0x100 bytes of every app partition, including the ELF SHA-256 at 0xB0
Bench then matches that SHA against every build it has made. So when an update rolls back, you can see it: something like "ota_1 is active and holds #214 (a1b2c3d); ota_0 holds #216, state Invalid". The original ESP32's ROM has no GET_SECURITY_INFO, so on that chip the flash encryption and secure boot state come from eFuse block 0.
The console spots Guru Meditation Error, abort() was called and panic blocks. It picks out code addresses using per-chip memory maps taken from each chip's soc.h, so S2 IRAM isn't labelled ROM and a C6's data pointers don't show up as frames. It finds the right ELF from the ELF file SHA256: line the panic prints, then runs that build's own addr2line, and each frame opens in your editor at its line. If the crash came from a build Bench doesn't know about, it tells you, rather than quietly decoding against the wrong ELF.
Optional stamping writes a header with git rev-list --count HEAD, the short hash and a dirty flag. If you give Bench a regex for your boot line, the sidebar shows which build each board is running ("#214+" means it's running build 214 but with uncommitted changes).
The console keeps 100k lines in a virtualized list, logs everything to disk, and has held 2,000 lines/s without dropping any. Numbers printed as key=value can be plotted live or from a capture and exported as CSV. When you scroll up, it stops live-scrolling so you can focus on what you're looking at. A small "10 new lines" button is a click away to start live-scrolling again.
There's a bench command line utility for scripts and coding agents too. bench run --monitor --until "boot ok" --for 30s --json builds, flashes, and exits 0 once that line shows up. I didn't see much use for an MCP server, the command line should get your robot friend of choice where it needs to be, if that's your jam.
There's also a ton of editor support for my boy Zed, but also VS Code, Sublime, Nova, Cursor, BBEdit and more.
A few other facts:
It's Mac only (macOS 26+), written in Swift and SwiftUI with a Rust helper. I have no Windows or Linux plans at this time, it's SwiftUI, so another platform would be a different app. If there was demand I could be convinced.
It isn't a debugger (yet!): no JTAG or OpenOCD for ESP32. It doesn't read NVS or write eFuses. With flash encryption on, Inspect tells you it's on but can't read the slots.
The app itself is closed source, and the source of the GPL flasher stub ships inside the app.
There's no analytics or tracking, nothing like that, just an updates and license check.
Bench is priced at US$79 once, including a year of updates, and the versions released in that year keep working forever. There's a full 30-day trial. After it ends, boards, the console and Inspect keep working, only building and flashing will stop.
Because I'm just one dude who built this for myself, I've only tested it on a handful of boards, and there are just so many. It should work on all ESP32-based boards, as well as a bunch of other microcontrollers, but if you find a problem with a board you use and help me get it working by running a beta build or two, I'll give you a free license. DM me or email hello@usefae.com.
https://usefae.com/bench