M5Stick S3 · Volume 10

M5Stack M5StickS3 Volume 10 — Custom Firmware Development

Worked wearable-scanner example, Evil-M5Project fork patterns, MicroHydra apps, stick-form-factor UI considerations

Contents

SectionTopic
1About this volume
2Forking Evil-M5Project for M5StickS3
3Writing a MicroHydra app for M5StickS3
4Stick-form-factor UI considerations
5Worked example — wearable Wi-Fi scanner + audio log
6Cherry-picking features from Cardputer ADV firmwares
7Audio-specific firmware patterns
8Releasing a fork
9Common build errors
10Resources

1. About this volume

Vol 10 is for the user who wants to modify firmware for the M5StickS3. The build mechanics are identical to Cardputer ADV (Vol 10 there) — PlatformIO + M5Unified + ESP32-S3 toolchain.

The M5StickS3-specific deltas:

  1. Smaller screen + button-only UI design (vs Cardputer ADV’s QWERTY + larger screen)
  2. Audio-as-a-first-class-feature (firmware should consider how to use the audio chain)
  3. The OPI PSRAM build flag (board_build.arduino.memory_type = qio_opi)
  4. 8 MB PSRAM enables larger buffers / wake-word models / audio buffers — design firmware to use this

2. Forking Evil-M5Project for M5StickS3

If upstream Evil-M5Project lacks dedicated M5StickS3 support, fork and add:

  1. New PlatformIO build env:
[env:m5sticks3]
platform = [email protected]
board = esp32-s3-devkitc-1
framework = arduino
upload_speed = 1500000
monitor_speed = 115200
board_build.partitions = default_8MB.csv
board_build.arduino.memory_type = qio_opi          ; ← REQUIRED
build_flags =
    -DBOARD_HAS_PSRAM
    -DARDUINO_USB_CDC_ON_BOOT=1
    -DARDUINO_USB_MODE=1
    -DESP32S3
    -DCORE_DEBUG_LEVEL=3
lib_deps =
    https://github.com/m5stack/M5Unified.git
    https://github.com/m5stack/M5GFX.git
  1. Pin remapping for M5StickS3-specific display, buttons, IR pins. M5Unified handles most of this automatically; only manual pin definitions when bypassing M5Unified.

  2. UI re-flow for 135×240 portrait (Cardputer ADV is 240×135 landscape):

    • Reduce columns of text per row (~22 chars vs Cardputer’s 40)
    • Increase font sizes for menu items
    • 5-6 menu items per screen max
    • Larger button hit areas (touchscreen-style padding, even though no touch)
  3. Button-only menu pattern (no QWERTY):

    • Button A = advance / select
    • Button B = back / cancel
    • Long-press A or B for chord commands
    • Power button for sleep/wake (firmware can’t intercept long-press shutdown)
  4. Audio integration:

    • Beep on menu navigation
    • Voice prompts for menu items (if implementing text-to-speech)
    • Audio recording as a first-class feature menu
  5. Build, flash, testpio run -e m5sticks3 -t upload.

  6. PR upstream if generally useful, or maintain personal fork.


3. Writing a MicroHydra app for M5StickS3

MicroHydra apps are Python files on SD-via-Hat2 (or internal flash) at /apps/<AppName>/__init__.py.

Minimum viable app:

from lib.hydra.app import App
from lib.display import Display
from lib.audio import Speaker
import time

class WearableScanner(App):
    name = "Wearable Scanner"
    icon = "radar"
    description = "Background Wi-Fi probe-request capture"

    def __init__(self):
        super().__init__()
        self.display = Display()
        self.speaker = Speaker()
        self.capture_count = 0

    def main(self):
        self.display.clear()
        self.display.text("Wearable Scanner", 10, 10)
        self.display.text("Press A to start", 10, 30)

        running = False
        while not self.exit_pressed():
            if self.btn_a_pressed():
                if not running:
                    running = True
                    self.speaker.tone(880, 100)
                    self.start_wifi_scan()
                else:
                    running = False
                    self.speaker.tone(440, 100)
                    self.stop_wifi_scan()

            if running:
                self.display.text(f"Captures: {self.capture_count}", 10, 50)

            self.tick()
            time.sleep_ms(100)

    def start_wifi_scan(self):
        # ... initialize promiscuous mode, install callback
        pass

    def stop_wifi_scan(self):
        # ... save captures to SD-via-Hat2 / internal flash
        pass

