M5Stack Cardputer ADV · Volume 10

M5Stack Cardputer ADV Volume 10 — Custom Firmware Development

Worked example, source modifications, fork strategy, contribution workflow

Contents

SectionTopic
1About this volume
2Adding a custom payload module to Bruce
3Writing your first MicroHydra app
4Modifying M5Launcher itself
5Worked example — Wi-Fi channel survey on Cardputer ADV
6Cherry-picking features across forks
7Fleet-flash with compile-time defaults
8Self-hosting an M5Launcher catalog
9Contributing upstream
10Common build errors
11Resources

1. About this volume

Vol 10 is for the user who wants to modify firmware, not just use it. Five concrete worked examples — adding a Bruce module, writing a MicroHydra app, modifying M5Launcher, building a custom Wi-Fi scan from scratch, cherry-picking features across forks. Plus fleet-flash patterns, self-hosted catalog setup, and the contribution-upstream workflow.

Cross-reference: this volume parallels ../../../ESP32 Marauder Firmware/03-outputs/ESP32_Marauder_Firmware_Complete.html Vol 10 — the build-system mechanics are identical (both are PlatformIO-based, both target ESP32-S3, both use Arduino framework). The Cardputer-ADV-specific deltas are: M5Cardputer library (not raw arduino-esp32), TCA8418 keyboard scanner (not buttons), ST7789V2 display (not generic TFT), specific GPIO assignments per Vol 3.


2. Adding a custom payload module to Bruce

Goal: add a new attack/utility module to Bruce that’s not in the upstream catalog.

Process:

  1. Fork Bruce on GitHub: github.com/BruceDevices/firmware.
  2. Clone locally: git clone https://github.com/<your-username>/firmware.git.
  3. Open in PlatformIO (VS Code recommended).
  4. Source tree:
firmware/
├── platformio.ini
├── src/
│   ├── main.cpp
│   ├── core/
│   │   ├── menu.cpp           ← top-level menu registry
│   │   └── settings.cpp
│   └── modules/
│       ├── wifi/              ← Wi-Fi attack modules
│       ├── ble/               ← BLE attack modules
│       ├── ir/                ← IR modules
│       │   ├── tv_b_gone.cpp  ← template for new IR module
│       │   └── ...
│       ├── subghz/            ← Sub-GHz modules
│       ├── rfid/              ← RFID modules
│       └── badusb/            ← BadUSB modules
├── data/                      ← Evil Portal templates, etc.
└── include/                   ← headers
  1. Add a new module: copy src/modules/ir/tv_b_gone.cpp to src/modules/ir/apple_tv_off.cpp. Edit the code list — for Apple TV NEC remote (address 0x77E1):
const uint16_t apple_tv_codes[] = {
    0x77E1, 0x40,    // Address + Power command (NEC 32-bit)
    0x77E1, 0x05,    // Address + Menu command
    0x77E1, 0xA0,    // Address + Select command
    // ... add more codes as needed
};
  1. Register in menu: edit src/core/menu.cpp:
// In the IR submenu definition:
MenuItem ir_menu[] = {
    {"TV-B-Gone", &tv_b_gone_start, IR_ICON},
    {"AC-B-Gone", &ac_b_gone_start, IR_ICON},
    {"Apple TV Off", &apple_tv_off_start, IR_ICON},    // ← NEW ENTRY
    // ...
};
  1. Build: pio run -e cardputer-adv -t upload.
  2. Test: device boots into Bruce, IR submenu shows new “Apple TV Off” entry.
  3. PR upstream (if generally useful) or maintain personal fork.

3. Writing your first MicroHydra app

Goal: create a custom MicroPython app that runs under MicroHydra’s app-switcher.

Process:

  1. Format SD with FAT32. Create /apps/HelloWorld/ directory on SD.
  2. Create __init__.py in that directory:
from lib.hydra.app import App
from lib.display import Display
from lib.keyboard import Keyboard
import time

