Developping an air quality sensor with Rust, Nix and a Esp32 Xtensa

Introduction

After dabbling in embedded programming with small, fun projects, I decided to take on a more ambitious challenge: building an air quality monitor using Rust and an ESP32 microcontroller. This journey was tougher than I expected, but incredibly rewarding. Setting up this project was, frankly, a bit of a pain. Maybe it’s because I’m still sharpening my skills, or maybe it’s just inherently tricky. To compile Rust code for an ESP32 with its Xtensa chip, you need a specialized toolchain. It’s straightforward enough in theory, but doing it the “Nix way” added some complexity. The approach is not the “correct” Nix way, but it works on my machine, and this time, I’m shipping it! Interestingly, using RISC-V-based chips seems like it might be simpler, but that’s a story for another day.

Setup

Installing the toolchain

The heavy lifting for setting up the toolchain is handled by espup, a handy tool that downloads everything you need. This part could be “Nixified” (integrated into Nix for reproducibility), but I didn’t tackle that yet, I’ll save it for later. To flash the compiled code onto the ESP32, I paired espup with espflash, which does exactly what its name suggests.

Here’s what my flake.nix looks like:

{
  description = "A Nix Flake for building and flashing Rust applications for Xtensa (ESP32)";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = {
    self,
    nixpkgs,
    flake-utils,
  }:
    flake-utils.lib.eachDefaultSystem (system: let
      pkgs = import nixpkgs {
        inherit system;
      };
    in {
      devShells.default = pkgs.mkShell {
        name = "xtensa-esp32-dev-shell";
        buildInputs = with pkgs; [
          rustup # Add rustup for espup
          cargo # Base Cargo (will be overridden by rustup)
          cargo-espflash # For building and flashing
          espup # For managing the toolchain
        ];

        shellHook = ''
          # Use a persistent HOME directory for rustup and cargo
          export RUSTUP_HOME="$PWD/.rustup"
          export CARGO_HOME="$PWD/.cargo"
          mkdir -p "$RUSTUP_HOME" "$CARGO_HOME"

          # Update PATH to include Cargo binaries
          export PATH="$CARGO_HOME/bin:$PATH"

          # Install the Xtensa toolchain via espup if not already present
          if [ ! -f rust-toolchain.env ]; then
            echo "Installing Xtensa Rust toolchain via espup..."
            ${pkgs.espup}/bin/espup install --export-file rust-toolchain.env
          fi

          # Source the toolchain environment
          source rust-toolchain.env
          rustup default esp

          echo "Xtensa ESP32 development environment ready!"
          echo "Rust version: $(rustc --version)"
        '';
      };
    });
}

As you can see, it’s concise. Most of the magic happens in the shellHook, which sets up the environment and checks the current directory. I’ll admit it’s not the prettiest solution—Nix could store results in its store to avoid polluting the environment, and Cargo.toml could be tracked for better reproducibility. Those improvements are on my to-do list, but I prioritized finishing the project first.

Setting config.toml

To run cargo run --release from the terminal, I found I had to set the default toolchain with rustup default esp. This ensures the Xtensa toolchain is used, assuming config.toml is properly configured.

Implementing the Code

Let the Fun Begin

Time to write some code! The first big decision was: how low-level do I want to go? I could write everything from scratch, managing pointers unsafely and talking directly to the hardware, or take the easier route with a library. After exploring excellent resources like the Embedonomicon and Rust Embedded, I got a sense of the ecosystem and made my choice.

Esp-hal

I opted for a library: esp-hal (Hardware Abstraction Layer). It’s awesome! It offers plenty of resources, some outdated due to a shift from 0.x to 1.x, but migration libraries help bridge the gap. With esp-hal, you get a clean abstraction over the hardware, freeing you from pointer juggling so you can focus on the fun stuff—like making LEDs blink! I drew inspiration from its examples (check the docs). It supports everything from communication protocols to heap management, Wi-Fi, and Bluetooth.

GPIOs

GPIOs (General Purpose Input/Output pins) are the ESP32’s way of interacting with the outside world—think sensors, LEDs, or motors. The ESP32 has several pins, most of which you can use without much hassle (see the pinout reference). You decide which ones suit your needs. I went with:

