Rusty Christmas Part 3: It’s About Time
Christmas and New Year’s have come and gone, but fear not—we’re still wrapping up the Rusty Christmas project!
I’ll admit, I may have been a bit ambitious about all the features I wanted to implement during my spare time—things like a bootloader, Wi-Fi, over-the-air (OTA) updates, and a mobile app to control everything didn’t quite get finished. In fact, the only thing I actually completed was the hardware control actor. Why?
It all comes down to time.
Where We Left Off
But wait, there’s more—more work to do, that is. Buckle up, because things are about to get pretty technical.
?A Quick Look at the LED Timing
In the first installment, we were using the transmit() function from esp-hal to send data one LED at a time. That process goes like: set the data to transmit, set the start bit, then yield until it’s done (or until there’s an error). It’s simple, and since our LEDs won’t lock in the frame until there’s been 50us of low output, it gives us almost 2 LED’s worth of transmit time to respond to the transmission being done and start the next LED transmission.?
How did we get that number?
These are RGB LEDs, each with 8 bits of data per color, for a total of 24 bits. The LEDs require:
So, to send data for one LED, the fastest possible transmission (off: 0,0,0) takes 27.6 μs, and the slowest (full white: 255,255,255) is 31.2 μs.
Why does this matter?
Our first version proved that when we have nothing else going on, we can send LEDs one at a time fast enough to get the whole frame out before hitting our 50 μs time limit.? But we were only generating a frame once every few seconds.? If we want to have 30 fps, each frame needs to be sent in 33ms. Sending LEDs one at a time gives us no guarantee of how much time is in between the LEDs sent.? If we run close to our 50 μs limit for every transmission we could use over 2x as much time as our 33ms window allows.?
So what to do?
RMT Peripheral 101
Our RMT peripheral works one u32 chunk at a time (a “PulseCode”). On our ESP32C6, each channel has 48 PulseCodes in RAM, and it can send these items in three ways:
Now, you might be wondering what the difference is between “continuous” and “wrap.” Continous mode is meant to repeat the same data over and over again. But we don't want the same color for every LED, we want them totally independent of each other.
Our friends at Espressif realized that some folks need to send a rediculous amount of LED data and don’t want to be limited to only 48 unique bits at a time. This situation is what "wrap" mode is for.
Enter the Threshold Interrupt
The RMT has 3 interrurpts, “done”, “error”, and “threshold”.? The first two are pretty self explanatory.? The third, the threshold interrupt, can be set to fire after the RMT has sent a certain number of items. Since each LED is 24 bits, we can store exactly two LEDs’ worth of data (2 × 24 = 48) in the RMT RAM. If we set the threshold to 24, the threshold interrupt tells us when it’s done sending the data for one LED and has effectively used up half of its RAM. If we handle this interrupt quickly, we can “hot swap” the next LED’s data into the half of RAM that just finished transmitting, all while the other half is still being sent down the wire.
Remember that our worst-case transmission time per LED is 27.6 μs. Our microcontroller runs at 160 MHz, giving us around 4400 CPU ticks in that period—plenty of time in theory to copy over new LED data… so long as we handle the interrupt fast enough.
Let the race begin!
Modifying esp-hal
Unfortunately, the esp-hal isn’t set up for hot-swap mode out of the box. That means we have to roll up our sleeves and do some hacking. There’s a single, overarching interrupt handler for the entire RMT peripheral. By default, it:
That’s good enough for many simple use cases, but we need to do more than just clear the interrupt and let our async function know we’re done. My first thought was to have the interrupt handler directly trigger a Bloxide actor message handler—but this would violate the run-to-completion model and concurrency guarantees of our actors. Actors can not share state between simultaneous “threads” of execution, and interrupts can barge in at any time acting like a separate thread.
The Workaround
We have two main options:
I chose #2. A waker doesn’t know how many times it’s been awakened, so if multiple interrupts occur before we can handle them, we’d lose some messages. By using a channel, we can queue multiple interrupt notifications—handy for catching multiple flags in quick succession. Of course, if we get behind on threshold interrupts, the RMT will move on and we’ll miss our chance to update the LED data in time. But at least we'll know we didn't miss any other interrupts along the way.
Digging Deeper
We also needed to expose a new "start transmit" function to the public channel interface so we can run the RMT without using the standard transmit() call.
One last thing, we needed a function to write directly to the RMT RAM. Surprinsingly the esp-hal does not have a built in safe wrapper around this unsafe call, but there's a pattern in send_raw() we can borrow from. We just need to add an offset to the write so we can choose which half of the RAM we're writing too
Building the Actor
A Bloxide Actor is a hierarchical state machine with a set of messages it can handle. In this demo, I only handle a small part of functionality for a single channel (channel 0). That’s enough for our example:
Here’s the basic flow to send a frame:
We also handle Disable from the parent On state, meaning that any of its child states (Idle, Transmitting, Error) can delegate that same message up to turn things off in the same way.
We send the frame's LED data and the StartFrame request from the main loop controlled by an Embassy timer, which doesn’t block the rest of the system. The RMT actor can keep pushing LED data out while the main thread is sleeping.
Performance
Generating the LED data for each frame takes 10 ms on average. Since we aren’t interleaving data generation with RAM updates for this demo, that leaves us with 23 ms to transmit the whole frame.? Unfortunately, our worst case transmission time (all LEDs full ON) is a bit above 28.1ms.? Testing confirmed that 23ms was not long enough time to wait, and 28ms was reliably enough time to push out data, giving us 38 ms per frame or about 26fps. That’s roughly 15% slower than our target, but hey—good enough for now! There’s definitely room for improvement in the future.
The Final Result
If you search online (or ask your favorite chatbot), you’ll find plenty of LED pattern generators. For this demo, I just built two simple ones:
Then, I set up a timer to switch between the two. And voilà—pretty lights on a tree. It’s surprisingly satisfying to see them in action. You can too! https://youtube.com/shorts/udK_-qP7anM?si=ySV6boNmO1wZIpM2
Easy. (Sort of.)
I hope you enjoyed this deep dive into the RMT peripheral, threshold interrupts, and hierarchical state machines in Rust. There’s more work to be done, but this was a fun end of the year project. Questions or ideas? Drop me a comment—I’d love to hear how you’re using Rust for your projects!? What should we build next?
Cofounder | 10X Published at Towards.dev, Hackernoon | AI Consultant
1 个月What would you suggest to someone if he doesn't have access to an embedded system ?