Rusty Christmas Part 3: It’s About Time

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

  1. We had WS2812 LEDs lighting up using the RMT peripheral on our ESP32C6 microcontroller, with the MCU running Embassy and using the esp-hal.
  2. We successfully ported our Bloxide Actor model to no_std + Embassy.
  3. Today, we’re putting it all together.

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:

  • A “0” bit pulse of about 1.15 μs.
  • A “1” bit pulse of about 1.3 μs.

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:

  1. Singleshot: Start at the beginning, then stop when you see an end marker (a 0 value).
  2. Continuous: Like singleshot, but it’ll keep looping the same data over and over, optionally for a set count.
  3. Wrap: Start at the beginning, and once it sends the 48th item, wrap around to the start again. Keep doing this until you see an end marker.


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:

  • Checks which channel threw the interrupt.
  • Stops listening to that interrupt.
  • Wakes the associated Future (in the esp-hal approach, each async transmit() makes a new Future for the channel we're using).

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:

  1. Create a special message handler in the actor loop that the interrupt can directly wake.
  2. Use a static Embassy channel (like our other message channels), and have the interrupt send a message into that channel.

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:


  • White-labeled messages are sent from another actor (or from our main function pretending to be one).
  • Green-labeled messages come from the RMT interrupt handler.

Here’s the basic flow to send a frame:

  1. Initialization: We configure the RMT peripheral. (Only done before the first frame)
  2. SetLed: We fill the actor’s LED color buffer (in a real scenario, we’d send this data incrementally).
  3. StartFrame: We copy data for the first LEDs into the RMT RAM and move to the Transmitting state. On entering Transmitting, we tell the RMT to start sending, and we begin listening for interrupt messages.
  4. Threshold Interrupt: Each time the threshold hits, we copy over the next LED’s data.
  5. Done Interrupt: Once the frame is fully sent, we return to Idle.

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:

  • A moving rainbow
  • A random color effect

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?

Mohammad Abir Abbas

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 ?

要查看或添加评论,请登录

Brendan Bogan-Ware的更多文章

社区洞察

其他会员也浏览了