peripherals.GPIO5,
peripherals.GPIO21,
peripherals.GPIO22,
peripherals.GPIO34,
peripherals.ADC1,
peripherals.I2C0,
  • Pins 21 and 22: Used for I2C, a protocol that lets multiple devices chat over a shared bus—perfect for sensors.
  • Pin 34: Connected to the ADC (Analog-to-Digital Converter), which turns analog signals (like sensor voltages) into digital values the ESP32 can understand.
  • Pin 5: Reads data from a sensor.

Sensors

Now for the serious stuff: sensors! I wanted to read useful data: temperature, humidity, gases; and do something with it. The tricky part? Calibration. How do you know they’re set up right? I mostly crossed my fingers and hoped. The readings seemed off sometimes, so calibration is definitely on my list.

DHT11

The DHT11 is a cheap, simple sensor for temperature and humidity. It uses a capacitive humidity sensor and thermistor, sending digital signals via a data pin. It’s easy to use but not super accurate—expect ±2°C and ±5% humidity.

AHT21

The AHT21 is a step up: a precise temperature and humidity sensor using I2C. It’s reliable, low-power, and fast, with accuracies of ±0.3°C and ±2% humidity—way better than the DHT11.

MQ135

The MQ135 gas sensor was a challenge. It detects gases like CO2, alcohol, and benzene, but calibration is a beast. I struggled to figure out if the hardware, software, or both were misconfigured. I wrote my own driver, inspired by an Arduino C++ version, which meant learning about pins and ADCs.

Key ADC notes:

  • ADC1 vs. ADC2: ADC1 (like GPIO34) is always available, unaffected by Wi-Fi. ADC2 works too, but not if Wi-Fi’s active (not an issue here).
  • Input-Only Pins: GPIO34–39 (ADC1) are input-only, ideal for the MQ135’s analog output.

The MQ135’s attenuation setting (Attenuation::Attenuation11dB) in esp-hal scales the ADC’s input range to 0–3.3V, matching the sensor’s output. This ensures accurate readings without clipping. It needs a 24-hour preheat, and calibration is manual—think lighters and screwdrivers! Info is scarce; I found bits on CO2 and benzene but not much else. The datasheet says 5V, but I used ~4.7V with a 3.3V ESP32 pin, so a voltage divider is next to avoid frying it. My C++-inspired code needs async support and generalization.

Sudden Break

After days of smooth running (with restarts from shutting down my PC), it crashed with no logs. Debugging revealed I2C timeouts, supported by my ESP32 (forum link). The culprit? Jumper wires to a sensor with pinless holes. Loose connections messed with I2C. Soldering pins fixed it.

Rolling Median

I’m getting data: temperature, humidity, CO2, TVOC, etc., from three sensors. I built a RollingMedian struct using insertion sort, less memory, more CPU time. It’s a fair trade-off for avoiding dynamic allocations.

pub struct RollingMedian<'a, const N: usize, T: Copy + PartialOrd + Default> {
    buffer: [T; N],
    index: usize,
    filled: bool,
    title: &'a str,
}

impl<'a, const N: usize, T: Copy + PartialOrd + Default> RollingMedian<'a, N, T> {
    pub fn new(title: &'a str) -> Self {
        Self {
            buffer: [T::default(); N],
            index: 0,
            filled: false,
            title
        }
    }

    pub fn update(&mut self, value: T) {
        self.buffer[self.index] = value;
        self.index = (self.index + 1) % N;
        if self.index == 0 {
            self.filled = true;
        }
    }

    pub fn median(&self) -> T {
        let mut sorted = self.buffer;
        let count = if self.filled { N } else { self.index };

        for i in 1..count {
            let mut j = i;
            while j > 0 && sorted[j] < sorted[j - 1] {
                sorted.swap(j, j - 1);
                j -= 1;
            }
        }

        sorted[count / 2]
    }
}

I currently use floats to represent the data, I probably could use integers, save more memory and convert it to floats on demand.

Adding a Screen

Adding an OLED screen was a breeze thanks to a library handling graphics. I plugged it in, added the library, and wrote a StrWriter struct to format text:

pub struct StrWriter<'a> {
    pub buffer: &'a mut [u8],
    pub pos: usize,
}

impl<'a> core::fmt::Write for StrWriter<'a> {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        let bytes = s.as_bytes();
        let len = bytes.len();

        if self.pos + len > self.buffer.len() {
            return Err(core::fmt::Error);
        }