Drop on SD (or upload via mpremote) → restart MicroHydra → app appears on home screen.

Iterate live with mpremote:

mpremote connect /dev/ttyACM0 cp __init__.py :/apps/WearableScanner/__init__.py
mpremote connect /dev/ttyACM0 exec "import machine; machine.reset()"

Edit-test cycle in 2-3 seconds.


4. Stick-form-factor UI considerations

Design rules for M5StickS3 firmware:

Display constraints:

  • 135×240 portrait (or 240×135 landscape via setRotation)
  • ~22 columns × 30 rows at default 6×8 font (portrait)
  • Max 5-6 menu items per screen for readability
  • Larger text for at-distance viewing (M5StickS3 may be worn on wrist)

Input constraints:

  • 2 programmable buttons + power button (HW shutdown)
  • No QWERTY — text input is limited
  • IMU gesture as alt input for hands-busy scenarios (shake, tilt, flick)

Audio output (the unique value-add):

  • Beep on button press — non-visual feedback for blind operation
  • Audio confirmation for menu selections (synthesized tones)
  • Voice prompts for menu items (if implementing TTS)

Battery indicator persistent:

  • 250 mAh limit makes battery state critical
  • Persistent battery % in corner of every screen
  • Color-code: green > 60%, yellow 30-60%, red < 30%
  • Audio alert when < 10%

IMU-augmented navigation:

  • Shake to advance menu (when buttons are unavailable)
  • Tilt to scroll long lists
  • Wake-word activation for menus (if esp-skainet is integrated)

Display orientation:

  • Default portrait (135 wide × 240 tall) — natural for wrist wear
  • Landscape (240 wide × 135 tall) — for screens with more horizontal content

Sleep / wake behavior:

  • Backlight off after N seconds of inactivity (saves battery)
  • Button A or shake to wake
  • Power button = real shutdown

5. Worked example — wearable Wi-Fi scanner + audio log

The flagship worked example. Combines:

  • Wi-Fi probe-request capture (passive scan)
  • Audio recording (ambient audio with the engagement)
  • Both running concurrently using both ESP32-S3 cores
  • Power-aware operation for 30-60 minute battery life

Use case: leave the M5StickS3 running in a pocket / magnetic-mounted during a target visit. Records who’s there (probe requests) and ambient audio (with authorization).

Code skeleton (~150 lines, abridged):

#include <Arduino.h>
#include <M5Unified.h>
#include <SPIFFS.h>
#include <esp_wifi.h>

// Audio config
const int AUDIO_SAMPLE_RATE = 16000;
const size_t AUDIO_BUFFER_SIZE = 1024 * 1024;   // 1 MB in PSRAM

// Wi-Fi capture buffer
const int MAX_PROBE_REQUESTS = 1000;
struct ProbeRequest {
    uint8_t src_mac[6];
    char ssid[33];
    int rssi;
    uint32_t timestamp_ms;
};

int16_t *audio_buffer;
size_t audio_offset = 0;
File audio_file;

ProbeRequest probes[MAX_PROBE_REQUESTS];
volatile int probe_count = 0;

File probe_csv;
bool running = false;

// Promiscuous-mode callback (runs in IRQ context)
void IRAM_ATTR promisc_cb(void *buf, wifi_promiscuous_pkt_type_t type) {
    if (type != WIFI_PKT_MGMT) return;
    wifi_promiscuous_pkt_t *pkt = (wifi_promiscuous_pkt_t *)buf;
    uint8_t *payload = pkt->payload;
    uint8_t subtype = (payload[0] >> 4) & 0x0F;
    if (subtype != 4) return;   // Not a probe request

    if (probe_count >= MAX_PROBE_REQUESTS) return;

    // Extract probe-request fields
    int idx = probe_count++;
    memcpy(probes[idx].src_mac, &payload[10], 6);
    int8_t rssi = pkt->rx_ctrl.rssi;
    probes[idx].rssi = rssi;
    probes[idx].timestamp_ms = millis();

    // Extract SSID (in frame body)
    uint8_t ssid_len = payload[25];   // SSID length tag
    if (ssid_len > 0 && ssid_len <= 32) {
        memcpy(probes[idx].ssid, &payload[26], ssid_len);
        probes[idx].ssid[ssid_len] = '\0';
    } else {
        probes[idx].ssid[0] = '\0';
    }
}

