⚙️

Rust (std) on ESP32-C3 で OSC からシリアル LED (WS2812 / SK6812) を動かす

2023/01/04に公開

年が明けてしまいましたが、この記事はモダン言語による組み込み開発 Advent Calendar 2022 の 22 日目の記事です。私は こんな感じでアート・エンタメ等のハードウェア・ソフトウェア開発をしてきた のですが、短納期で安定動作するハードウェアを作らなければいけないときがほとんどです。そんな時、いつも思います。Rust で…ファームウェアが……書きたい………!!

やっていくこと (OSC と NeoPixel 系 LED)

ということで、組込み Rust のサンプルを動かすところから初めて、上記のような業界でこれが使えたら 8 割方だいたいなんとかなるよね (ならない)、という基本的な 2 つの機能を組込み Rust で動かすところまで、試してみたいと思います。

ということで、マイコンは定番の ESP32 シリーズの ESP32-C3 (RISC-V MCU) を使用した M5Stamp C3 Mate を使用して std な組込み Rust でサクサク Wi-Fi などの豊富な機能を使っていきます。 std が組込み環境で使えるなんて…ステキ…!

https://www.switch-science.com/products/7474?variant=42382162559174

組込み Rust on ESP をはじめるための参考文献

std な組込み Rust に M5Stamp C3 Mate で入門するには、こちらの記事をどうぞ。

https://tomo-wait-for-it-yuki.hatenablog.com/entry/embedded-std-rust-on-m5stamp-c3-mate

esp-rs が提供する公式の The Rust on ESP Book も片手にどうぞ。

https://esp-rs.github.io/book/

Ferrous Systems の Embedded Rust on Espressif という Book もあります。

https://espressif-trainings.ferrous-systems.com/

まだ組込み Rust したこと無い方は、書籍「基礎から学ぶ 組込み Rust」でぜひ入門ください!

https://book.mynavi.jp/manatee/c-r/books/detail/id=122677

まずは環境構築してデモを動かしてみる

さて、それではサクサクと環境を構築して、ESP32 の std な組込み Rust のデモを M5Stamp C3 Mate で動かしてみます。Rust はすでにインストール済みとし、Ubuntu 20.04 にて動作を確認しています。

espup を使って環境を構築

最近は rustup のように ESP のツールチェイン等の開発環境をよしなに構築してくれる espup なるツールがあるようです。簡単で最高なので、こちらを使って環境を構築します。

https://github.com/esp-rs/espup

cargo install espup
espup install --esp-idf-version 4.4

# espup install でやってくれてるかも
rustup install nightly
rustup target add riscv32imc-unknown-none-elf

cargo install espflash

簡単すぎる…!

rust-esp32-std-demo を build & flash & monitor

さて、コマンド数発でサクッと環境が作れたと思いますので、ESP32 の std な組込み Rust のデモを動かしてみましょう。環境変数として Wi-Fi の SSID/PASS を設定し、target を適切に設定するだけで、こちらもサクッと動かすことができます。

https://github.com/ivmarkov/rust-esp32-std-demo

git clone git@github.com:ivmarkov/rust-esp32-std-demo.git
cd rust-std-demo

# ESP 開発用の環境変数を設定
source ~/export-esp.sh

# デモ用の環境変数を設定
export RUST_ESP32_STD_DEMO_WIFI_SSID=<ssid>
export RUST_ESP32_STD_DEMO_WIFI_PASS=<password>

# ESP32-C3 をターゲットに設定してビルド
rustup default nightly
cargo build --target riscv32imc-esp-espidf

# OR
# ESP32 をターゲットに設定してビルド
rustup default esp
cargo build --target xtensa-esp32-espidf

# OR
# .cargo/config.toml の [build] target を変更すれば --target は不要
# デフォルトでは xtensa-esp32-espidf
rustup default esp
cargo build

# Flash & Monitor
espflash /dev/ttyUSB0 target/riscv32imc-esp-espidf/debug/rust-esp32-std-demo
espflash serial-monitor

きっとなんの問題もなく動くはず…!

テンプレートから新規プロジェクトを生成してみる