        self.buffer[self.pos..self.pos + len].copy_from_slice(bytes);
        self.pos += len;
        Ok(())
    }
}

impl<'a> StrWriter<'a> {
    pub fn as_str(&self) -> &str {
        core::str::from_utf8(&self.buffer[..self.pos]).unwrap_or("")
    }
}

I preallocate a buffer for text—inefficient with all the copying, but it works for now.

Bluetooth

For a bit of excitement, I decided to use Bluetooth to send sensor data instead of HTTP. Spoiler alert: it was anything but exciting. Implementing Bluetooth turned out to be a much bigger headache than I anticipated.

First off, I overlooked a critical configuration for dynamic memory allocation, which is needed for the Wi-Fi/Bluetooth module. You have to include this in your code:

extern crate alloc;

and this on the .crate/config.toml

build-std = ["alloc", "core"]

source This setup enables dynamic memory allocation, which Bluetooth relies on. Without it, you’re dead in the water.

Next, I chose to use the async module because it seemed like a natural fit, sensors and Bluetooth both involve waiting for responses, so why not handle that asynchronously? Big mistake.

Problems

I got lured in by the esp-hal Bluetooth examples. They looked so clean and straightforward! They worked fine out of the box, but when I tried tailoring them to my project, everything fell apart. I hit a wall of issues: memory allocation errors, Bluetooth refusing to respond without throwing errors, and sudden panics that came out of nowhere.

To troubleshoot, I tried running everything async on a single core, no luck. Then I split tasks across the ESP32’s two cores: sensors on the main core and Bluetooth on the second. Still no dice. I flipped it, putting Bluetooth on the main core and sensors on the second. Guess what? That didn’t work either.

Here are the specific errors I ran into and how I wrestled with them:

Memory Overflow Error

When I tried running Bluetooth on the second core, I got this cryptic linker error:

INFO - all started well
INFO - esp-wifi configuration EspWifiConfig { rx_queue_size: 5, tx_queue_size: 3, static_rx_buf_num: 10, dynamic_rx_buf_num: 32, static_tx_buf_num: 0, dynamic_tx_buf_num: 32, ampdu_rx_enable: true, ampdu_tx_enable: true, amsdu_tx_enable: false, rx_ba_win: 6, max_burst_size: 1, country_code: "CN", country_code_operating_class: 0, mtu: 1492, tick_rate_hz: 100, listen_interval: 3, beacon_timeout: 6, ap_beacon_timeout: 300, failure_retry_cnt: 1, scan_method: 0 }
INFO - data started ERROR0 - error
panicked at /home/lucas/Projects/embedded/rusty/.cargo/registry/src/index.crates.io-6f17d22bba15001f/esp-hal-1.0.0-beta.0/src/soc/esp32/cpu_control.rs:297:38:
unwrap of `APP_CORE_STACK_TOP` failed: NoneError