void setup() {
    auto cfg = M5.config();
    M5.begin(cfg);
    SPIFFS.begin();

    // Allocate audio buffer in PSRAM
    audio_buffer = (int16_t *)ps_malloc(AUDIO_BUFFER_SIZE);
    if (!audio_buffer) {
        M5.Display.println("PSRAM alloc failed!");
        while (1) delay(100);
    }

    // Initialize Wi-Fi for promiscuous mode
    nvs_flash_init();
    wifi_init_config_t cfg_wifi = WIFI_INIT_CONFIG_DEFAULT();
    esp_wifi_init(&cfg_wifi);
    esp_wifi_set_storage(WIFI_STORAGE_RAM);
    esp_wifi_set_mode(WIFI_MODE_NULL);
    esp_wifi_start();

    M5.Display.println("Wearable Scanner");
    M5.Display.println("Press A to start");

    M5.Mic.begin();
}

void start_capture() {
    char fname[64];
    snprintf(fname, sizeof(fname), "/scan_%lu.csv", millis());
    probe_csv = SPIFFS.open(fname, "w");
    probe_csv.println("timestamp,src_mac,ssid,rssi");

    snprintf(fname, sizeof(fname), "/audio_%lu.wav", millis());
    audio_file = SPIFFS.open(fname, "w");
    // Write WAV header (44 bytes typical RIFF)
    // ... (omitted for brevity)

    esp_wifi_set_promiscuous(true);
    esp_wifi_set_promiscuous_rx_cb(promisc_cb);

    audio_offset = 0;
    running = true;
}

void stop_capture() {
    esp_wifi_set_promiscuous(false);

    // Flush probe captures
    for (int i = 0; i < probe_count; i++) {
        probe_csv.printf("%lu,%02X:%02X:%02X:%02X:%02X:%02X,%s,%d\n",
                         probes[i].timestamp_ms,
                         probes[i].src_mac[0], probes[i].src_mac[1], probes[i].src_mac[2],
                         probes[i].src_mac[3], probes[i].src_mac[4], probes[i].src_mac[5],
                         probes[i].ssid, probes[i].rssi);
    }
    probe_csv.close();

    // Flush audio buffer to file
    audio_file.write((uint8_t *)audio_buffer, audio_offset);
    // Patch WAV header byte-count fields
    // ... (omitted)
    audio_file.close();

    running = false;
    probe_count = 0;
}

void loop() {
    M5.update();

    if (M5.BtnA.wasPressed()) {
        if (!running) {
            start_capture();
            M5.Speaker.tone(880, 100);
        } else {
            stop_capture();
            M5.Speaker.tone(440, 100);
        }
    }

    if (running) {
        // Record audio chunks
        int16_t buf[256];
        M5.Mic.record(buf, 256);

        size_t bytes_to_copy = 256 * sizeof(int16_t);
        if (audio_offset + bytes_to_copy < AUDIO_BUFFER_SIZE) {
            memcpy(audio_buffer + (audio_offset / 2), buf, bytes_to_copy);
            audio_offset += bytes_to_copy;
        }

        // Display status
        M5.Display.setCursor(10, 50);
        M5.Display.printf("Probes: %d ", probe_count);
        M5.Display.setCursor(10, 70);
        M5.Display.printf("Audio: %d s    ", audio_offset / (2 * AUDIO_SAMPLE_RATE));
        M5.Display.setCursor(10, 90);
        M5.Display.printf("Battery: %d%% ", M5.Power.getBatteryLevel());
    }

    delay(50);
}

Key design choices:

  • PSRAM for audio buffer: 1 MB buffer holds ~30 seconds of audio at 16 kHz. Without PSRAM, capping at 256 KB internal RAM = ~8 seconds.
  • IRQ-context probe capture: fast packet handler; main loop drains.
  • Concurrent operation: both ESP32-S3 cores used — one for Wi-Fi RX callback + audio I²S, one for main loop + display.
  • Audio sample rate 16 kHz mono: balance of quality and storage.
  • Display updates throttled at 20 fps to avoid hogging the SPI bus.

Power profile: ~150-180 mA average (Wi-Fi RX + audio active). 250 mAh battery → ~80-100 minutes runtime.

Legal posture: voice recording with people present in two-party-consent jurisdictions = criminal. Authorized use only. See Vol 5 § 10 + Vol 11 § 7.


6. Cherry-picking features from Cardputer ADV firmwares