ここまでで、組込み Rust で開発できそうなことがわかりました!それでは下記の std な組込み Rust プロジェクトのテンプレートを使って、自分のプロジェクトを作っていきましょう。こんなものまで用意してくれているなんて、ステキ!

https://github.com/esp-rs/esp-idf-template

cargo install cargo-generate
cargo generate https://github.com/esp-rs/esp-idf-template cargo
# 対話的にプロジェクトをいろいろ設定して生成
cd your-rust-esp32-project
rustup default nightly

# Flash & Monitor
cargo run
# .cargo/config.toml で runner = "espflash --monitor" 等が設定されており下記が実行される
# また、ポートは指定しないと自動で /dev/ttyUSB0 などに接続しにいくみたい
# espflash --monitor target/riscv32imc-esp-espidf/debug/rust-esp32-template

シリアル LED を光らせてみる

さて、ようやく本題です。まずはシリアル LED を光らせてみましょう。M5Stamp C3 Mate 上には、NeoPixel (WS2812B) 互換の SK6812 が 1 個載っています。なんと都合が良いのでしょう。こちらを光らせていきます。

いまの組込み Rust でシリアル LED を扱うには、WS2812B などのシリアル LED を抽象化して扱えるようにするための smart-leds というトレイトを利用するのがメジャーなようです。

https://github.com/smart-leds-rs/smart-leds

今回はその smart-leds トレイトを実装した WS2812・SK6812 のドライバである、下記の crate を使ってみます。

https://github.com/cat-in-136/ws2812-esp32-rmt-driver?utm_source=pocket_saves

use esp_idf_sys::esp_random;
use smart_leds::hsv::hsv2rgb;
use smart_leds::hsv::Hsv;
use smart_leds::SmartLedsWrite;
use std::thread::sleep;
use std::time::Duration;
use ws2812_esp32_rmt_driver::driver::color::LedPixelColorGrbw32;
use ws2812_esp32_rmt_driver::{LedPixelEsp32Rmt, RGB8};

const LED_PIN: u32 = 2; // 2: M5Stamp C3 Mate, 8: ESP32-C3-DevKitM-1
const NUM_PIXELS: usize = 1;

fn main() -> ! {
    let mut ws2812 = LedPixelEsp32Rmt::<RGB8, LedPixelColorGrbw32>::new(0, LED_PIN).unwrap();

    let mut hue = unsafe { esp_random() } as u8;
    loop {
        // NUM_PIXEL 分、同じ色の iterator をつくって write() に渡す
        let pixels = std::iter::repeat(hsv2rgb(Hsv {
            hue,
            sat: 255,
            val: 8,
        }))
        .take(NUM_PIXELS);
        ws2812.write(pixels).unwrap();

        sleep(Duration::from_millis(100));

        hue = hue.wrapping_add(10);
    }
}

簡単すぎて、コメントが出てきません。最高ですね。Ferrous Systems の Rust Training のサンプルコードにも、ボード上の WS2812 を RMT から使うサンプルがあるようですので、RMT の利用例としてこちらもご参考にどうぞ。

https://github.com/ferrous-systems/espressif-trainings/blob/main/common/lib/esp32-c3-dkc02-bsc/src/led.rs

Wi-Fi で OSC を送受信してみる (/ping /pong)

Wi-Fi の初期化と AP への接続

お次は Wi-Fi を使って OSC (Open Sound Control) を送受信してみます。Wi-Fi が使えると、一気に応用の幅が広がってきますね。 no_std 環境では半分諦めのお気持ちになってしまう Wi-Fi が本当に動くのか…!?とはいえ rust-esp32-std-demo で既に動いてるので、そこから Wi-Fi の初期化だけ抜粋して簡略化します。

use anyhow::{bail, Result};
use embedded_svc::wifi::*;
use esp_idf_hal::peripheral;
use esp_idf_hal::prelude::*;
use esp_idf_svc::eventloop::*;
use esp_idf_svc::netif::*;
use esp_idf_svc::wifi::*;
use esp_idf_sys;
use log::*;
use rosc::{self, OscMessage, OscPacket, OscType};
use std::net::{SocketAddr, SocketAddrV4, UdpSocket};
use std::str::FromStr;
use std::{env, time::*};

