A robot's body language: driving a 96-LED ring from a Raspberry Pi
The hardware and ROS2 software behind Elliot's status ring — driving 96 WS2812Bs straight from a Pi's SPI with no microcontroller, a pure-function animation engine on a render thread, and the sudo that quietly cut the node off from the rest of ROS2.
Elliot is a robot I’m building in my spare time — a Kobuki base, a pair of Raspberry Pi 5s, a LIDAR, a camera, a speaker it talks through, and a 96-pixel ring of light around its crown that is, as far as anyone standing in front of it is concerned, its face.
The ring does something the voice can’t. Speech is serial and slow: you have to wait for it, and it’s gone the moment it finishes. Light is ambient and instant. Before Elliot says a word, the ring has already told you it heard you (a cyan pulse), that it’s thinking (a purple chase), that it’s about to speak (a green breath), or that something has gone sideways (red sparks). It’s the robot’s body language — the channel that runs underneath the conversation and never shuts up, and the part visitors react to first.
This post is the whole of how that ring works, down to the one genuinely nasty footgun I hit wiring it into the rest of the robot — the kind that throws no error, just leaves a node that has quietly stopped talking to everything else. It’s a hobby project, but none of the engineering is toy.
The hardware: 96 LEDs, one Pi, no microcontroller
The ring is a WS2812B — the addressable RGB LED that Adafruit brands “NeoPixel” — ninety-six of them in a circle. Each pixel is its own little controller. You talk to the whole strip over a single data wire, streaming 24 bits of color per pixel down the line; each pixel swallows its 24 bits and forwards the rest.
The catch is timing. WS2812B speaks a one-wire protocol at roughly 800 kHz where each bit is encoded as a pulse whose width carries the value — a longer high then short low is a 1, a short high then longer low is a 0 — with tolerances of ±150 ns on each pulse. Miss the timing and you get wrong colors, garbage, or nothing.
That timing is why the reflexive answer is “use a microcontroller.” An Arduino can bit-bang WS2812B precisely because it has nothing else to do; a general-purpose Linux box can’t be trusted to toggle a GPIO every few hundred nanoseconds without the scheduler wandering off mid-frame. My own build notes still list an Arduino Nano in the parts table — “driven by Arduino Nano via serial,” tagged need to buy.
I never bought it. There’s a better way that uses hardware the Pi already has on board: the SPI peripheral.
SPI is a clocked serial bus whose entire job is to shift bits out of the MOSI pin at a precise, hardware-driven rate with no CPU babysitting. Pick an SPI clock where a few SPI bits add up to the width of one WS2812B pulse, and you can encode the LED protocol as an SPI bitstream — each WS2812B “1” becomes a bit pattern like 1100, each “0” becomes 1000 — then let the SPI hardware clock it out with rock-steady timing while Linux does whatever it likes. That is exactly what Adafruit’s neopixel_spi library does: from Python you write colors, and underneath it builds the encoded buffer and hands it to the kernel’s SPI driver.
So the data path contains no microcontroller at all. It’s the Pi’s MOSI pin, one level shifter, and the ring:
Two details on that diagram matter more than they look.
The level shifter. The Pi’s GPIO — SPI MOSI included, on physical pin 19 — is 3.3V logic. WS2812B wants its data line at 5V levels, and at the edge of spec a 3.3V “high” is marginal: it works until it doesn’t, usually right after you’ve stopped watching. A logic level converter takes the 3.3V bitstream on its low side and re-drives it at 5V on the high side. A 220Ω resistor sits in series on the data line into the first pixel — standard practice to tame edge ringing and protect that first LED’s input. (The shifter itself is fed 3.3V on its low rail and 5V on its high rail; the diagram leaves that out to keep the signal path clean.)
The power. This is the detail that bites people. A single WS2812B can pull about 60mA at full white. Ninety-six of them is roughly 5.8A at 5V — call it 30 watts — which is wildly past anything a Raspberry Pi’s 5V pin can source. You do not power this ring from the Pi. The LEDs’ 5V comes from their own buck converter off the robot’s battery (the brass box in the diagram), and the Pi only ever touches the data line. The one wire that must be shared is ground: Pi, level shifter, LED ring, and buck converter all tie their grounds together, because the data signal is meaningless without a common reference. A floating ground here doesn’t fail cleanly — it flickers, shows wrong colors, and sends you hunting through software for a hardware problem.
In code, none of this is visible. After sudo pip3 install adafruit-blinka adafruit-circuitpython-neopixel-spi, the entire ring is four lines:
import board, busio, neopixel_spi
spi = busio.SPI(board.SCLK, MOSI=board.MOSI)
pixels = neopixel_spi.NeoPixel_SPI(spi, 96, brightness=0.1, auto_write=False)
pixels.fill((255, 0, 0)) # all red
pixels.show() # push the buffer out over SPI
Two gotchas worth saving you: the module imports as neopixel_spi, not adafruit_neopixel_spi, and it needs root to open the SPI device — which is the seed of the footgun later in this post.
The node: a render thread behind a mailbox
In the robot, nothing calls those four lines directly. The ring is owned by a ROS2 node, led_node, and every other part of Elliot controls it by publishing JSON to one topic, /led_command:
{ "mode": "chase", "colors": ["#8000FF"], "speed": 0.5, "brightness": 0.2 }
The node has one structural problem to solve, and it’s a concurrency problem. ROS2 hands you commands whenever they arrive, on its own thread. But an animation isn’t a one-shot — a “breathe” or a “chase” is a continuous thing that has to keep producing new frames forever, thirty times a second, until the next command changes it. So the node runs two threads: the ROS2 executor receiving commands, and a dedicated animation thread rendering frames. They meet at exactly one place — the current animation state — guarded by a lock.
The command callback’s whole job is to validate a message and drop the new state into that shared slot:
def on_led_command(self, msg):
try:
data = json.loads(msg.data)
except json.JSONDecodeError:
self.get_logger().warn(f'Invalid JSON: {msg.data}')
return # keep whatever's already running
mode = data.get('mode', self.mode)
if mode not in ANIMATIONS:
self.get_logger().warn(f'Unknown mode: {mode}')
return
colors_hex = data.get('colors', ['#FFFFFF'])
brightness = min(max(data.get('brightness', 0.1), 0.0), MAX_BRIGHTNESS)
with self.lock: # hand off to the render thread
self.mode = mode
self.colors_rgb = [hex_to_rgb(c) for c in colors_hex[:8]]
self.speed = data.get('speed', 0.5)
self.brightness = brightness
Three small decisions in there are deliberate. A bad message — malformed JSON, a mode that doesn’t exist — logs a warning and returns, leaving the current animation running. A status light that goes dark because something upstream sent a typo is worse than useless: it reports a fault that isn’t there and hides the one that is. Brightness is clamped to MAX_BRIGHTNESS = 0.3 no matter what anyone asks for — partly so a ring at arm’s length doesn’t sear your retinas, partly as a software cap on current draw, since brightness tracks amps roughly linearly. And colors are capped at eight, because the animations cycle through them and nobody needs a sixteen-color chase.
The render thread is the other half. It snapshots the state under the lock, computes one frame, pushes it, and sleeps:
def animation_loop(self):
frame = 0
while self.running:
with self.lock:
mode = self.mode
colors = list(self.colors_rgb)
speed, brightness = self.speed, self.brightness
pixel_data = ANIMATIONS.get(mode, anim_off)(frame, colors, NUM_LEDS, speed)
self.pixels.brightness = brightness
for i, (r, g, b) in enumerate(pixel_data):
self.pixels[i] = (r, g, b)
self.pixels.show()
frame += 1
time.sleep(1.0 / DEFAULT_FPS) # DEFAULT_FPS = 30
The lock is held only for the snapshot — three assignments — never for the rendering or the SPI write. That keeps the command thread from ever blocking on a frame: a new command can land and update the state while the render thread is mid-show(), and it gets picked up on the very next frame, a few milliseconds later. For a status light, “next frame” is indistinguishable from “instantly.”
The animation engine: one function per mode
The animations themselves are the part I’d happily lift into any other project. Each is a pure function with the same shape — given a frame number, the colors, the LED count, and a speed, return the list of 96 RGB tuples for that frame:
def anim_breathe(frame, colors, num_leds, speed):
period = max(1, int(90 / max(speed, 0.1)))
brightness = (math.sin(2 * math.pi * frame / period) + 1) / 2 # 0 → 1 → 0
idx = (frame // (period * 3)) % len(colors) # slow color cycle
return [scale_color(colors[idx], brightness)] * num_leds
def anim_chase(frame, colors, num_leds, speed):
pixels = [(0, 0, 0)] * num_leds
tail_length = max(3, num_leds // 8)
pos = int(frame * speed * 2) % num_leds
for i in range(tail_length):
led_idx = (pos - i) % num_leds
fade = 1.0 - (i / tail_length) # bright head → dim tail
pixels[led_idx] = scale_color(colors[i % len(colors)], fade)
return pixels
No hardware, no ROS2, no threads — just math from a frame counter to a list of colors. That shape is the design. Adding a mode means writing one function and registering it in a dict:
ANIMATIONS = {
'off': anim_off, 'solid': anim_solid, 'breathe': anim_breathe,
'chase': anim_chase, 'pulse': anim_pulse, 'rainbow': anim_rainbow,
'sparkle': anim_sparkle,
}
Because the functions are pure, you can test them — assert that anim_off returns 96 black pixels, that anim_breathe peaks at the right frame — without touching the SPI bus or the robot. rainbow is the only one doing anything mathematically interesting: it walks an HSV hue around the ring and rotates it over time, converting to RGB per pixel.
def anim_rainbow(frame, colors, num_leds, speed):
pixels = []
for i in range(num_leds):
hue = ((i / num_leds) + frame * speed * 0.01) % 1.0
r, g, b = _hsv_to_rgb(hue, 1.0, 1.0)
pixels.append((int(r * 255), int(g * 255), int(b * 255)))
return pixels
Seven modes cover everything the robot needs to say. The supporting cast is three small helpers — hex_to_rgb to turn #8000FF into (128, 0, 255), scale_color to dim a tuple by a factor, and _hsv_to_rgb for the rainbow — and that’s the whole engine.
The footgun: sudo, and the topics that vanished
Here’s the one that cost me an evening, and it has nothing to do with LEDs.
Recall that neopixel_spi needs root to open the SPI device, so led_node runs under sudo. The other two comms nodes — the voice interface and the LLM bridge — run as my normal user. All three are ROS2 nodes that coordinate entirely over topics, with led_node subscribed to the /led_command the others publish.
Except they couldn’t see each other. I’d start led_node under sudo, start the others normally, and ros2 topic list in each terminal showed a different world. The LLM bridge published /led_command into the void; the LED node sat waiting on a topic nobody was publishing. No error, no warning, no failed connection — two halves of one robot, each convinced it was alone on the machine.
The cause is a collision between two reasonable defaults. ROS2 Jazzy’s default middleware is Fast DDS, and for two participants on the same machine Fast DDS prefers a shared-memory transport — it’s faster than routing packets through the network stack to reach a process a few megabytes away. Shared memory means files under /dev/shm, and those files carry Unix ownership. The segment my root led_node created wasn’t readable by my user processes, and theirs weren’t readable by root. Discovery — the handshake where participants find each other — runs partly over that shared-memory channel, and it silently never completed across the privilege line. Same machine, same ROS_DOMAIN_ID, mutually invisible.
The fix is to take shared memory off the table and make Fast DDS use plain UDP, which has no such ownership problem — loopback packets cross the user/root boundary fine. Fast DDS is configured by an XML profile; this one declares a single UDPv4 transport and switches the built-in transports (shared memory among them) off:
<?xml version="1.0" encoding="UTF-8" ?>
<dds>
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
<transport_descriptors>
<transport_descriptor>
<transport_id>udp_transport</transport_id>
<type>UDPv4</type>
</transport_descriptor>
</transport_descriptors>
<participant profile_name="participant_profile" is_default_profile="true">
<rtps>
<userTransports>
<transport_id>udp_transport</transport_id>
</userTransports>
<useBuiltinTransports>false</useBuiltinTransports>
</rtps>
</participant>
</profiles>
</dds>
Point both sides at it through FASTRTPS_DEFAULT_PROFILES_FILE and the two halves of the robot find each other immediately.
There’s a second, smaller trap stacked right on top of the first, and it’s pure sudo. The launch line that actually works is this:
sudo bash -c "source /opt/ros/jazzy/setup.bash && \
source $HOME/elliot_comms_workspace/install/setup.bash && \
export ROS_DOMAIN_ID=42 && \
export FASTRTPS_DEFAULT_PROFILES_FILE=$HOME/elliot_comms_workspace/config/fastdds_no_shm.xml && \
ros2 run elliot_comms led_node"
Two things in there aren’t obvious. First, sudo resets the environment, so everything ROS2 needs — the setup scripts, the domain ID, the profile path — has to be re-established inside the sudo shell; a root shell doesn’t inherit your sourced ROS2. Second, and easy to miss: I use $HOME, not ~. Inside a root shell ~ expands to /root, where my workspace doesn’t exist — but $HOME is expanded by my outer user shell before sudo ever runs, so it stays /home/<me>. Swap the two and the command fails to find the workspace with an error that points you in entirely the wrong direction.
None of this shows up in an LED tutorial, because it isn’t about LEDs. It’s what surfaces the first time a node in your system needs privileges the others don’t. The most generalizable lesson here: the moment one ROS2 node needs sudo, expect Fast DDS’s shared-memory transport to quietly partition your graph, and force UDP.
Body language: the ring as a status bus
With the hardware driven and the node wired in, the ring becomes a shared status channel any node can write to. led_node itself knows nothing about voice, LLMs, or navigation — it renders JSON. The meaning lives in what the other nodes choose to publish, and they’ve settled into a small vocabulary:
| Robot state | Mode | Color |
|---|---|---|
| Idle | breathe | #0000FF |
| Listening | pulse | #00FFFF |
| Thinking (command) | chase | #8000FF |
| Thinking (conversation) | chase | #FFB000 |
| Speaking | breathe | #00FF00 |
| Error | sparkle | #FF0000 |
The LLM bridge drives most of it. When a voice command comes in, it publishes a thinking chase before it calls Bedrock, swaps to a speaking color as the answer starts arriving, and — after enough time for the speech to finish — returns the ring to idle blue. The voice node flashes the ring the instant it hears the wake word, before any of the slower machinery spins up, so you get feedback in the same beat as saying “Elliot.” Command mode thinks in cool purple; conversation mode (a different, pricier model) thinks in warm amber, so the ring also tells you at a glance which brain is running.
The property that makes this pleasant to live with is that it’s all decoupled. None of those nodes import led_node or know how a chase is rendered; they publish a tiny JSON document to a topic and move on. I can run the whole animation engine on my laptop with no robot attached, swap the 96-LED ring for a different count by changing one constant, or add a mode without touching another node. The light ring is a service with a one-line interface, and the robot’s “personality” is an emergent property of everyone publishing to it — including the error handler, which on a dead Bedrock call sparkles red and has Elliot mutter “I’m not feeling that well, Darlene,” a joke you can only get away with in a project that’s strictly for yourself.
What I’d tell someone building one
A handful of things worth carrying to your own build:
- If you already have a Pi, drive addressable LEDs from its SPI, not a microcontroller.
neopixel_spiplus a level shifter is fewer parts, no firmware, and no serial protocol to maintain. The SPI hardware gives you the timing the Linux scheduler can’t. - Power the LEDs from their own supply and tie every ground together. Ninety-six WS2812Bs are a ~6-amp load; the Pi drives only the data line. A floating ground is a hardware bug wearing a software bug’s clothes.
- Clamp brightness in software. One line, and it’s a current cap and an eye-comfort cap at once.
- A render thread, pure animation functions, and a lock is a tiny, testable engine. Hold the lock around the state snapshot only, never the frame, and the command path never blocks.
- The first node that needs
sudowill break Fast DDS’s shared-memory transport. Force UDP with a profile, re-source ROS2 inside the sudo shell, and reach for$HOMEover~.
The ring has been the most-noticed part of the robot by a wide margin — more than the autonomy, more than the voice. People walk up, say “Elliot,” and watch it light up. None of them know there’s no Arduino in there, that the colors are pure functions of a frame counter, or that the whole thing nearly failed to talk to itself over a shared-memory permissions quirk. They just see the robot notice them, which is the entire point of body language.