The bet
The ar-tur posts so far have been a motor and a magnet: a salvaged floppy-drive spindle coaxed into open-loop rotation, then an AS5600 encoder closing the loop so it tracked a target. The plan from there is a second wheel, a sensor, a balance loop, and one day a learned policy. This note is a detour off that line. Same goal, a two-wheel robot that stays upright, but I stopped to build the robot's nervous system first, in Elixir.
I wanted something shaped like ROS 2, the Robot Operating System, but for the BEAM. Not ROS 2 itself. A small, Elixir-native way to connect a robot's brain to the microcontrollers that read its sensors and drive its motors. There is a young ecosystem for this, BeamBots, and it gives you the brain in Elixir. What it does not reach is the silicon at the edge. That gap is what I wanted to close.
Why Elixir for a robot? Because the runtime already is what a robot needs. A robot is a tree of parts that can fail on their own. A gripper jams, a sensor browns out, a motor driver faults. You want each failure isolated and restarted without taking the whole machine down. On the BEAM (Erlang's virtual machine, the runtime Elixir compiles to) you do not build that; it is already there. Lightweight processes, supervision trees, fault isolation. The shape of a supervision tree and the shape of a robot are the same shape. That match is the whole reason I keep betting on this language here.
Why the work has to split
A robot has two kinds of work, and they want different machines.
Some work is heavy and slow. A balance controller. Path planning. Later, a learned policy that maps what the robot senses to what it should do. This work wants CPU, room to think, and a runtime that keeps it alive when a part fails. That is the host: a Raspberry Pi running Elixir.
Other work is small and fast, and it has to happen right now. Commutate a motor. Read an encoder. Denoise a sensor at high frequency. This work has hard real-time deadlines measured in microseconds, and it has to sit physically next to the hardware. A Raspberry Pi running Linux is the wrong machine for this. It has no hard real-time guarantee, and it is one long wire away from the sensor. A microcontroller is the right machine: a small chip, doing one well-known job, with tight timing.
So I split the robot along that line. Elixir on the host for the thinking, where it gets fault tolerance and supervision for free. C on the microcontrollers at the edge, where timing is critical and the chip is cheap. Each layer gets the tool that fits it.
The two sides can also run at different rates. The sensor samples and denoises fast on the microcontroller. The control loop runs slower on the host, and it can be heavy (even a neural-network policy) because it does not have to keep pace with the raw sensor.
The microcontrollers do not all hang off one wire, either. They form a small tree. The host talks to the first chip over UART. Below that, chips can share a CAN bus, or chain over more UART links. Different layers of communication, chosen to fit the distance and the number of devices. The next sections are about the shape of that tree and the bytes that flow through it.
The hub: one shape, repeated
The whole tree is one idea, called a hub. A hub is a single microcontroller node that does any mix of three jobs:
- sense — read a device and publish a typed value upward;
- act — drive a local actuator behind a safety floor;
- route — forward frames for its child hubs, without caring what they mean.
Because every hub is the same shape, they nest into a tree of any depth. There is no separate gateway type, no leaf type, no router type. A hub with children is a branch. A hub with none is a leaf. The host sits above the tree and reaches every node through the root hub, the one chip that holds the link up to the host.
You declare that tree. You do not wire it up by hand. The whole topology for the balancing bot is two lines:
use BB, extensions: [BBMCUHub.Dsl]
hubs do
# The root (parent: :host) owns the host UART; the wheels leaf hangs off it.
hub(:blaster, SegbyV1.Hubs.Blaster, node: 0x02, parent: :host)
hub(:wheels, SegbyV1.Hubs.Wheels, node: 0x05, parent: :blaster, uplink: :uart)
end
Each hub states its parent and its uplink transport. UART for a point-to-point link, CAN for a shared bus. From those pointers the library builds the route table and any UART-to-CAN bridging. A compile-time check confirms the tree is well formed: one root, every parent resolves, no cycles. A broken tree fails to compile. It never reaches the bus.
One byte on the wire
Now the part I most wanted to get right: the bytes between the host and the chips.
Every message is a small binary frame. It carries which node and port it is for, a sequence number, an optional device timestamp, the payload, and a checksum:
That body is wrapped for the wire with two plain, old techniques. A CRC-16 checksum catches corruption. Then COBS framing (Consistent Overhead Byte Stuffing) removes every zero byte from the data and marks the end of each frame with a single zero. So the full shape on the wire is:
COBS( body ‖ CRC-16(body) ) ‖ 0x00
A receiver reads until it sees a zero, un-stuffs, checks the CRC, and either accepts the frame or drops it. All multi-byte numbers are big-endian, fixed, never negotiated. Nothing here is clever. That is on purpose. The wire path is small enough to read in full, and simple enough to test exhaustively.
CAN adds one wrinkle. A classic CAN frame carries only 8 data bytes, and some messages are larger (the IMU pose is 40 bytes). So a large body is split across several CAN frames. The trick: the fragment bookkeeping rides in the spare bits of the 29-bit CAN id, not in the data. That keeps the data bytes byte-identical to the UART path, so the same message survives both transports unchanged. If any fragment is lost or arrives out of order, the whole body is dropped, never applied in part.
One model, and everything else derived
That binary frame lives in two places at once. C on the chip has to encode and decode it. Elixir on the host has to do the same. If the two ever disagree by a single byte, the robot sees garbage. Two hand-written codecs kept in sync by hand are exactly the kind of quiet, ongoing mistake I wanted to design out.
So there is one source of truth. You describe each value once, and the library derives the rest. A value-type is that description. It says what bytes go on the wire, and how those bytes become a normal Elixir message and back:
use BBMCUHub.ValueType
# What goes on the wire: one 32-bit float, the wheel speed in rad/s.
layout(rad_s: :f32)
# lift: turn the raw wire field into a normal BeamBots sensor message.
def lift(%{rad_s: speed}) do
%BB.Message.Sensor.JointState{velocities: [speed]}
end
# unlift: the exact inverse, message back to the raw wire field.
def unlift(%BB.Message.Sensor.JointState{velocities: [speed | _]}) do
%{rad_s: speed}
end
From that one description, a generator writes three things: the C headers for the
firmware, the per-hub glue that wires each port to the framing and the floor, and a set
of parity vectors. The parity vectors are rows of {payload, framed bytes, CRC}
that both the Elixir test suite and a small C test program check. If the two codecs
ever produce different bytes, the tests fail. And a drift test re-runs the generator in
CI; if the committed files no longer match, the build fails. The two ends of the
protocol cannot drift apart. The build enforces it, so it does not depend on me
remembering to.
This is where the reusable and the specific separate cleanly. The library owns the mechanical, safety-critical parts: the framing, the checksum, the routing, the floor, the generated glue. You write only two small things: a value-type like the one above, and one C function per port that talks to your actual device. The generated code calls your function with a value the floor has already approved; your function just applies it to the hardware.
For a motor, that function is three lines of the motor library. It converts the commanded effort into the driver's units, runs one control step, and applies it:
extern "C" void wheels_motor_left_drive(float effort) {
left_motor.target = effort_to_voltage(effort); // command → driver units
left_motor.loopFOC(); // one field-oriented-control step
left_motor.move(); // apply it to the coils
}
That is the only C you write for this port. The everyday loop, when you change a port:
edit the value-type, run mix wire.gen, commit. The firmware and host stay in step
because they both came from the same model.
It runs: sim, bench, and the floor
The point of an abstraction is that it carries a real machine. segby_v1 is meant to
be a two-wheel self-balancing bot: a Raspberry Pi Zero 2 W as the host, an ESP32 as the
root hub reading an MPU-9250 inertial measurement unit (IMU), and an MKS Dual-FOC board
as the leaf hub driving two brushless motors. The balance loop is ordinary: a
complementary filter on the pose, a proportional-integral-derivative (PID) controller
for the pitch, and an inner velocity loop, all on the host in Elixir.
On the bench, that tree is real hardware today, even though a standing chassis is not. The host, the two hubs, and the IMU are wired up and talking over the link.
The bench found three bugs that only real silicon shows. A watchdog boot-loop, from arming the watchdog before setup finished. The host UART wired to the wrong pins, so a healthy host showed every counter at zero. And a missing down-relay, so status flowed up to the Pi but commands never reached the wheels. None of these live in the library. They are the ordinary cost of working on real hardware.
I did not want the balancing work blocked on a finished chassis. So I closed the loop against a physics simulation: real motor commands going into a simulated body, simulated sensor readings coming back, at the same rates the real robot would use.
This works, because the transport is the one and only hardware boundary in the design. Everything above it (the framing, the freshness checks, the floor, the views, the dashboard) is the real shipped code, and it cannot tell whether the robot is real. Swap the transport for one that plays the hub tree against a MuJoCo model, and the whole stack drives a bot with a 3-D viewer. In the sim it balances, drives, and turns. The controller I tune in the sim is the same one that will run on the board.
The safety story lives on the chip, and it is the smallest, most important piece of C in the whole thing. Each actuator hub runs a dead-man's switch against its own command counter, on its own clock. It watches one thing: is the command counter still advancing inside a small time window? If commands go silent (a host crash, a cut wire, a dead parent, anything) the next tick drives the motor to a safe value and latches it off. It needs no incoming message to fire, so it survives the whole tree above it going away. And it is born disarmed: a motor boots at its safe value and refuses to move until it sees a fresh command since its own boot. A stale frame in a buffer can never spin it.
uint8_t floor_tick(Floor *f, uint32_t now_ms, uint8_t *out) {
/* Born-disarmed: the first seq only records a baseline; trust begins on a
* later, different seq — so a stale buffered command cannot start us at boot. */
if (!f->have_baseline) {
f->have_baseline = true;
f->last_seq = f->cmd_seq;
} else if (f->cmd_seq != f->last_seq) {
f->last_seq = f->cmd_seq;
f->seen_advance = true;
f->last_advance_ms = now_ms;
}
bool fresh =
f->seen_advance && (uint32_t)(now_ms - f->last_advance_ms) < f->window_ms;
if (!fresh) {
f->armed = false; /* silence, or not yet earned → safe, latched */
memcpy(out, f->safe, f->n);
return f->n;
}
f->armed = true; /* a fresh, in-window command earns motion */
memcpy(out, f->target, f->n);
return f->n;
}
Driving the bot in the sim is also how I found an important bug. I pressed disarm, and the wheels kept turning. The reason is subtle. The floor disarms on command silence, but a self-balancing controller can never be silent. It has to keep commanding every tick or it falls over. So it kept advancing the counter the floor watches, and the dead-man never fired. The safety mechanism and the balance controller were fighting each other.
The fix is a rule: a control loop must check the safety state, and stop commanding when it is disarmed. I would not have caught this on the bench for a while, because it only shows up with a live controller running. The sim let me see it fail with no real wheels to hurt anyone.
Where the tree stops, and where this is going
I want to be honest about the limit, because it is also the interesting part. The supervision tree mirrors the robot down to the UART, and then it stops. The actuators are C on microcontrollers, outside the tree. You cannot avoid that. Some devices need their own chip, and the BEAM does not run on a motor driver. So "a failing gripper does not crash the system" is exactly the property you start to lose the moment you cross the wire.
The answer is not to pretend the tree reaches all the way down. It is to accept the boundary and handle it carefully. Above the wire, the runtime supervises by restarting. Below it, the chip supervises itself: the floor fails safe the moment the host goes quiet. The job of this library is to make that one crossing reliable.
That is also why this is not a ROS 2 replacement, and saying so is accuracy, not modesty. ROS 2 is a vast, mature ecosystem, and I am not competing with it. The real contrast is narrow. ROS 2's microcontroller arm assumes a host in the loop and no first-class on-chip dead-man; you assemble safety from watchdog patterns yourself. This library makes a born-disarmed, command-silence floor the default, on the chip, surviving host loss. These are two different choices about where safety should live.
And there is plenty it does not do yet. There is no device discovery; I assume the tree is known ahead of time. There is no quality-of-service machinery and no clock sync. It drives one bot, over UART, and that bot balances in simulation while its physical twin is still an early bench test. It is a small, safe-by-default layer, and a starting point.
There is also a direction I want to push the simulation. Today the sim runs the real Elixir stack, but the C firmware is not in the loop. I already run the real C floor against simulated time in the tests, so the next step is tempting: run the real firmware against a virtual motor. Let the actual field-oriented-control code drive a simulated motor with real parameters, and see how much of the edge I can test with no silicon at all. I do not know how far MuJoCo can be pushed this way. That is exactly why I want to try it.
Where it goes next is the reason the split matters. With the wire and the safety handled,
the host is free for the heavy, slow work: a learned policy. I have started a sibling
library for exactly that, bb_policy. A policy
is a function from what the robot senses to what it should do. The idea is that you train
it, export it to ONNX (the Open Neural Network Exchange format), and run it on the host, in
the same runtime as control, so a slow or crashed policy cannot take the robot down. The
plan is to train a balancing policy in the MuJoCo sim above, then deploy it on real hardware
through the same transport seam, with the on-chip floor underneath the whole time. That work
is early. But the shape is already here: think on the host, act at the edge, and a wire
between them you can trust.
References & Links
- BeamBotsBEAM robotics
- bb_policy (learned policies for BeamBots)Elixir
- MuJoCo physics engineDeepMind
- Spark DSLElixir
- COBS (Consistent Overhead Byte Stuffing)Reference