Many Cardputer ADV-targeted features port directly to M5StickS3:

  • Bruce attack modules — port from Cardputer ADV’s Bruce, adapt UI for stick form factor + button-only input
  • Evil-Portal templates — HTML works unchanged; just smaller display for confirmation UI
  • MicroHydra apps — most work directly; some need pin remapping or UI re-flow
  • Custom Wi-Fi attacks — same ESP32-S3 silicon, same Wi-Fi API; portable

Process:

  1. Identify the feature in Cardputer ADV firmware source (typically src/modules/<category>/).
  2. Copy .cpp + .h to M5StickS3 firmware tree.
  3. Add #include statements.
  4. Adjust UI calls — Cardputer ADV M5Cardputer.Display.* → M5StickS3 M5.Display.* (M5Unified provides both transparently in newer versions).
  5. Adapt menu UI to button-only navigation.
  6. Build + test.

Time budget: 30-90 minutes for typical feature ports, depending on dependency depth.


7. Audio-specific firmware patterns

M5StickS3-unique firmware patterns leveraging the audio subsystem:

  • ESP-NOW walkie-talkie — push-to-talk audio over Wi-Fi raw frames (Vol 5 § 7)
  • Audio FFT visualizer — real-time 16-band bar graph (Vol 5 § 5)
  • Wake-word activated menus — esp-skainet Multinet5 + custom command vocabulary (Vol 5 § 6)
  • Voice memo recorder — ES8311 → flash workflow (Vol 5 § 4)
  • Audio-attack patterns — synthesized tones that interact with target audio devices (research/education only)
    • DTMF dial generation
    • Specific-frequency tone for testing audio receivers
    • Audio watermark injection
  • Internet radio — Wi-Fi stream → ES8311 → speaker (Vol 5 § 8)

Pattern: each audio firmware uses the same hardware chain — only the data source and processing differ. Frameworks (esp_codec_dev, M5Unified M5.Speaker/M5.Mic) abstract the codec details.


8. Releasing a fork

Standard release pattern:

  • Semantic versioning: v1.0.0, v1.0.1, etc.
  • CHANGELOG.md in keep-a-changelog convention (https://keepachangelog.com)
  • GitHub Releases: per-tag binary attachments (.bin files for M5StickS3)
  • Web flasher: host on GitHub Pages (or Vercel / Netlify / Cloudflare Pages)
  • README + installation instructions

License: most M5Stack-derived firmwares are GPLv3 or AGPLv3. Honor the upstream license.


9. Common build errors

SymptomCauseFix
PSRAM getFreePsram() returns 0Missing board_build.arduino.memory_type = qio_opiAdd to platformio.ini
Wrong device path in flash commandM5StickS3 uses /dev/ttyACM0 (USB-CDC), not /dev/ttyUSB0Update path
Display init failsWrong driver — ST7789P3 vs ST7789V2 distinctionM5Unified handles transparently; if using raw driver, specify P3
Audio not workingES8311 not initialized via M5UnifiedUse M5.config() + M5.begin(cfg) properly
IR not transmittingWrong GPIO assumed (different from Cardputer ADV)Verify M5StickS3 IR pinout via M5Unified source
Linker error multiple definition of 'M5'Multiple .cpp files instantiate M5UnifiedOnly one entry-point file should instantiate
arduino-esp32 version conflictMismatched core versionpio platform update espressif32
Build OOMs on small VMInsufficient RAM during compileAdd -j1 to limit parallelism
Brownout reboot loopInadequate USB power during bootBetter USB cable; check battery
Compile fails on M5UnifiedLibrary version driftPin specific M5Unified version in lib_deps
USB Serial output emptyMissing -DARDUINO_USB_CDC_ON_BOOT=1Add to build_flags
Custom audio recording captures silenceES8311 init order wrong; mic gain not setCheck esp_codec_dev init sequence

10. Resources

Cross-references

  • Parallel build-toolchain coverage: Cardputer ADV Vol 10 at ../../../M5Stack Cardputer ADV/03-outputs/Cardputer_ADV_Complete.html
  • Audio API deep dive: Vol 5
  • Programming environment setup: Vol 7
  • GPIO assignments for custom code: Vol 3 § 2

This is Volume 10 of a twelve-volume series. Next: Vol 11 covers operational posture — 250 mAh battery realism, thermal under 1 W speaker, audio-bug legal landscape, Espressif OUI, chain-of-custody.