const OSC_WIFI_SSID: &str = env!("OSC_WIFI_SSID");
const OSC_WIFI_PASS: &str = env!("OSC_WIFI_PASS");
const OSC_WIFI_TIMEOUT: Duration = Duration::from_secs(20);

fn main() -> Result<()> {
    // Initialize nvs
    unsafe {
        esp_idf_sys::nvs_flash_init();
    }

    // Bind the log crate to the ESP Logging facilities
    esp_idf_svc::log::EspLogger::initialize_default();

    // Initialize Wi-Fi and connect to AP
    let peripherals = Peripherals::take().unwrap();
    let sysloop = EspSystemEventLoop::take()?;
    let wifi = init_wifi(peripherals.modem, sysloop.clone())?;

    // Wait for 20 secs...
    time::sleep(Duraion::from_secs(OSC_WIFI_TIMEOUT));

    drop(wifi);
    info!("Wifi stopped");

    Ok(())
}

fn init_wifi(
    modem: impl peripheral::Peripheral<P = esp_idf_hal::modem::Modem> + 'static,
    sysloop: EspSystemEventLoop,
) -> Result<Box<EspWifi<'static>>> {
    let mut wifi = Box::new(EspWifi::new(modem, sysloop.clone(), None)?);
    info!("Wifi created, about to scan");

    let ap_infos = wifi.scan()?;
    let ours = ap_infos.into_iter().find(|a| a.ssid == OSC_WIFI_SSID);
    let channel = if let Some(ours) = ours {
        info!(
            "Found configured AP {} on channel {}",
            OSC_WIFI_SSID, ours.channel
        );
        Some(ours.channel)
    } else {
        info!(
            "Configured AP {} not found during scanning, will go with unknown channel",
            OSC_WIFI_SSID
        );
        None
    };

    wifi.set_configuration(&Configuration::Client(ClientConfiguration {
        ssid: OSC_WIFI_SSID.into(),
        password: OSC_WIFI_PASS.into(),
        channel,
        ..Default::default()
    }))?;

    wifi.start()?;
    info!("Starting wifi...");

    if !WifiWait::new(&sysloop)?.wait_with_timeout(OSC_WIFI_TIMEOUT, || wifi.is_started().unwrap())
    {
        bail!("Wifi did not start");
    }

    info!("Connecting wifi...");
    wifi.connect()?;

    if !EspNetifWait::new::<EspNetif>(wifi.sta_netif(), &sysloop)?.wait_with_timeout(
        OSC_WIFI_TIMEOUT,
        || {
            let is_wifi_connected = wifi.is_connected().unwrap();
            let ip = wifi.sta_netif().get_ip_info().unwrap().ip;
            is_wifi_connected && ip != std::net::Ipv4Addr::new(0, 0, 0, 0)
        },
    ) {
        bail!("Wifi did not connect or did not receive a DHCP lease");
    }

    let ip_info = wifi.sta_netif().get_ip_info()?;
    info!("Wifi DHCP info: {ip_info:?}");

    Ok(wifi)
}

きちんと AP に接続して、IP アドレスを取得できていますね…!最高です。

OSC (Open Sound Control) の UDP での送受信

続いて、OSC (Open Sound Control) というプロトコルを UDP で送受信する部分を実装します。OSC の encode / decode には rosc crate を使用します。今回は std で使いますが、 no_std にも対応しているという、メジャーな OSC の crate です。

https://github.com/karnpapon/oscd

さっそく実装してみます。上で書いた Wi-Fi の初期化部分は省略しています。

use anyhow::{bail, Result};
use embedded_svc::wifi::*;
use esp_idf_hal::peripheral;
use esp_idf_hal::prelude::*;
use esp_idf_svc::eventloop::*;
use esp_idf_svc::netif::*;
use esp_idf_svc::wifi::*;
use esp_idf_sys;
use log::*;
use rosc::{self, OscMessage, OscPacket, OscType};
use std::net::{SocketAddr, SocketAddrV4, UdpSocket};
use std::str::FromStr;
use std::{env, time::*};

