Control Protocol

There are several control protocols in use:

The console talks to the server over gRPC. This is not a full time connection, it’s only used when making changes to the database, creating/editing animations, and controlling a creature live.

The server talks to the controller over DMX. There’s two forms of DMX that can be used interchangeably depending on the need / situation. The first is standard DMX over RS-485, and the second is the E1.31 protocol. E1.31 is DMX-over-UDP, and allows DMX frames to be sent over IP networks.

Finally, the controller talks to the microcontroller-based motor controller over a serial connection. It speaks commands that are ASCII strings. ASCII strings are used because it’s easier to debug. (I was inspired by how computers can talk to 3D printers in this method, using G-Code.) By using a standard CDC USB connection the controller software is able to run as a normal user on any Linux host (or macOS). I target Raspberry Pis mostly, but any generic Linux host with a USB port will work.

Why DMX?

DMX seems like an odd choice at first, but I think it fits the purpose very nicely. Here’s a few reasons:

  • RS-485 is super robust (as is wired Ethernet)
  • It’s well understood
  • Parts for DMX control systems are easy to find
  • Other accessories, like lights and smoke machines, are also able to be controlled via DMX!

DMX allows for eight bits per channel, but if I need more than eight bits channels can be combined together.

ASCII Protocol

The RP 2040 connects to the Linux host as a CDC device. (Serial port emulation.)

Controller ➡️ Pi Pico


CONFIG \t SERVO pp nnnn mmmm \t (repeats for each servo) \t CS ####

Sends the configuration for this session to the firmware, in response to an INIT message.

ThingWhat it means
SERVOThis is the parameters for a servo
ppThe output position of this servo (A0, A3, etc)
nnnnThe minimum number of microseconds that this servo is allowed to be set to
mmmmThe maximum number of microsecond that this server is allowed to be set to
CSChecksum of the line, excluding the checksum token itself.

This is quite critical to the operation of the firmware. Great care is given to make sure that a motor does not operate outside of it’s normal range to avoid damaging any parts of the creature.


PING \t ########## \ CS ####

Sends a ping to the firmware, which will respond with a PONG. The second token is a timestamp of format std::chrono::duration_cast(now.time_since_epoch()).count()); (C++ is gonna C++! 😅)

This is used to make sure the firmware is alive, and we keep metrics on how long it takes for it to respond. The firmware calls the tud_task() (TinyUSB) task every one millisecond, so it’s not going to be super fast, but is usually less than 2ms. Give a 50Hz duty cycle (20ms frames), this is just fine.

Messages are processed in the order they came in, so if the controller can get to a PING in less than the servo frequency, all is well.


POS \t pp vvvv \t pp vvvv \t pp vvvv \t CS ####

Sets the position of the motors.


“pp” denotes the “output position” on the controller board. These are in range A0 – A3, B0 – B3, and C0 – C3. The letter is the header on the controller card where the servo module is connected, and 0-3 indicates the number of the servo header on the module itself. Some of the modules have two outputs and some have four.


“vvvv” denotes the number of microseconds for PWM pulse for that frame. This is typically in the range of 1000 – 2500, but it varies wildly depending on the type of servo used.

CS ####

This block is a checksum of the control string not including the final token. (How would I be able to calculate the checksum for the thing I’m writing? 😅)

The checksum is defined in controller/src/controller/commands/ICommand.h.

 * Get the checksum of this message as a u16
u16 getChecksum() {
    u16 checksum = 0;

    // Calculate checksum
    for (char c : toMessage()) {
        checksum += c;

   return checksum;

Pi Pico ➡️ Controller

Initialization Request

INIT \t ######
####### is the firmware’s version number. The controller will refuse to run against a firmware version it doesn’t know, and the firmware will refuse to listen to an unknown version of the controller. They must be in lock step.

This message is the firmware requesting its configuration from the controller. It will not process any position messages until it has received a valid configuration from the server.

This message is sent via a timer that’s kicked off when the CDC connection on the firmware opens. It does nothing if the CDC connection isn’t open, because it’s pointless for it to do anything. When the CDC connection closes the firmware wipes out it’s configuration and waits for the CDC connection to re-open so it can re-request a configuration.

I do this so that the firmware’s configuration is completely dynamic. The config file for the creature is read by the controller at each startup and is the only source of truth. I want to be able to make changes to the config file and iterate rapidly changes to it.

Log Message

LOG \t time \t level \t message

A log message from the Pi Pico to the controller.

This is handled by the LogHandler on the Linux side, where the various log levels are mapped to the local log levels and the message is displayed.

    void LogHandler::handle(std::shared_ptr<Logger> logger, const std::vector<std::string> &tokens) {
         // 0       1       2       3
        // LOG \t time \t level \t message

        logger->trace("incoming log message");
        for(std::string token : tokens) {
            trace(" {}", token);
        if (tokens.size() < 4) {
            logger->error("Invalid number of tokens in log message: {}", tokens.size());

        auto level = tokens[2];
        auto message = tokens[3];

        if (level == FIRMWARE_LOGGING_VERBOSE) logger->trace("📟 {}", message);
        else if (level == FIRMWARE_LOGGING_DEBUG) logger->debug("📟 {}", message);
        else if (level == FIRMWARE_LOGGING_INFO) logger->info("📟 {}", message);
        else if (level == FIRMWARE_LOGGING_WARNING) logger->warn("📟 {}", message);
        else if (level == FIRMWARE_LOGGING_ERROR) logger->error("📟 {}", message);
        else if (level == FIRMWARE_LOGGING_FATAL) logger->critical("📟 {}", message);
        else logger->warn("Unknown logging level from firmware: {}, message: {}", level, message);


STATS \t HEAP_FREE %lu \t C_RECV %lu \t M_RECV %lu \t SENT %lu \t S_PARSE %lu \t F_PARSE %lu \t CHKFAIL %lu \t POS_PROC %lu \t PWM_WRAPS %lu

Internal housekeeping stats from the Pi Pico. These are sent every few seconds to the controller automatically.

HEAP_FREEAmount of free heap on the Pi Pico
C_RECVNumber of characters received off the wire
M_RECVNumber of messages received off the wire. (A message is considered an entire message that’s to be processed and not just each character.)
SENTNumber of messages sent to the controller
S_PARSENumber of messages from the controller that were successfully parsed
F_PARSENumber of messages that failed to parse
CHKFAILNumber of messages that were discarded due to a checksum mismatch
POS_PROCNumber of POS messages processed. (Position update)
PWM_WRAPSThe number of times the PWM counter has wrapped over. (ie, the number of times the ISR that services the servos has been called.)


PONG \t %lu

A response to a PING. Generated automatically when a PING comes in as part of the message handler. The value that’s returned is to_ms_since_boot(get_absolute_time()), which is usually ignored by the controller.


READY \t 1

This message tells the controller that the firmware has loaded its configuration and is ready to start receiving frames. If frames are sent before this message the firmware will drop them with an error message.