class HelloWorld(App):
    name = "Hello World"
    icon = "rocket"
    description = "My first MicroHydra app"

    def __init__(self):
        super().__init__()
        self.display = Display()
        self.keyboard = Keyboard()
        self.counter = 0

    def main(self):
        self.display.clear()
        while not self.exit_pressed():
            self.display.text(f"Hello! Counter: {self.counter}", 10, 10)
            self.counter += 1

            # Read keyboard
            key = self.keyboard.read()
            if key == 'r':
                self.counter = 0    # Reset on 'r' key

            time.sleep_ms(100)
            self.tick()    # Let MicroHydra handle background tasks
  1. Restart MicroHydra: pull power, replace SD, power on.
  2. Navigate to your app: home screen → scroll to “Hello World” → select.
  3. App runs: screen shows incrementing counter; pressing ‘r’ resets; pressing Esc exits.

Edit-test loop with mpremote (faster than SD card swap):

# After initial deployment, edit live
mpremote connect /dev/ttyACM0 cp __init__.py :/apps/HelloWorld/__init__.py
mpremote connect /dev/ttyACM0 exec "import machine; machine.reset()"

Iterate in 2-3 seconds. Once the app stabilizes, copy to SD permanently.


4. Modifying M5Launcher itself

Common modifications and their files:

ModFileChange
Default Wi-Fi credentialssrc/settings.cppEdit setupSDCard() defaults
Custom catalog URLinclude/sd_functions.hChange CATALOG_URL macro
Default color themesrc/themes/default_theme.hEdit RGB565 hex palette (bg / fg / accent / highlight)
Add a top-level menu itemsrc/core/menu.cpp + add icon BMP to src/data/icons/Add MenuItem entry + icon
Add a Tools submenu entrysrc/core/tools.cppAdd switch-case enum + handler
Add a language (i18n)src/i18n/Add xx_XX.h with string table + register in i18n.cpp
Disable BLE (save ~80 KB binary)platformio.iniDrop -DUSE_BLE flag
Boot straight into a specific appsrc/main.cppPatch the boot_picker function

Build + flash: pio run -e m5stack-cardputer-adv -t upload. Test. Iterate.

RGB565 color picker: M5Launcher themes use RGB565 16-bit color. Convert RGB888 to RGB565:

def rgb888_to_rgb565(r, g, b):
    return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)

# Example: orange (#FF6B35) = 0xFB67
print(hex(rgb888_to_rgb565(0xFF, 0x6B, 0x35)))

Online RGB888→RGB565 converters are also fine.


5. Worked example — Wi-Fi channel survey on Cardputer ADV

Goal: write a Cardputer ADV firmware from scratch that scans each 2.4 GHz Wi-Fi channel for 5 seconds, counts beacons + probe requests per channel, dumps the counts to SD as a CSV. Demonstrate the full Arduino + PlatformIO + M5Cardputer development loop.

Setup:

# Create project
mkdir cardputer-channel-survey
cd cardputer-channel-survey
pio init -b esp32-s3-devkitc-1 -d .

# Edit platformio.ini

platformio.ini:

[env:cardputer-adv]
platform = espressif32@6.7.0
board = esp32-s3-devkitc-1
framework = arduino
upload_speed = 1500000
monitor_speed = 115200
board_build.partitions = default_8MB.csv
build_flags =
    -DBOARD_HAS_PSRAM=0
    -DARDUINO_USB_CDC_ON_BOOT=1
    -DARDUINO_USB_MODE=1
lib_deps =
    https://github.com/m5stack/M5Cardputer.git
    https://github.com/m5stack/M5Unified.git

src/main.cpp:

#include <Arduino.h>
#include <M5Cardputer.h>
#include <SD.h>
#include <esp_wifi.h>