error: linking with `xtensa-esp32-elf-gcc` failed: exit status: 1
  |
  = note: LC_ALL="C" PATH="/home/lucas/Projects/embedded/rusty/.rustup/toolchains/esp/lib/rustlib/x86_64-unknown-linux-gnu/bin:/home/lucas/Projects/embedded/rusty/.rustup/toolchains/esp/xtensa-esp-elf/esp-14.2.0_20240906/xtensa-esp-elf/bin:/home/lucas/Projects/embedded/rusty/.cargo/bin:/nix/store/9wwkzvmrlv43y71rbw8sbh9bamc62a16-patchelf-0.15.0/bin:/nix/store/gnd8f9h2ycxrfrvrga508c4n9cxy6720-gcc-wrapper-13.3.0/bin:/nix/store/yg4ahy7gahx91nq80achmzilrjyv0scj-gcc-13.3.0/bin:/nix/store/2kiskq24j06g4qw3xs8zsy2dkawh4gxk-glibc-2.40-66-bin/bin:/nix/store/9m68vvhnsq5cpkskphgw84ikl9m6wjwp-coreutils-9.5/bin:/nix/store/1mv8pj4nxwnd9bbxshljc9p4cnl3rakj-binutils-wrapper-2.43.1/bin:/nix/store/22qnvg3zddkf7rbpckv676qpsc2najv9-binutils-2.43.1/bin:/nix/store/vs2215qf5kv4i2rwyxzwan7xhg2h7vpr-rustup-1.27.1/bin:/nix/store/x09izmf44ykb98j60pv83lmqh1if7d73-cargo-1.82.0/bin:/nix/store/ifc4gnfbcg3jfz8fmgm0v5pqf952ddaz-espflash-3.2.0/bin:/nix/store/5rvrc5nc0xgap0xwb9mma0mqprbyq8as-espup-0.13.0/bin:/nix/store/9m68vvhnsq5cpkskphgw84ikl9m6wjwp-coreutils-9.5/bin:/nix/store/vc2d1bfy1a5y1195nq7k6p0zcm6q89nx-findutils-4.10.0/bin:/nix/store/lr3cvmq5ahqcj29p8c6m0fdl1y8krc86-diffutils-3.10/bin:/nix/store/3ks7b6p43dpvnlnxgvlcy2jaf1np37p2-gnused-4.9/bin:/nix/store/qjsj5vnbfpbg6r7jhd7znfgmcy0arn8n-gnugrep-3.11/bin:/nix/store/3p3fwczck2yn1wwfjnymzkz8w11vbvg7-gawk-5.3.1/bin:/nix/store/pmg7hw4ar16dhx5hk6jswvxy349wzsm7-gnutar-1.35/bin:/nix/store/nc394xps4al1r99ziabqvajbkrhxr5b7-gzip-1.13/bin:/nix/store/yyfzan4mn874v885jy6fs598gjb31c4l-bzip2-1.0.8-bin/bin:/nix/store/m3da56j3r7h4hp0kr8v1xsnk16x900yf-gnumake-4.4.1/bin:/nix/store/8vpg72ik2kgxfj05lc56hkqrdrfl8xi9-bash-5.2p37/bin:/nix/store/zfjv48ikkqn3yhi8zi7lvqvxyscbg87n-patch-2.7.6/bin:/nix/store/rbns8mzghhqxih4hj2js4mb4s6ivy5d1-xz-5.6.3-bin/bin:/nix/store/p751fjd81h3926ivxsq0x20lz5j7yscc-file-5.45/bin:/home/lucas/Projects/embedded/rusty/.direnv/bin:/run/wrappers/bin:/home/lucas/.local/bin:/home/lucas/.nix-profile/bin:/nix/profile/bin:/home/lucas/.local/state/nix/profile/bin:/etc/profiles/per-user/lucas/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin:/nix/store/dvq8sf2z7scj7kdhfjj157nmja6jljj9-util-linux-2.39.4-bin/bin:/nix/store/6j5p1mfbi9i7bd8q533k0cpdyky25dn5-newt-0.52.24/bin:/nix/store/14ayypkyiwsznm434msbzcp11x25rxfs-libnotify-0.8.3/bin:/nix/store/8vpg72ik2kgxfj05lc56hkqrdrfl8xi9-bash-5.2p37/bin:/nix/store/21z9i4yi42z608308jng11x3lyrslymy-systemd-256.10/bin:/nix/store/619jw38vyb39qij5h2v8il7h7gk361q3-python3-3.12.8-env/bin:/nix/store/inq52zqqmjbmij7sfi0c2gybkzmi4wxb-dmenu-5.3/bin:/nix/store/1mv8pj4nxwnd9bbxshljc9p4cnl3rakj-binutils-wrapper-2.43.1/bin:/nix/store/qc90xwxs5d4p1sbq5xjbq1i5xxxbkp77-pciutils-3.13.0/bin:/nix/store/alsqcdafvm8wlbl02qs2km1rcqaqrxhm-pkgconf-wrapper-2.3.0/bin:/nix/store/aam3hl6cbcmk2fnz81328vqwipnn3gbz-ghostty-1.1.3/bin:/home/lucas/.zsh/plugins/you-should-use" VSLANG="1033" "xtensa-esp32-elf-gcc" "/tmp/rustccT1yRa/symbols.o" "/home/lucas/Projects/embedded/rusty/target/xtensa-esp32-none-elf/release/deps/rusty-986265a68c400034.rusty.2d666b4b36ca1121-cgu.0.rcgu.o" "-Wl,--as-needed" "-Wl,-Bstatic" "/home/lucas/Projects/embedded/rusty/target/xtensa-esp32-none-elf/release/deps/libcompiler_builtins-ca3ff35d893e0bbe.rlib" "-Wl,-Bdynamic" "-lbtdm_app" "-lcoexist" "-lcore" "-lespnow" "-lmesh" "-lnet80211" "-lphy" "-lpp" "-lrtc" "-lsmartconfig" "-lwapi" "-lwpa_supplicant" "-lprintf" "-Wl,--eh-frame-hdr" "-Wl,-z,noexecstack" "-L" "/home/lucas/Projects/embedded/rusty/target/xtensa-esp32-none-elf/release/build/esp-hal-00ca8cbb607d13b5/out" "-L" "/home/lucas/Projects/embedded/rusty/target/xtensa-esp32-none-elf/release/build/esp32-14dad795dc79827b/out" "-L" "/home/lucas/Projects/embedded/rusty/target/xtensa-esp32-none-elf/release/build/xtensa-lx-rt-30285b357ae9b315/out" "-L" "/home/lucas/Projects/embedded/rusty/target/xtensa-esp32-none-elf/release/build/esp-wifi-sys-cb7db6bc8f573731/out" "-o" "/home/lucas/Projects/embedded/rusty/target/xtensa-esp32-none-elf/release/deps/rusty-986265a68c400034" "-Wl,--gc-sections" "-no-pie" "-nodefaultlibs" "-Wl,-Tlinkall.x" "-nostartfiles"
  = note: /home/lucas/Projects/embedded/rusty/.rustup/toolchains/esp/xtensa-esp-elf/esp-14.2.0_20240906/xtensa-esp-elf/bin/../lib/gcc/xtensa-esp-elf/14.2.0/../../../../xtensa-esp-elf/bin/ld: /home/lucas/Projects/embedded/rusty/target/xtensa-esp32-none-elf/release/deps/rusty-986265a68c400034 section `.bss' will not fit in region `dram_seg'
          /home/lucas/Projects/embedded/rusty/.rustup/toolchains/esp/xtensa-esp-elf/esp-14.2.0_20240906/xtensa-esp-elf/bin/../lib/gcc/xtensa-esp-elf/14.2.0/../../../../xtensa-esp-elf/bin/ld: region `dram_seg' overflowed by 3752 bytes
          collect2: error: ld returned 1 exit status

This error means the linker couldn’t fit all the static data (stored in the .bss section) into the ESP32’s DRAM memory region (dram_seg). My program was using too much memory, 3752 bytes more than available. This likely happened because Bluetooth and sensor data buffers were hogging static memory. To fix it, I needed to optimize memory usage, so I reduced buffer sizes.

LoadProhibited Exception

Another head-scratcher was this runtime panic:

ERROR - error panicked at /home/lucas/Projects/embedded/rusty/.cargo/registry/src/index.crates.io-6f17d22bba15001f/xtensa-lx-rt-0.18.0/src/exception/context.rs:148:5:
Exception: LoadProhibited, Context { PC: 400e4234, PS: 00060430, A0: 800d4a33, A1: 3ffda850, A2: 00000000, A3: 000ca5f8, A4: ffffffff, A5: 00000000, A6: 00000001, A7: 00000000, A8: ffffffff, A9: 000018da, A10: 3ff5f078, A11: 002dc6c0, A12: 0000ffff, A13: 3ff5f07c, A14: 0000001c, A15: 0000a9e8, SAR: 00000004, EXCCAUSE: 0000001c, EXCVADDR: ffffffff, LBEG: 4000c2e0, LEND: 4000c2f6, LCOUNT: 00000000, THREADPTR: 00000000, SCOMPARE1: 00000100, BR: 00000000, ACCLO: 00000000, ACCHI: 00000000, M0: 00000000, M1: 00000000, M2: 00000000, M3: 00000000, F64R_LO_CPENABLE: 00000000, F64R_HI: 00000000, F64S: 00000000, FCR: 00000000, FSR: 00000000, F0: 00000000, F1: 00000000, F2: 00000000, F3: 00000000, F4: 00000000, F5: 00000000, F6: 00000000, F7: 00000000, F8: 00000000, F9: 00000000, F10: 00000000, F11: 00000000, F12: 00000000, F13: 00000000, F14: 00000000, F15: 00000000 }

The LoadProhibited exception occurs when the CPU tries to read from an invalid memory address. This could be due to a null pointer dereference or accessing unallocated memory, possibly from a misconfigured Bluetooth stack or a bug in my async code. Who knows.

Broken Pipe Error

After switching Bluetooth to the first core and sensors to the second, I hit this:

INFO - esp-wifi configuration EspWifiConfig { rx_queue_size: 5, tx_queue_size: 3, static_rx_buf_num: 10, dynamic_rx_buf_num: 32, static_tx_buf_num: 0, dynamic_tx_buf_num: 32, ampdu_rx_enable: true, ampdu_tx_enable: true, amsdu_tx_enable: false, rx_ba_win: 6, max_burst_size: 1, country_code: "CN", country_code_operating_class: 0, mtu: 1492, tick_rate_hz: 100, listen_interval: 3, beacon_timeout: 6, ap_beacon_timeout: 300, failure_retry_cnt: 1, scan_method: 0 }
INFO - bluetooth started 0
INFO - COEX Version 2.0.0
Error:   × Broken pipe

I resorted to the ancient art of “turn it off and on again,” and it worked… briefly. I suspected the Wi-Fi/Bluetooth module needed a heap that the second core couldn’t access, but that was a guess. Moving Bluetooth to the main core magically resolved this.

StoreProhibited Exception

With Bluetooth, screen display, and rolling medians all allocating memory, I hit another panic:

ERROR - big error happened panicked at /home/lucas/Projects/embedded/rusty/.cargo/registry/src/index.crates.io-6f17d22bba15001f/xtensa-lx-rt-0.18.0/src/exception/context.rs:148:5:
Exception: StoreProhibited, Context { PC: 40081681, PS: 00060835, A0: 800e1a5d, A1: 3ffda270, A2: 00000000, A3: 00002710, A4: 3ffc7cdc, A5: 00000001, A6: 400df510, A7: 000091b0, A8: ffffffff, A9: ffffffff, A10: 00060820, A11: 00000002, A12: 00000008, A13: 00000001, A14: 3ffda2c4, A15: 00000000, SAR: 00000004, EXCCAUSE: 0000001d, EXCVADDR: ffffffff, LBEG: 4000c46c, LEND: 4000c477, LCOUNT: 00000000, THREADPTR: 00000000, SCOMPARE1: 00000100, BR: 00000000, ACCLO: 00000000, ACCHI: 00000000, M0: 00000000, M1: 00000000, M2: 00000000, M3: 00000000, F64R_LO_CPENABLE: 00000000, F64R_HI: 00000000, F64S: 00000000, FCR: 00000000, FSR: 00000000, F0: 00000000, F1: 00000000, F2: 00000000, F3: 00000000, F4: 00000000, F5: 00000000, F6: 00000000, F7: 00000000, F8: 00000000, F9: 00000000, F10: 00000000, F11: 00000000, F12: 00000000, F13: 00000000, F14: 00000000, F15: 00000000 }

i had to :

esp_alloc::heap_allocator!(#[link_section = ".dram2_uninit"] size: 72 * 1024);

The StoreProhibited exception (EXCCAUSE: 0x1D) happens when the CPU tries to write to an invalid memory address. My program was running out of heap memory for Bluetooth. This macro allocates a 72KB heap in the uninitialized DRAM section (.dram2_uninit), giving Bluetooth the dynamic memory it needed. It was a lifesaver, but I need to brush up on ESP32 memory management.

Mysterious Bluetooth Crash

I also stumbled across this error, which left me baffled:

INFO - Starting control_led() on core 1
INFO - COEX Version 2.0.0
WARN - coex_register_bt_cb 0x4008306c
0x4008306c - coex_bt_callback
    at ??:??
WARN - coex_schm_register_btdm_callback 0x400e400c
0x400e400c - coex_schm_btdm_callback_v1
    at ??:??
WARN - coex_wifi_channel_get
INFO - Connector created
INFO - Ok(CommandComplete { num_packets: 5, opcode: 3075, data: [0] })
INFO - Ok(CommandComplete { num_packets: 5, opcode: 8198, data: [0] })
INFO - Ok(CommandComplete { num_packets: 5, opcode: 8200, data: [0] })
INFO - Ok(CommandComplete { num_packets: 5, opcode: 8202, data: [0] })
INFO - started advertising
WARN - Ignoring unknown le-meta event 03 data = [00, 00, 00, 06, 00, 00, 00, f4, 01]
WARN - Ignoring unknown le-meta event 03 data = [00, 00, 00, 18, 00, 00, 00, f4, 01]
INFO - RECEIVED: 0 [255]
INFO - Sending LED off
INFO - LED off
nb:255,status:0x0088,done:0,hdl:8
nb:255,status:0x0088,done:0,hdl:49152
nb:255,status:0x0088,done:0,hdl:49152
nb:255,status:0x0088,done:0,hdl:49152
ASSERT_PARAM(1 2), in lld_evt.c at line 2371
ERROR - big error happened panicked at /home/lucas/Projects/embedded/rusty/.cargo/registry/src/index.crates.io-6f17d22bba15001f/xtensa-lx-rt-0.18.0/src/exception/context.rs:148:5:
Exception: Illegal, Context { PC: 4008a8d7, PS: 00060411, A0: 800833ba, A1: 3ffdbca0, A2: 00000000, A3: 00000001, A4: 00000002, A5: 3f4118fe, A6: 00000943, A7: fffffffb, A8: 8000814b, A9: 3ffdbc10, A10: 00000000, A11: 3ffdbc34, A12: 3ffdbbdf, A13: 00000031, A14: 00000000, A15: 3ffdbbe5, SAR: 00000004, EXCCAUSE: 00000000, EXCVADDR: 00000000, LBEG: 400012c5, LEND: 400012d5, LCOUNT: fffffffe, THREADPTR: 00000000, SCOMPARE1: 00000100, BR: 00000000, ACCLO: 00000000, ACCHI: 00000000, M0: 00000000, M1: 00000000, M2: 00000000, M3: 00000000, F64R_LO_CPENABLE: 00000000, F64R_HI: 00000000, F64S: 00000000, FCR: 00000000, FSR: 00000080, F0: 433b8000, F1: 426c0000, F2: 43800000, F3: 00000000, F4: 00000000, F5: 00000000, F6: 00000000, F7: 00000000, F8: 00000000, F9: 00000000, F10: 00000000, F11: 00000000, F12: 00000000, F13: 00000000, F14: 00000000, F15: 00000000 }

Weirdly, this error vanished on its own. It seems tied to the Bluetooth stack (something about lld_evt.c and an Illegal exception), but I didn’t dig deeper since it stopped haunting me.

After all this, I realized my async dreams were doomed. Running Bluetooth on the main core and keeping things synchronous finally got things stable.

Result

My dreams of a sleek async implementation went up in flames. The Bluetooth code would start, loop indefinitely, and then vanish without a trace—no timeout, no error, just silence. I also tried the Embassy framework’s Bluetooth stack, hoping for a smoother ride, but their examples required patching packages, which felt like more trouble than it was worth. So, I waved the white flag on async. Instead, I switched to a synchronous approach and leveraged the ESP32’s second core to handle sensor data collection.

Here’s the setup: Bluetooth runs on the main core, serving notifications to any device that wants to listen. Sensor data (temperature, humidity, CO2, eCO2, and TVOC) is collected on the second core and sent to the main core via an embassy_sync channel.

The code looks like this:

let data = SensorsData {
    temperature: temperature_average.median(),
    humidity: humidity_average.median(),
    co2: c02_average.median(),
    eco2: eco2_average.median(),
    tvoc: tvoc_average.median(),
};
if let Err(_err) = SENSOR_CHANNEL.try_send(data) {
    SENSOR_CHANNEL.clear();
};

On the main core, Bluetooth reads the channel and sends the data:

loop {
    let notification = match SENSOR_CHANNEL.try_receive() {
        Ok(data) => {
            let bytes = data.into_bytes();
            Some(NotificationData::new(my_characteristic_handle, &bytes))
        }
        Err(_err) => None,
    };

    match srv.do_work_with_notification(notification) {
        Ok(res) => {
            if let WorkResult::GotDisconnected = res {
                break;
            }
        }
        Err(err) => {
            log::error!("{:?}", err);
        }
    }
}

Is this the most elegant setup? Probably not, but it gets the job done. I can read the data from any Bluetooth device, and I wrote a script to pull the data and display it on my Waybar (I use NixOs btw). The data also shows up on the OLED screen, which is a nice touch:

The setup looks a bit rough with all those exposed cables, so my next step is to get a 3D printer and design a case to make it look less like a science experiment gone wild. Soldering the connections properly will also help tidy things up.

That’s all, folks! Thanks for following my journey through the ups and downs of building this air quality monitor. I hope you had as much fun reading about it as I (sometimes) had building it!