This is the firmware that actually runs a creature. Of all the firmware targets, the controller is the one that matters — the others are bench tools; this is the code that’s alive inside every creature in my house, driving its servos and keeping an eye on its health.
It follows the same philosophy as the rest of the system: it doesn’t think. All the behavior, sequencing, and decision-making happens up on the Linux controller host. The firmware just receives position frames over a USB serial control protocol, moves the servos to match, reads the sensors, and reports everything back. It’s purely reactive, and that’s on purpose — it keeps the real-time path short and predictable.
The servo control loop
At the heart of the firmware is a critical ISR that fires every time the PWM counter wraps — at the servo frame rate, typically 50 Hz. On each wrap it looks up the next requested position for every servo channel and updates the PWM duty cycles. It has to be fast: any delay in that ISR shows up as jitter in the creature’s motion. So it does the absolute minimum — no blocking, no slow peripherals, just a memory lookup and a register write — and everything else is kept well clear of it.
The firmware also speaks Dynamixel Protocol 2.0 for XC330 smart servos, using sync-read and sync-write so it can pull position, load, temperature, and voltage from every servo — and set all their goal positions — in a single bus transaction each frame.
Knowing who it is
When the firmware boots, one of the first things it does is read the creature’s personality module — a small I2C EEPROM on the board, written by the I2C EEPROM Programmer. That chip holds the creature’s name, serial number, logging level, and USB VID/PID, and the firmware uses it to build the USB device name the Linux host sees. That’s how a creature shows up as itself under /dev/serial/by-id instead of a generic Pico. If there’s no module installed, it boots as “Unknown Creature” and carries on.
Watching itself
Because these creatures live in my house and I want to be able to walk away while one is powered up and moving, the firmware takes self-monitoring seriously:
- Power & temperature — it reads the onboard PAC1954 power monitors and MCP9808 temperature sensor, and if either crosses a configured threshold for more than a few seconds, it cuts power to the motors. (3D-printed PLA parts get soft when things run hot, so temperature is the sensor I care about most.)
- Per-motor power control — power isn’t applied to a motor until a valid configuration and a first frame have arrived from the host, and each motor can be switched independently in software.
- Watchdog health gate — a hardware watchdog coordinated with the FreeRTOS tasks, so a wedged task reboots the board rather than leaving a creature stuck mid-motion.
- Power-on hours odometer — a running tally of how long the board has been powered up, which is handy for keeping track of wear.
All those RGB LEDs
I cover everything I possibly can in RGB, and the firmware is no exception. WS2812 LEDs give constant visual feedback: a color-cycling LED shows the main event loop is ticking over, others change when USB messages arrive, and every servo has its own status LED that shifts color with its position. It makes bench debugging a lot more pleasant when you can just see what the board is doing.
Under the hood
It’s built on FreeRTOS, with each major subsystem — servo control, sensor monitoring, communication — running as its own task and talking to the others through message queues rather than shared variables. Critical tasks like servo control run at a higher priority than the monitoring tasks. It’s written in pure C11 against the Pico SDK, and targets my custom RP2350B board (hardware version 3), though it’ll happily run on an off-the-shelf RP2040/RP2350 dev board too if you turn the sensors off.
Building
cmake -B build .
cmake --build build --target controller-rp2350-arm-s-hw3
That produces controller-rp2350-arm-s-hw3.uf2. Flash it by holding BOOTSEL, plugging in the board, and copying the .uf2 onto the drive that appears.