const OSC_WIFI_SSID: &str = env!("OSC_WIFI_SSID");
const OSC_WIFI_PASS: &str = env!("OSC_WIFI_PASS");
const OSC_WIFI_RECV_PORT_STR: &str = env!("OSC_WIFI_RECV_PORT");
const OSC_WIFI_PONG_PORT_STR: &str = env!("OSC_WIFI_PONG_PORT");
const OSC_WIFI_TIMEOUT: Duration = Duration::from_secs(20);

fn main() -> Result<()> {
    // ...

    // Create socket for osc
    let ip_info = wifi.sta_netif().get_ip_info()?;
    let recv_port = OSC_WIFI_RECV_PORT_STR.parse::<u16>().unwrap();
    let recv_addr = SocketAddrV4::new(ip_info.ip, recv_port);
    let sock = UdpSocket::bind(recv_addr).unwrap();
    info!("Listening to {recv_addr}");

    // Receive osc
    let mut buf = [0u8; rosc::decoder::MTU];
    let pong_port = OSC_WIFI_PONG_PORT_STR.parse::<u16>().unwrap();
    loop {
        match sock.recv_from(&mut buf) {
            Ok((size, addr)) => {
                info!("Received packet with size {size} from: {addr}");
                let (_, packet) = rosc::decoder::decode_udp(&buf[..size]).unwrap();
                let mut pong_addr = addr.clone();
                pong_addr.set_port(pong_port);
                handle_osc_packet(packet, &sock, pong_addr);
            }
            Err(e) => {
                error!("Error receiving from socket: {e}");
                break;
            }
        }
    }

    drop(wifi);
    info!("Wifi stopped");

    Ok(())
}

fn init_wifi(
    modem: impl peripheral::Peripheral<P = esp_idf_hal::modem::Modem> + 'static,
    sysloop: EspSystemEventLoop,
) -> Result<Box<EspWifi<'static>>> {
    // ...

    Ok(wifi)
}

fn handle_osc_packet(packet: OscPacket, sock: &UdpSocket, pong_addr: SocketAddr) {
    match packet {
        OscPacket::Message(msg) => {
            info!("OSC address: {}", msg.addr);
            info!("OSC arguments: {:?}", msg.args);

            // reply /pong 1 to sender (port will be changed to OSC_DEST_PORT)
            if msg.addr == "/ping" {
                info!("Reply /pong to {pong_addr}");

                let msg_buf = rosc::encoder::encode(&OscPacket::Message(OscMessage {
                    addr: "/pong".to_string(),
                    args: vec![OscType::Int(1)],
                }))
                .unwrap();

                sock.send_to(&msg_buf, pong_addr).unwrap();
            }
        }
        OscPacket::Bundle(bundle) => {
            info!("OSC Bundle: {bundle:?}");
        }
    }
}

ESP32 の Wi-Fi ドライバの初期化部分以外は、ほぼ stdrosc のサンプルコードがそのまま動く…!そりゃそうなんだけれども、そりゃそうなんだが、なんて最高なんだ…!! std::net::UdpSocket があっさり動きすぎて、 std だから当たり前なのに、感動しています。

LED の色を指定する OSC を受信して光らせ方を変えてみる

さて、これまで実装してきた、LED と OSC のサンプルをがっちゃんこすれば、問題なく OSC から LED の光を制御することができるようになります。ただコピペするだけではおもしろくないので、ちょっとコードを整理して、こんな感じで動くようにしてみました。

  • OSC 送受信と LED の駆動をスレッドで行う
  • OSC で受信した RGB データを OSC スレッドから LED スレッドへ送信

具体的な実装はこちらにアップしてありますので、興味のある方はどうぞ!上記のシンプルなシリアル LED の駆動と OSC による通信も example に置いてあります。Issue / Pull Request お待ちしております。

https://github.com/hideakitai/rust-esp32-osc-led

細かい実装は良いとして、上記 2 点の改造したとこについてメモを残して終わりにします。完全に蛇足になります。