// Per-channel statistics
struct ChannelStats {
    uint32_t beacons;
    uint32_t probe_requests;
};
ChannelStats stats[15];   // Indices 1-14
volatile int current_channel = 1;

// 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 frame_type = (payload[0] >> 2) & 0x03;
    uint8_t subtype = (payload[0] >> 4) & 0x0F;

    if (frame_type != 0) return;   // Not a management frame

    int ch = current_channel;
    if (ch >= 1 && ch <= 14) {
        if (subtype == 8)        stats[ch].beacons++;       // Beacon
        else if (subtype == 4)   stats[ch].probe_requests++;  // Probe request
    }
}

void setup() {
    auto cfg = M5.config();
    M5Cardputer.begin(cfg, true);
    M5Cardputer.Display.fillScreen(BLACK);
    M5Cardputer.Display.setTextSize(2);
    M5Cardputer.Display.setCursor(10, 10);
    M5Cardputer.Display.println("Channel Survey");
    M5Cardputer.Display.setTextSize(1);

    // Wi-Fi 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();
    esp_wifi_set_promiscuous(true);
    esp_wifi_set_promiscuous_rx_cb(&promisc_cb);

    // SD
    SD.begin(12);
}

void loop() {
    // Walk channels 1-14, 5 seconds each
    for (int ch = 1; ch <= 14; ch++) {
        current_channel = ch;
        esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE);

        M5Cardputer.Display.setCursor(10, 50);
        M5Cardputer.Display.printf("Channel %2d  beacons:%6u  probes:%6u ",
                                    ch, stats[ch].beacons, stats[ch].probe_requests);
        delay(5000);
    }

    // After full pass, write to SD
    char fname[64];
    snprintf(fname, sizeof(fname), "/survey_%lu.csv", millis());
    File f = SD.open(fname, FILE_WRITE);
    if (f) {
        f.println("channel,beacons,probe_requests");
        for (int ch = 1; ch <= 14; ch++) {
            f.printf("%d,%u,%u\n", ch, stats[ch].beacons, stats[ch].probe_requests);
        }
        f.close();
        M5Cardputer.Display.setCursor(10, 100);
        M5Cardputer.Display.printf("Wrote %s\n", fname);
    } else {
        M5Cardputer.Display.setCursor(10, 100);
        M5Cardputer.Display.println("SD write failed");
    }

    delay(60000);   // Pause 1 minute before next sweep
}

Build: pio run -e cardputer-adv. ~30 seconds first build (libraries fetched + compiled).

Flash: pio run -e cardputer-adv -t upload. ~10 seconds.

Test:

  1. Cardputer ADV boots, shows “Channel Survey” on top.
  2. Counter at bottom updates as the promiscuous callback fires.
  3. After ~70 seconds (14 channels × 5 sec), a full pass completes.
  4. SD card now has /survey_<millis>.csv with per-channel counts.
  5. Eject SD, view on host:
channel,beacons,probe_requests
1,42,18
2,3,0
3,1,0
4,0,0
5,2,1
6,87,34
7,4,1
8,1,0
9,2,0
10,5,2
11,68,29
12,0,0
13,0,0
14,0,0

(Channels 1, 6, 11 dominate as expected in a US environment.)

Iteration loop: edit main.cpppio run -t upload → test. ~60-90 seconds per cycle on modern hardware.

This is the full development loop. The pattern scales to substantially larger firmware (Bruce, Marauder, NEMO are all built this way).


6. Cherry-picking features across forks

Goal: take a feature from one firmware and port it to another.

Example: NEMO is minimal; Bruce has an Apple TV-Off feature you want in NEMO.

