MicroPython firmware for the Pixel Pump β a vacuum pick-and-place tool for PCB assembly.
It runs on an RP2040 (Raspberry Pi Pico) with MicroPython v1.28. The pump has six illuminated buttons, three solenoid valves, a PWM-driven vacuum pump and two foot pedal inputs. The firmware is a small state machine: Lift, Drop and Reverse are the three operating modes, and long-pressing a button gets you into its settings.
Prebuilt firmware is on the releases page.
If you just want a working pump, grab firmware.uf2 from there and skip to Flashing.
Every build produces two UF2 files. The difference matters:
firmware.uf2
# Flash this for regular use of the pump.
# It is MicroPython with the Pixel Pump firmware frozen into the image.
# It ignores any main.py you copy onto the device and runs the frozen one instead.
firmware-blank.uf2
# Plain MicroPython, without the Pixel Pump firmware frozen in.
# Flash this to develop: now you can copy your own .py files and have them run on boot.
So: firmware.uf2 to use the pump, firmware-blank.uf2 to hack on it.
With a working pump, long-press Lift to enter brightness settings, then long-press Drop. All buttons turn white and the pump reboots into the bootloader. Power cycle to get back out.
If the pump isn't running or has no firmware on it, there's a hardware bootloader switch. Looking at the pump from the front, there are two small holes on the left side β the one towards the back exposes the switch. Reach in with something thin (one of the nozzles that came with the pump works) until you feel it click, hold it, and power the pump on.
You can also send bootloader over the serial interface.
In bootloader mode the pump shows up as a mass storage device. Copy the UF2 onto it and wait for the reboot. Once it restarts it should no longer appear as a drive β it has left the bootloader.
Flash firmware-blank.uf2 first, then use mpremote
to get your code onto the device.
uv tool install mpremote # or: pip3 install mpremoteMounting is the fastest loop β the device runs the files straight off your machine, so there's no
copy step at all. Add this to ~/.config/mpremote/config.py:
commands = { "debug": ["mount", "./src", "exec", "import main"] }Then from the repo root:
mpremote debugEdit a file, Ctrl-C, run it again. Note that the pump writes its settings.json to whatever
filesystem it's running from, so while mounted it lands in src/ on your machine. That path is
gitignored.
If you'd rather have the code live on the device:
mpremote cp -r src/pixel_pump : # the whole package
mpremote cp src/main.py : # and the entry point
mpremote ls # see what's on the device
mpremote rm :main.py # remove a file
mpremote reset # rebootIf you have more than one board attached, pick the port explicitly:
mpremote devs # list attached devices
mpremote connect /dev/tty.usbmodem2201 lsSay we want the button LEDs to render at 5 FPS instead of 30, just to see what happens.
The main loop lives at the bottom of src/pixel_pump/pixel_pump.py (src/main.py is a one-line
import). Find the render throttle and change the delay from 33 ms to 200 ms:
# Render the UI at 5 FPS.
if utime.ticks_ms() - rendered_at > 200:
renderer.flush_frame_buffer()
rendered_at = utime.ticks_ms()Run mpremote debug again and the buttons will animate in visible steps. Congratulations, that's
your first Pixel Pump hack π
Flash firmware.uf2 from the releases page
whenever you want to get back to a known-good pump.
The firmware polls USB stdin for newline-terminated, colon-separated commands. Open a serial
terminal (mpremote repl) and type them β the running firmware consumes stdin, so they reach the
command parser directly.
| Command | Effect |
|---|---|
bootloader |
Reboot into the UF2 bootloader |
version:info |
Prints tag,branch,commit_hash,timestamp |
reset:soft / reset:hard |
Exit the program / hard reset the MCU |
settings:dump |
Print all settings as JSON |
settings:persist:<json> |
Overwrite all settings, then hard reset |
settings:reset |
Restore defaults, then hard reset |
settings:set_brightness:<float> |
Global LED brightness (clamped 0.35β0.8) |
settings:set_mode:lift|drop|reverse |
Switch operating mode |
settings:set_power_mode:high|low |
Switch power mode |
settings:set_low_power_setting:<0-100> |
Low mode motor duty, in percent |
settings:set_high_power_setting:<0-100> |
High mode motor duty, in percent |
settings:set_keyboard_enabled:0|1 |
Register the HID keyboard interface, or don't (takes effect on the next boot) |
settings:set_secondary_pedal_key:<hex> |
HID keycode sent when the second pedal is tapped |
settings:set_secondary_pedal_key_modifier:<hex> |
Modifier for the above |
settings:set_secondary_pedal_long_key:<hex> |
HID keycode for a long hold |
settings:set_secondary_pedal_long_key_modifier:<hex> |
Modifier for the above |
Keycodes are HID scancodes β see the scancode table.
The pump enumerates as a keyboard so the second foot pedal can type. If you don't use that pedal,
macOS' Keyboard Setup Assistant popping up on a fresh machine is pure nuisance, and
settings:set_keyboard_enabled:0 is the way out β it stops the keyboard interface being registered
at all, which is the only thing that reliably suppresses the dialog.
It behaves a little differently from the other settings commands, in three ways worth knowing:
- It echoes
keyboard_enabled:0orkeyboard_enabled:1back at you, normalised rather than parroted, so any nonzero number reads back as1. No othersettings:set_*answers. - It does not reboot the pump, unlike
settings:persistandsettings:reset. Nothing changes until the nextreset:hardor power cycle, so you can write several settings and reboot once. - Writing the whole settings dict back through
settings:persistwithout the key re-enables the keyboard, because the missing key gets filled in from the defaults. Worth remembering if the interface reappears and you can't see why.
Everything else survives with the keyboard off: the vendor interface, the mapping table and the serial port all work as normal, and the second pedal still reports its presses to a host β it just doesn't type.
Besides the serial port, the pump enumerates as a composite USB device: a plain HID keyboard, plus a
vendor HID interface on usage page 0xFF00 that a host application uses to drive the pump.
docs/usb-communication.md is the full spec; the short version:
- The device sends a heartbeat every 500 ms carrying its model id and firmware version. A host counts as connected only while it keeps writing back β the pump falls back to standalone behaviour 1200 ms after the host goes quiet.
- While a host is connected, every control publishes its gestures (press, release, tap, hold, long hold) as event frames, whatever else that control also does locally.
- A host can read and rewrite the mapping table: which action each control and gesture triggers, with a separate column for standalone and connected use. Writes land in RAM until committed to flash. Out of the box both columns are classic Pixel Pump behaviour, so a pump with no host attached acts exactly like it always did.
If a host maps a button to FORWARD β meaning "just tell me, don't act" β that button lights up
while connected, so you can see at a glance which buttons the host owns. The host picks the look
along with the mapping: FORWARD's parameter byte packs a colour and an animation (solid or
breathing), and a button whose parameter is left at zero glows purple, the way it always did. The
pump owns brightness, so your global brightness setting still wins.
Should you ever end up with a table that forwards everything and no host to undo it, hold Lift + Drop while powering on for three seconds: the LEDs flash white and the table is back to defaults.
tools/pump_check.py runs the whole thing against a real pump. It's one entry point over five
groups, and with no arguments it runs them all in an order that leaves the pump usable:
DYLD_LIBRARY_PATH=/opt/homebrew/lib uv run --with hid --with pyserial python tools/pump_check.py| Group | What it checks |
|---|---|
static |
The checker's expectations still match the firmware source β needs no pump, and CI runs it |
wire |
Control ids, the full gesture set, and the heartbeat's model id |
mapping |
The mapping table: reads, writes, slots, flash persistence, factory reset |
keyboard |
keyboard_enabled β enumeration with and without the keyboard interface |
identity |
GET_VERSION, GET_INFO, and both ENTER_BOOTLOADER magics |
Name groups to narrow it down (pump_check.py mapping keyboard), and pass --auto to skip
everything that needs someone standing at the pump. --list prints the groups.
Anything that can't be undone by itself asks first: the identity group offers to reboot the pump
into the bootloader, which is the only way to test that command for real, and the mapping group
offers the factory-reset gesture, which wants you to power-cycle the pump with two buttons held. Say
no to either and it's skipped. The keyboard group reboots the pump a couple of times over the
serial port, but waits it back onto the bus itself and restores the setting it found.
If opening the vendor interface fails with exclusive access and device already open, the usual
cause is Board Factory's daemon holding it β quit Board Factory, or check pgrep -fl pixel-pump-daemon. It reads like a permissions problem and generally isn't one.
There's no Makefile here. A build is the MicroPython rp2 port compiled against the board definition
in boards/PIXEL_PUMP/, so you need a MicroPython checkout and an Arm bare-metal toolchain. It all
runs natively β no Docker, no containers.
brew install cmake
brew install --cask gcc-arm-embedded # Arm's official toolchain β includes newlibTake the cask, not the
arm-none-eabi-gccformula. The formula ships a compiler with no newlib, and the pico-sdk build dies on a missingnosys.specs.
Clone it wherever you like β the examples below put it next to this repo. Match the version CI pins, or you're testing a different firmware than the one that ships:
git clone --depth 1 --branch v1.28.0 https://github.com/micropython/micropython.git
cd micropython
make -C mpy-cross # bytecode compiler, needed to freeze src/
make -C ports/rp2 submodules # pico-sdk, tinyusb, micropython-libBOARD_DIR has to be an absolute path, so point a variable at your checkout of this repo and stay
in the micropython/ directory:
export PP=/absolute/path/to/pixel-pump-firmware
# Optional β stamps real git metadata into version.py. Skip it and you get "local" placeholders.
python3 $PP/tools/generateVersionFile.py --output $PP/src/pixel_pump/version.py --repo $PP
# firmware-blank.uf2 β MicroPython and the USB stack, without src/
make -C ports/rp2 BOARD_DIR=$PP/boards/PIXEL_PUMP BOARD_VARIANT=EMPTY -j8
# firmware.uf2 β the same, with src/ frozen in
make -C ports/rp2 BOARD_DIR=$PP/boards/PIXEL_PUMP -j8Each variant gets its own build directory:
ports/rp2/build-PIXEL_PUMP-EMPTY/firmware.uf2 β this is firmware-blank.uf2
ports/rp2/build-PIXEL_PUMP/firmware.uf2 β this is firmware.uf2
What ends up frozen is decided by the manifests in boards/PIXEL_PUMP/:
| Manifest | Frozen content |
|---|---|
manifest_shared.py |
The port manifest, plus micropython-lib's usb-device, usb-device-hid and usb-device-keyboard |
manifest_empty.py |
Shared only β this is the blank image, and why import usb.device works while you're mounting src/ |
manifest.py |
Shared plus src/ β this is the shipping image |
Run this whenever you add frozen code:
$PP/tools/checkFirmwareSize.sh \
ports/rp2/build-PIXEL_PUMP-EMPTY/firmware.bin \
ports/rp2/build-PIXEL_PUMP/firmware.binThe pump's 2 MB of flash is split in two: 1408 KiB of littlefs at the top, where settings.json
lives, leaving 640 KiB for firmware. Nothing in the build enforces that split. The linker is
handed the whole 2 MB, so an image that outgrows 640 KiB links without a word of complaint and then
overwrites the filesystem the first time it boots. The boundary can't be moved either β that would
wipe the settings of every pump already in the field. This check is the only thing guarding it, and
CI runs it as a hard failure.
The workflows do exactly the above on an Ubuntu runner:
| Workflow | Trigger | Result |
|---|---|---|
pixel_pump_build.yml |
push to main, PRs into main, manual |
both UF2s as a downloadable run artifact |
pixel_pump_main.yml |
v* tag |
draft release |
pixel_pump_publish.yml |
publishing a release | uploads the UF2s to object storage, then the manifest to the website feed |
pixel_pump_build.yml is how you get a testable image without cutting a tag. Its artifact β a zip of
firmware.uf2 and firmware-blank.uf2, kept 30 days β hangs off the run's summary page. Nothing
consumes it; it's there for you to flash. Because the build is untagged, git describe returns
something like v2.0.0-3-gabc1234 and version.py lands dev = True, so the pump reports itself as
a dev build over USB and can't be mistaken for a release. Use Run workflow on the Actions tab to
build any branch on demand.
Both pixel_pump_build.yml and pixel_pump_main.yml run tools/pump_check.py static before
building. It needs no pump and no dependencies β it reads src/ and fails if the default mapping
table has drifted from what the hardware checks expect, so that turns up on a push rather than the
next time someone plugs a pump in. Both also run checkFirmwareSize.sh as a hard failure. You still
build natively as you work; CI is the backstop, not the first place you'd hit either check.
Releases are a two-step gesture, and the gate is yours: a v* tag builds and leaves a draft, so
nothing is public until you review it and hit Publish. That click is what fires pixel_pump_publish.yml,
which uploads the versioned UF2s to the artifact bucket and then POSTs the manifest alone to
robins-tools.com/downloads/pixel-pump-firmware/ β the feed Board Factory reads to offer a firmware
update. Only firmware.uf2 is listed in that manifest; firmware-blank.uf2 is a development image and
reaches no client. Tag strictly as vMAJOR.MINOR.PATCH: the pump reports exactly three numbers over USB,
so anything else is rejected rather than published.
Publishing therefore needs the bucket's write keys alongside the ingest token β secrets
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, plus RELEASE_STORE_ENDPOINT / RELEASE_STORE_BUCKET
as either repository variables or secrets. The workflow asserts all four up front and names the
missing one.
src/main.py Entry point β imports and starts the firmware
src/pixel_pump/
pixel_pump.py Pin setup, object graph, boot sequence, main loop
pixel_pump_state_machine.py Holds the hardware and the current state
states/ One file per mode (lift, drop, reverse, settings, bootloader)
controls/ Button and GPIO event handling
enums/ Colors, brightness levels, power modes
ui_renderer.py WS2812 driver (PIO) and frame buffer
motor.py, valve.py Pump and solenoid control
usb/ Vendor HID stack β protocol frames, event publishing, keyboard
mapping.py Which control and gesture does what, and the host-writable table
communication_manager.py Serial command parser
settings_manager.py settings.json persistence
boot_sequence.py Startup LED sweep and valve clicks
version.py Git metadata β regenerated in CI, "local" placeholders in git
boards/PIXEL_PUMP/ MicroPython board definition, pin names and freeze manifests
docs/usb-communication.md USB protocol spec (canonical copy lives in the Pixel Pump 2 repo)
tools/generateVersionFile.py Writes version.py from git metadata (runs in CI)
tools/checkFirmwareSize.sh Fails if an image would overrun the littlefs partition
tools/pump_check.py Runs the hardware checks (see Checking a pump, above)
tools/pumpcheck/ Those checks, one module per feature area
There's no test suite and no linter. Testing is done by hand, on hardware, with
tools/pump_check.py β the exception being its static group, which reads src/ rather than a pump
and runs in CI on every build.
Fork the repo, work the way described above, and open a pull request against main β the only
long-lived branch. Every push is built and checked; releases are tagged v* off main, reviewed as
a draft, and published by hand.
Happy hacking!