OSC (std::net::UdpSocket) をスレッドで動作させようとすると Stack Overflow する (蛇足 1)

OSC と LED をスレッド化しておくか〜と思って、OSC 送受信部分をスレッド化したら見事に Stack Overflow が発生。順調すぎたからまあそうだよな〜でも Stack Overflow!?と思っていたら

https://twitter.com/tnakabayashi/status/1610156048559804424

よし!?

https://twitter.com/tnakabayashi/status/1610158386414514176

だから Stack Overflow なのか…!

https://twitter.com/ciniml/status/1610162191143174145

ちょっと CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE 大きめにして置いておきます!

https://twitter.com/tnakabayashi/status/1610177677939572741

  • 順調すぎたのでなんかおかしいと思ったら、フラグだった (見事に回収)
  • Socket 使う時は std::thread::Builder::stack_size() で Stack を多めに確保しましょう
  • CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE で default stack size 指定できるよ

スレッド間のデータのやり取りについて (蛇足 2)

C で ESP-IDF (FreeRTOS) を使う場合は、Queue などで Task 間のデータ渡しをしますが、今回はどうやるのが良いんだろうな、と思ってちょっとだけ試行錯誤しました。が、深堀りしきれてなくて、別途ちゃんと調べよう…という気持ちになったので、軽く試行錯誤の経緯を貼っておしまいにします。

heapless::spsc::Queue を採用するも !Sync だったので NG

まず、OSC と LED をまずはシングルスレッドで一緒くたにする際、なにを使って RGB データの送受信をやるかを検討しました。今回は下記のように RGB のデータを OSC スレッドから LED スレッドに送信するだけの簡単なお仕事なので、型指定でサクッと使えそうな heapless::spsc::Queue を採用しようと思ったわけです。

https://docs.rs/heapless/latest/heapless/spsc/struct.Queue.html

スレッドに分けるまではいい感じでした。

use heapless::spsc::Queue;
use smart_leds::RGB8;

// create heapless:spsc::Queue
let mut queue: Queue<RGB8, 16> = Queue::new();
let (mut producer, mut consumer) = queue.split();

// send color via heapless::spsc::Queue
producer.enqueue(RGB8 { r: 128, g: 128, b: 128 ));

// send color via heapless::spsc::Queue
if let Some(val) = consumer.dequeue() {
    // drive your LEDs
}

そして producer consumer を各スレッドに渡してから気づくわけです。 !Sync だからスレッドに渡せないじゃん…! (ちなみに heapless::mpmc::MpMcQueue<T, N>Sync ですが、 MPMC (Multi Producer Multi Consumer) にする理由もないので、今回は見送ります)

BBQueue を採用するも、もう少しお手軽に使いたい

じゃあどうしよう、ということで次は BBQueue を採用することとします。BBQueue はスレッドセーフに可変長フレームをロックフリー・動的メモリ確保なしで使える SPSC (Single Producer Single Consumer) な Queue です。

https://docs.rs/bbqueue/latest/bbqueue/

こちらの記事が BBQueue の内部構造まで丁寧に解説されていて、とても勉強になります。

ぶらり組込みRustライブラリ探索の旅 BBQueue編 -スレッドセーフなSingle Producer Single Consumer Queue- - Nature Engineering Blog

今回は framed にする必要もないのですが、サイズ指定を省けるという理由だけでなんとくなくこうしています。

use bbqueue::BBBuffer;
use smart_leds::RGB8;

// LED_QUEUE should be 'static to pass to threads
static LED_QUEUE: BBBuffer<64> = BBBuffer::new();
let (producer, consumer) = LED_QUEUE.try_split_framed().unwrap();

// send color via BBQueue
let rgb = RGB8 { r: 128, g: 128, b: 128 };
let sz = std::mem::size_of::<RGB8>();
if let Ok(mut wg) = self.producer.grant(sz) {
    wg.to_commit(sz);
    wg[0..].copy_from_slice(rgb.as_slice());
}