Process:

  1. Locate the feature in Bruce source: src/modules/ir/apple_tv_off.cpp (and its .h if present).
  2. Identify dependencies: what headers does it include? What M5Cardputer / M5GFX / Arduino APIs does it call?
  3. Copy .cpp + .h to NEMO’s source tree at the equivalent path: src/modules/ir/.
  4. Add #includes at the top of the new file:
    #include <M5Cardputer.h>    // (NEMO uses this too)
    #include "menu_helper.h"     // NEMO's abstraction layer
  5. API differences: Bruce uses M5Cardputer.Display.* directly. NEMO sometimes uses menu_helper.h abstraction. Search-replace M5Cardputer.Display.fillScreenmenu_helper.clear_screen (or whatever NEMO’s pattern is). ~80% of changes are mechanical search-replace; ~20% require manual fixup.
  6. Register in NEMO’s menu: edit NEMO’s src/main.cpp or equivalent, add the new module to the menu enum.
  7. Build + test: pio run -e cardputer-adv -t upload.

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

Cross-fork code sharing is a real thing in the Cardputer ADV community — Bruce’s Sour Apple emerged from spec exploration that started in Marauder; NEMO’s BadUSB Hunter inspired similar features elsewhere. Stay legal-compatible (license headers, attribution).


7. Fleet-flash with compile-time defaults

Goal: flash 20+ Cardputer ADVs with the same Wi-Fi credentials + custom catalog URL + theme. Avoid paste-NVS-on-each-boot tedium.

Process:

  1. Patch M5Launcher’s compile-time defaults: edit src/settings.cpp loadConfigs():
void loadConfigs() {
    nvs.begin("launcher", false);

#ifdef FLEET_DEFAULTS
    // Pre-populate fields if NVS is empty
    if (!nvs.contains("wifi_ssid")) {
        nvs.putString("wifi_ssid", "Engagement_AP");
        nvs.putString("wifi_pass", "secret_password");
        nvs.putString("catalog_url", "https://my-private-catalog.example.com/catalog.json");
    }
#endif

    // ... existing loadConfigs logic ...
}
  1. Add the flag to platformio.ini:
[env:fleet-cardputer-adv]
extends = env:m5stack-cardputer-adv
build_flags =
    ${env.build_flags}
    -DFLEET_DEFAULTS
  1. Build: pio run -e fleet-cardputer-adv. Produces a fleet-specific binary.
  2. Flash each device: pio run -e fleet-cardputer-adv -t upload --upload-port /dev/ttyACM0. Or use M5Burner with the produced .bin.

Result: every fresh device boots with the same defaults. No per-device configuration needed.


8. Self-hosting an M5Launcher catalog

Goal: serve your own M5Launcher catalog from a private URL — fleet distribution without depending on bmorcelli’s catalog (rate limits, customization, internal-tools control).

Catalog schema:

{
    "version": 4,
    "categories": [
        {"id": "pentest", "name": "Pentest", "icon": "skull"},
        {"id": "internal", "name": "My Tools", "icon": "wrench"}
    ],
    "firmwares": [
        {
            "name": "My Custom Bruce",
            "author": "tjscientist",
            "description": "Bruce with internal tooling",
            "icon": "https://my-cdn.example.com/icons/custom-bruce.bmp",
            "category": "internal",
            "versions": [
                {
                    "tag": "v1.0.0",
                    "url": "https://my-releases.example.com/bruce-custom-v1.0.0-cardputer-adv.bin",
                    "sha256": "abc123...",
                    "size_bytes": 2147483
                }
            ]
        }
    ]
}

Hosting:

  • GitHub Pages: free, HTTPS, easy. Put catalog.json in a public repo, enable Pages.
  • Netlify / Vercel / Cloudflare Pages: same simplicity, custom domain support.
  • Private HTTPS server: nginx serving static catalog.json + binary files.

M5Launcher configuration:

  1. M5Launcher → Config → Catalog URL → https://my-private-catalog.example.com/catalog.json.
  2. Restart Catalog menu — M5Launcher fetches new URL.
  3. Custom firmwares appear in your private category.

