loading...

Matrix Voice ESP32 in Rust

jeikabu profile image jeikabu Originally published at rendered-obsolete.github.io on ・8 min read

Smart Home (8 Part Series)

1) Installing Home Assistant 2) Solar Powered USB 3 ... 6 3) Home Assistant Voice Recognition with Snips 4) Home Assistant Voice Recognition with Rhasspy 5) Matrix Voice- A Microphone Array w/ Microcontroller 6) Addendum- Migrating to Jetson Nano and Docker 7) Matrix Voice ESP32 in Rust 8) More Matrix Voice ESP32 in Rust

My ultimate goal for Matrix Voice w/ ESP32 is to be able to develop in Rust.

Basic Toolchain

Previously we used their recommend dev environment of PlatformIO. It’s also possible to do development without using PlatformIO. Matrix-io provides matrixio_hal_esp32 C/C++ repository for programming of Matrix Voice with ESP32. The Github repo and this hackster post contain details.

From the initial tests we know Matrix Voice depends on release v1.9.0 of platformio’s espressif toolchain. Looking at platformio releases this corresponds to ESP-IDF v3.2.2.

The guide to install v3.2.2 (there’s also stable as well as latest/HEAD) is detailed and boils down to:

  1. ESP32 toolchain/pre-requisites (for Mac, Linux, Windows)
  2. Install ESP-IDF and set IDF_PATH
  3. Install required python packages

On Mac/Linux:

# ESP32 toolchain/pre-requisites
sudo easy_install pip
mkdir -p ~/esp
cd ~/esp
curl -O https://dl.espressif.com/dl/xtensa-esp32-elf-osx-1.22.0-80-g6c4433a-5.2.0.tar.gz
tar -xzf xtensa-esp32-elf-osx-1.22.0-80-g6c4433a-5.2.0.tar.gz
# Add to PATH
export PATH=$HOME/esp/xtensa-esp32-elf/bin:$PATH

# Install ESP-IDF v3.2.2
git clone -b v3.2.2 --recursive https://github.com/espressif/esp-idf.git
# Set IDF_PATH
export IDF_PATH=$HOME/esp/esp-idf

# Install required python packages
python -m pip install --user -r $IDF_PATH/requirements.txt

Build and run matrixio_hal_esp32 example:

git clone https://github.com/matrix-io/matrixio_hal_esp32.git
cd matrixio_hal_esp32/examples/everloop_demo

# Mac Only: Install bison
brew install bison
export PATH=/usr/local/opt/bison/bin:$PATH

make menuconfig
make -j4
export RPI_HOST=pi@raspberrypi.local
make deploy

The deploy script currently requires a small hack to work correctly (see this issue).

Rust-ified

There’s an excellent write-up of the current state of ESP32 development in Rust:

Building and installing custom versions of LLVM/Rust along with configuring cross-compiling is a bit onerous, but the author put together a docker image:

git clone https://github.com/ctron/rust-esp-container.git
cd rust-esp-container
git submodule update --init --recursive
cd ../matrix-io
docker run -it -v $(pwd):/home/matrix-io quay.io/ctron/rust-esp /bin/bash

matrix_hal_esp32_sys

The components/hal/ directory of matrix_hal_esp32 contains C++ to access Matrix Voice-specific functionality. Let’s pass it through bindgen to generate a Rust FFI wrapper matrix_hal_esp32_sys.

With a basic Makefile:

PROJECT_NAME := esp-app

EXTRA_COMPONENT_DIRS += $(PROJECT_PATH)/matrixio_hal_esp32/components

include $(IDF_PATH)/make/project.mk
include $(PROJECT_PATH)/matrixio_hal_esp32/make/deploy.mk

This bindgen-project script based off the original.

In build.rs:

use std::{env, path::PathBuf};

fn main() {
    link_nng();
    bindgen_generate();
}

fn link_nng() {
    let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    println!(
        "cargo:rustc-link-search=native={}", root.join("build/hal/").display()
    );
    // Link to matrixio_hal_esp32 generated `hal` library
    println!("cargo:rustc-link-lib=static=hal");
}

The main thing being linking to build/hal/libhal.a generated by make:

# Generate sdkconfig
make menuconfig
# Populate `build/`
make -j4

# From: https://github.com/ctron/rust-esp-container/blob/master/Dockerfile
export LIBCLANG_PATH=/home/esp32-toolchain/llvm/llvm_install/
rustup toolchain link xtensa /home/esp32-toolchain/rustc/rust_build/
cargo install cargo-xbuild bindgen

./bindgen-project

# From `xbuild-project` script
cargo +xtensa xbuild --target "${XARGO_TARGET:-xtensa-esp32-none-elf}" --release

Make sure to build --release of you’ll get the mysterious error: Error: CFI is not supported for this target. Debug configuration isn’t yet supported, but should be shortly (see issues #1 and #2).

“Hello World”

esp-idf contains several esp_log_* funtions, but they don’t work here. There’s also several ESP_EARLY_LOGx macros which ultimately call ets_printf() enabling you to write “hello world”:

#![no_std]
#![no_main]

#[no_mangle]
pub fn app_main() {
    unsafe {
        use matrix_hal_esp32_sys::*;
        // `b`-prefix creates byte string, `\0` null-terminates it
        let text = b"Hello World\n\0";
        // `ets_printf()` takes a null-terminated `* const u8`
        ets_printf(text.as_ptr() as *const _);
    }
}

use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}
  • #![no_std] precludes using the standard library (see embedded book)
  • #![no_main] lets us build a binary without main()- we instead have app_main()
  • #[no_mangle] ensures app_main() symbol isn’t mangled in way unreconizable to native linkers (see nomicon and embedded)
  • #[panic_handler] handles panic!() in no_std applications (see nomicon and reference)