// receive color via BBQueue
if let Some(mut frame) = self.consumer.read() {
    frame.auto_release(true);
    let rgb = RGB8 {
        r: frame[0],
        g: frame[1],
        b: frame[2],
    };

    // drive your LEDs
}

良い。すごく良い。良いんだけれども、今回のような単純な用途では、可変長でなく固定の型を送るし、型から静的にデータサイズをよしなにしてくれたりして、使い勝手の良い heapless::spsc::Queue みたいなやつへの未練が捨てられません…困った…!

thingbuf::mpsc::StaticChannel を採用するも深堀りはできてない

じゃあどういうものだったらいいのさ?というと、最終的に欲しいのは下記のような要件を満たすものです。

  • no_std で使える mpsc / spsc な Queue / Ring Buffer / Channel
  • スレッドセーフでロックフリー
  • 型指定で静的にメモリを確保して使える (サイズ管理はよしなにやってくれる)
  • 使い勝手重視

なにか良いものがないだろうか…と探してみたところ thingbuf::mpsc が良さげです。

https://github.com/hawkw/thingbuf

https://docs.rs/thingbuf/0.1.3/thingbuf/mpsc/index.html

thingbuf::mpsc::StaticChannel<T, N>no_std はもちろん async な文脈でも使用可能なようです。今後、embassy のような組込み用の async executor と一緒に試してみたいところですね。今回は async は置いておきますが、 thingbuf::mpsc::StaticChannel<T, N>std::sync::mpsc::channel みたく使えて使い勝手が良さそうです。

use thingbuf::mpsc::StaticChannel;
use smart_leds::RGB8;

// create thingbuf::mpsc::StaticChannel
static LED_CHANNEL: StaticChannel<RGB8, 16> = StaticChannel::new();
let (sender, receiver) = LED_CHANNEL.split();

// send color via StaticChannel
let rgb = RGB8 { r: 128, g: 128, b: 128 };
sender.try_send(rgb)?;

// receive color via StaticChannel
if let Ok(rgb) = self.receiver.try_recv() {
    // drive your LEDs
}

雰囲気で使っていて中身をきちんと見れていないので、後日要検証です。以上、長い蛇足でした。

追記 (2023/1/9) : heapless も問題なく使えました

記事を公開してすぐに @ciniml さんから教えてもらいました!

https://twitter.com/ciniml/status/1610451214076084224

なんと…!なるほど、ちゃんと staticheapless::spsc::Queue を定義できてなかっただけだったようです。Rust 力高めていかねばですね。

use heapless::spsc::Queue;
use std::time::Duration;

static mut QUEUE: Option<Queue<u32, 16>> = None;

fn main() {
    unsafe { QUEUE = Some(Queue::new()); }
    let (mut producer, mut consumer) = unsafe { QUEUE.as_mut().unwrap().split() };

    std::thread::spawn(move || {
        producer.enqueue(1);
    });

    std::thread::spawn(move || {
        if let Some(val) = consumer.dequeue() {
            println!("Received {val}");
        }
    }).join();

    println!("end");
}

heapless を使ったサンプルを feature/use_heapless ブランチにあげておいたので、ご参考までにどうぞ。

https://github.com/hideakitai/rust-esp32-osc-led/tree/feature/use_heapless

thingbuf::mpsc::StaticChannel から heapless::spsc::Queue にすると、上記サンプルのように unsafe が必要になってきます。また、heapless::spsc::{Producer, Consumer} がライフタイムパラメータを持つため、それらを渡す構造体にもライフタイムパラメータをつけなくてはいけなくなります。それがちょっとだけ面倒な人もいるかも?と思いました。

まとめ

半分近く蛇足になってしまいましたが、組込み Rust は楽しい!ということが伝わりましたでしょうか?上記のような業界では、あまり複雑ではないにしろ、本当に短納期での安定動作が求められるます。そのため、ラピッドプロトタイピングして良さげなら、ちょっと手直ししてそのまま本番、となることがほとんどです。

よりサクッと組込み Rust でのラピッドプロトタイピングができるように、エコシステムに貢献していけるように精進していきたいですね。それでは良い組込み Rust なお年を!

Discussion