Useful for:

  • Fleet distribution (don’t risk bmorcelli’s catalog changing)
  • Internal-tools-only firmwares not for public release
  • Controlled-update environments (HIPAA / PCI / classified)
  • Rate-limit avoidance (your own server has no rate limit)

9. Contributing upstream

Bruce (github.com/BruceDevices/firmware):

  • AGPLv3 license — your contributions inherit.
  • Active maintainers respond to PRs typically within a week.
  • High merge bar for well-described PRs with tests.
  • Process: fork → branch → develop → PR → respond to review → merge.
  • Discord for design discussion before authoring a big feature.

NEMO (github.com/n0xa/m5stick-nemo):

  • MIT license.
  • Responsive maintainer.
  • Smaller community — less competition for attention; PRs land faster.

M5Launcher (github.com/bmorcelli/Launcher):

  • Bmorcelli is selective — discuss in Discord or issue tracker first.
  • License: MIT for the launcher core; some bundled apps have different licenses.

MicroHydra, CardputerLoRaChat, Meshtastic:

  • Each has its own contribution model. Read CONTRIBUTING.md in the repo first.

General etiquette:

  • Sign your commits.
  • Match the existing code style.
  • Test on Cardputer ADV (not just compile-clean).
  • Write descriptive PR titles + bodies.
  • Be patient with review feedback.

10. Common build errors

Triage table for the errors that most frequently bite Cardputer ADV firmware development:

SymptomLikely causeFix
multiple definition of 'tft' linker errorTwo .cpp files instantiate M5GFX / TFT_eSPIEnsure only Display.cpp (or equivalent) instantiates the display
error: 'X' was not declared in this scopearduino-esp32 version mismatchpio platform update espressif32
Build hangs at “compiling” for >10 minAntivirus scanning ~/.platformio toolchainWhitelist ~/.platformio in your AV
Flash succeeds but BLACK SCREEN on bootWrong TFT pin mapping for Cardputer ADVVerify build flags match Vol 3 § 2 (G33-G38 LCD bus etc.)
Reboot loop with “Brownout detector triggered”Inadequate USB power during bootBetter USB cable; check battery; relax brownout threshold (Vol 11 § 5)
Build OOMs with arm-none-eabi-cc1: out of memoryInsufficient RAM during compileAdd -j1 to limit parallelism; close other apps
Error: The current upload protocol "esptool" is not supportedPlatformIO platform version conflictpio platform install espressif32 --skip-default-package-sync; pio platform update espressif32
Display works but corrupted colorsWrong color-order in M5GFX initToggle M5.Display.setSwapBytes(true/false)
Display works but rotated 90/180/270Wrong rotation settingM5.Display.setRotation(N) with N=0-3
SD card not mountingWrong SD pin assignmentVerify SD.begin(12) for Cardputer ADV (CS = G12)
USB Serial output emptyMissing -DARDUINO_USB_CDC_ON_BOOT=1 build flagAdd to build_flags; rebuild
Wire and Wire1 confusionGrove I²C on G1/G2 secondary bus vs primary G8/G9Use Wire1.begin(2, 1) for Grove I²C
Compile fails on M5Cardputer libraryLibrary version mismatch with arduino-esp32Pin specific M5Cardputer version in lib_deps

The Cardputer Discord is the fastest path for Cardputer-ADV-specific build issues — the community has hit most of them, and answers are searchable.


11. Resources

Repos

Build tools

Cross-references

  • Parallel build-toolchain coverage: Marauder Firmware Vol 10 at ../../../ESP32 Marauder Firmware/03-outputs/ESP32_Marauder_Firmware_Complete.html
  • Programming environment setup: Vol 7
  • GPIO assignments for custom code: Vol 3 § 2
  • Cardputer Discord (firmware development support): linked from cardputer.wiki

This is Volume 10 of a twelve-volume series. Next: Vol 11 covers operational posture — detection, regulatory, legal, RF safety, LiPo handling, charging gotchas, when-NOT-to-use.