# From `image-project` script
"${IDF_PATH}/components/esptool_py/esptool/esptool.py" \
     --chip esp32 \
     elf2image \
     -o build/esp-app.bin \
     ../../target/xtensa-esp32-none-elf/release/everloop

install.sh comes from esp32-platformio, and is basically identical to flash-project in docker using esp_tool to push the build to the device.

Previously we used screen to monitor console output from the ESP32, but tail is better since we can still write images to the serial port white capturing output:

# On Raspberry Pi:
tail -f /dev/ttyS0
# OR
screen /dev/ttyS0 115200

Everloop

Matrix-io provides an example everloop_demo.

The C++:

#include <stdio.h>
#include <cmath>

#include "esp_system.h"

#include "everloop.h"
#include "everloop_image.h"
#include "voice_memory_map.h"
#include "wishbone_bus.h"

namespace hal = matrix_hal;

int cpp_loop() {
  hal::WishboneBus wb;

  wb.Init();

  hal::Everloop everloop;
  hal::EverloopImage image1d;

  everloop.Setup(&wb);

  unsigned counter = 0;
  int blue = 0;

  while (1) {
    // Pulsing blue
    blue = static_cast<int>(std::sin(counter / 64.0) * 10.0) + 10;
    for (hal::LedValue& led : image1d.leds) {
      led.red = 0;
      led.green = 0;
      led.blue = blue;
      led.white = 0;
    }
    // Set the LEDs
    everloop.Write(&image1d);
    ++counter;
  }

  return 0;
}

extern "C" {
  // Entry point
  void app_main(void) { cpp_loop(); }
}

If you’ve seen any of the Rust/Lumberyard stuff I’ve been experimenting with, or you’ve tried on your own codebases, you’re familiar with bindgen’s limitations regarding C++ and especially STL.

//----------------------------------------------------
// everloop_image.h

const int kMatrixCreatorNLeds = 18;

// An array of 18 LED values
class EverloopImage {
 public:
  EverloopImage(int nleds = kMatrixCreatorNLeds) { leds.resize(nleds); }
  std::vector<LedValue> leds;
};

//----------------------------------------------------
// everloop.cpp

bool Everloop::Write(const EverloopImage* led_image) {
  if (!wishbone_) return false;

  // Create array of 18*4 bytes
  std::valarray<unsigned char> write_data(led_image->leds.size() * 4);

  // Fill RGB-White values
  uint32_t led_offset = 0;
  for (const LedValue& led : led_image->leds) {
    write_data[led_offset + 0] = led.red;
    write_data[led_offset + 1] = led.green;
    write_data[led_offset + 2] = led.blue;
    write_data[led_offset + 3] = led.white;
    led_offset += 4;
  }

  // Write array of values to wishbone bus
  wishbone_->SpiWrite(kEverloopBaseAddress, &write_data[0], write_data.size());
  return true;
}

We can write a pure Rust version of most of this and skip wrangling with what bindgen generates from C++.

The Cargo.toml:

[package]
name = "everloop"
version = "0.1.0"
edition = "2018"

[dependencies]
matrix_hal_esp32_sys = {path = "../../matrix_hal_esp32_sys"}
# `no_std` access to math functions like `sin()`
libm = "0.2"
# C types for FFI
cty = {version = "0.2"}

Owing to the no_std requirement: libm gives us access to math routines like sin(), and cty provides std::os::raw types.

In Rust:

#![no_std]
#![no_main]

// Entry point
#[no_mangle]
pub fn app_main() {
    unsafe {
        everloop();
    }
}

unsafe fn everloop() {
    use matrix_hal_esp32_sys::*;
    let mut wb = matrix_hal::WishboneBus::default();
    wb.Init();

    // Don't bother with Everloop helper class, it just makes a byte array
    // let mut everloop = matrix_hal::Everloop::new();
    // everloop._base.Setup(&mut wb);

    let mut counter = 0;
    loop {
        const NUMBER_LEDS: usize = matrix_hal::kMatrixCreatorNLeds as usize;
        let mut image1d = [0u8; NUMBER_LEDS * 4];
        let blue = (libm::sinf(counter as f32 / 64.0) * 10.0 + 10.0) as u8;
        ets_printf(b"counter=%d blue=%d\n\0".as_ptr() as *const _, counter, blue as cty::c_uint);
        for i in 0..NUMBER_LEDS {
            image1d[i * 4 + 2] = blue;
        }
        wb.SpiWrite(matrix_hal::kEverloopBaseAddress as u16, image1d.as_ptr(), image1d.len() as i32);
        counter += 1;
    }
}

// Same panic handler as above
#[panic_handler]
//...

Tips

Remote Development has become one of my favorite plugins for VS Code. It lets you run a VS Code session locally that interacts with a remote/virtual environment. Remote- SSH makes it easier to work with another device like a Raspberry Pi- and is decidedly better than X11 forwarding. Remote- Containers lets you do the same with running containers and is especially handy when you want to mess with files not in a volume mounted from the host.

Next

Smart Home (8 Part Series)

1) Installing Home Assistant 2) Solar Powered USB 3 ... 6 3) Home Assistant Voice Recognition with Snips 4) Home Assistant Voice Recognition with Rhasspy 5) Matrix Voice- A Microphone Array w/ Microcontroller 6) Addendum- Migrating to Jetson Nano and Docker 7) Matrix Voice ESP32 in Rust 8) More Matrix Voice ESP32 in Rust

Discussion

markdown guide