Community project

BlueFox Firmware

ESP32
Photo of BlueFox Firmware
Generated with AI

OlimDev

Last updated September 13, 2026

BlueFox Firmware is a local radio inventory and hardware test console for the M5 Cardputer. It scans and displays nearby WiFi networks, Bluetooth devices, and IR signals without connecting to or pairing with any of them, making it useful for RF environment assessment and device diagnostics.

This guide provides the complete firmware, wiring diagram, and assembly instructions for flashing BlueFox onto an M5 Cardputer. Readers will get a ready-to-use handheld tool for surveying wireless activity, testing IR transmission, and adjusting display settings via the device's keyboard interface.

Wiring diagram

Assemble it in 2 steps

1. Utiliser le Cardputer tel quel

Aucun fil ni module externe n’est nécessaire : l’écran, le clavier, le Wi‑Fi, le Bluetooth et l’émetteur infrarouge font déjà partie du Cardputer ADV.

  • Gardez l’émetteur infrarouge en haut du boîtier dégagé si vous testez cette fonction.
  • N’envoyez le signal infrarouge de test que vers un appareil vous appartenant.

2. Brancher pour programmer

Branchez le port USB-C du Cardputer à votre ordinateur avec un câble USB qui transporte les données. Ce câble alimente aussi le Cardputer pendant la programmation.

  • Une fois le firmware lancé, utilisez ; pour monter, . pour descendre, , et / pour régler la luminosité, Entrée pour choisir et Suppr pour revenir.
  • N’utilisez pas un câble USB-C uniquement prévu pour la charge : il peut alimenter le Cardputer sans permettre la programmation.

Deploy the firmware

#include <Arduino.h>
#include <M5Cardputer.h>
#include <WiFi.h>
#include <NimBLEDevice.h>

// BlueFox Firmware v0.1: a local radio inventory and hardware test console.
// No connection, pairing, credential capture, packet injection, or remote-code replay.


// Hoisted type definitions
enum Page : uint8_t { HOME, WIFI, BLE, IR, SETTINGS };

enum Tone : uint8_t { INFO, GOOD, WARN };

struct AppSettings { bool wifiOn = true; bool bleOn = true; uint8_t brightness = 160; };


// Forward declarations
void setNotice(const String &text, Tone tone);
uint16_t noticeColor();
void brightness(int value);
void top(const char *label);
void footer(const char *text);
void statusLine(uint8_t y);
void tag(uint16_t x, uint16_t y, const char *text, uint16_t color);
String clipped(const String &text, uint8_t maxChars);
void homeView();
void radioHeader(const char *label, bool enabled, uint16_t count, const char *unit);
void wifiView();
void bleView();
void irView();
void settingsView();
void scanWifi();
void scanBle();
void testIr();
void move(int amount);
void changeBrightness(int amount);
void back();
void selectItem();
void handleKey(char key);

constexpr uint8_t IR_TX_PIN = 44;
constexpr uint8_t MENU_COUNT = 4;
constexpr uint8_t SETTINGS_COUNT = 4;
constexpr uint8_t MAX_RESULTS = 5;
constexpr uint16_t W = 240;





AppSettings cfg;
Page page = HOME;
uint8_t cursor = 0, settingCursor = 0;
uint16_t wifiCount = 0, bleCount = 0;
String wifiNames[MAX_RESULTS], bleNames[MAX_RESULTS];
String notice = "READY  Select a module";
Tone noticeTone = INFO;

const char *menuName[MENU_COUNT] = {"WIFI SCANNER", "BLE SCANNER", "IR TEST", "SETTINGS"};
const char *menuHint[MENU_COUNT] = {"Nearby network broadcasts", "Nearby BLE advertisements", "Built-in transmitter check", "Radios and display controls"};
const char *menuIcon[MENU_COUNT] = {"[W]", "[B]", "[I]", "[S]"};
const char *settingName[SETTINGS_COUNT] = {"WiFi radio", "BLE radio", "Screen brightness", "Back to main menu"};

void render();
void setNotice(const String &text, Tone tone = INFO) { notice = text; noticeTone = tone; }
uint16_t noticeColor() { return noticeTone == GOOD ? TFT_GREEN : (noticeTone == WARN ? TFT_ORANGE : TFT_CYAN); }
void brightness(int value) { cfg.brightness = static_cast<uint8_t>(constrain(value, 32, 255)); M5Cardputer.Display.setBrightness(cfg.brightness); }

void top(const char *label) {
  auto &d = M5Cardputer.Display;
  d.fillScreen(TFT_BLACK); d.fillRect(0, 0, W, 16, TFT_DARKGREY); d.fillRect(0, 15, W, 1, TFT_CYAN);
  d.setTextSize(1); d.setTextColor(TFT_CYAN, TFT_DARKGREY); d.setCursor(6, 4); d.print("BLUEFOX");
  d.setTextColor(TFT_LIGHTGREY, TFT_DARKGREY); d.print("  v0.1");
  d.setTextColor(TFT_WHITE, TFT_DARKGREY); d.setCursor(184, 4); d.print(label);
}
void footer(const char *text) {
  auto &d = M5Cardputer.Display; d.fillRect(0, 119, W, 16, TFT_DARKGREY);
  d.setTextColor(TFT_CYAN, TFT_DARKGREY); d.setTextSize(1); d.setCursor(5, 124); d.print(text);
}
void statusLine(uint8_t y = 106) {
  auto &d = M5Cardputer.Display; d.fillRect(6, y - 2, 228, 11, TFT_BLACK);
  d.setTextColor(noticeColor(), TFT_BLACK); d.setTextSize(1); d.setCursor(8, y); d.print(notice);
}
void tag(uint16_t x, uint16_t y, const char *text, uint16_t color) {
  auto &d = M5Cardputer.Display; uint16_t width = strlen(text) * 6 + 8;
  d.fillRoundRect(x, y, width, 11, 3, color); d.setTextColor(TFT_BLACK, color); d.setCursor(x + 4, y + 2); d.print(text);
}
String clipped(const String &text, uint8_t maxChars) { return text.length() <= maxChars ? text : text.substring(0, maxChars - 1) + "."; }

void homeView() {
  top("MAIN"); auto &d = M5Cardputer.Display;
  d.setTextColor(TFT_DARKGREY, TFT_BLACK); d.setCursor(8, 23); d.print("LOCAL SECURITY CONSOLE");
  for (uint8_t i = 0; i < MENU_COUNT; ++i) {
    const int y = 34 + i * 17; bool selected = i == cursor; uint16_t bg = selected ? TFT_DARKCYAN : TFT_BLACK;
    if (selected) { d.fillRoundRect(5, y - 2, 230, 15, 3, bg); d.fillRect(5, y, 3, 10, TFT_CYAN); }
    d.setTextColor(selected ? TFT_WHITE : TFT_LIGHTGREY, bg); d.setCursor(12, y + 1); d.print(menuIcon[i]); d.setCursor(39, y + 1); d.print(menuName[i]);
    if (selected) tag(190, y, "OPEN", TFT_CYAN);
  }
  d.setTextColor(TFT_LIGHTGREY, TFT_BLACK); d.setCursor(8, 106); d.print(menuHint[cursor]); footer("W/S move  ENTER open");
}
void radioHeader(const char *label, bool enabled, uint16_t count, const char *unit) {
  top(label); auto &d = M5Cardputer.Display; d.setTextColor(enabled ? TFT_GREEN : TFT_RED, TFT_BLACK);
  d.setCursor(8, 25); d.printf("RADIO: %s", enabled ? "READY" : "OFF"); tag(191, 21, enabled ? "ON" : "OFF", enabled ? TFT_GREEN : TFT_RED);
  d.setTextColor(TFT_LIGHTGREY, TFT_BLACK); d.setCursor(8, 39); d.printf("%u %s", count, unit); d.fillRect(7, 47, 226, 1, TFT_DARKGREY);
}
void wifiView() {
  radioHeader("WIFI", cfg.wifiOn, wifiCount, "NETWORKS FOUND"); auto &d = M5Cardputer.Display;
  if (!wifiCount) { d.setTextColor(TFT_DARKGREY, TFT_BLACK); d.setCursor(8, 58); d.print("Press ENTER to scan nearby broadcasts."); d.setCursor(8, 70); d.print("No network is joined or contacted."); }
  else for (uint8_t i = 0; i < MAX_RESULTS && i < wifiCount; ++i) { d.setTextColor(TFT_CYAN, TFT_BLACK); d.setCursor(8, 54 + i * 10); d.print(">"); d.setTextColor(TFT_LIGHTGREY, TFT_BLACK); d.setCursor(20, 54 + i * 10); d.print(clipped(wifiNames[i], 31)); }
  statusLine(); footer("ENTER scan  DEL back");
}
void bleView() {
  radioHeader("BLE", cfg.bleOn, bleCount, "DEVICES FOUND"); auto &d = M5Cardputer.Display;
  if (!bleCount) { d.setTextColor(TFT_DARKGREY, TFT_BLACK); d.setCursor(8, 58); d.print("Press ENTER to listen for broadcasts."); d.setCursor(8, 70); d.print("No device is paired or contacted."); }
  else for (uint8_t i = 0; i < MAX_RESULTS && i < bleCount; ++i) { d.setTextColor(TFT_CYAN, TFT_BLACK); d.setCursor(8, 54 + i * 10); d.print(">"); d.setTextColor(TFT_LIGHTGREY, TFT_BLACK); d.setCursor(20, 54 + i * 10); d.print(clipped(bleNames[i], 31)); }
  statusLine(); footer("ENTER scan  DEL back");
}
void irView() {
  top("IR TEST"); auto &d = M5Cardputer.Display; d.setTextColor(TFT_CYAN, TFT_BLACK); d.setCursor(8, 28); d.print("INTEGRATED IR TRANSMITTER");
  d.setTextColor(TFT_LIGHTGREY, TFT_BLACK); d.setCursor(8, 50); d.print("Send an 80 ms 38 kHz carrier test."); d.setCursor(8, 63); d.print("Use only with equipment you own.");
  d.setTextColor(TFT_DARKGREY, TFT_BLACK); d.setCursor(8, 83); d.print("No IR remote codes are stored or replayed."); statusLine(); footer("ENTER test  DEL back");
}
void settingsView() {
  top("SETTINGS"); auto &d = M5Cardputer.Display;
  for (uint8_t i = 0; i < SETTINGS_COUNT; ++i) {
    const int y = 27 + i * 19; bool selected = i == settingCursor; uint16_t bg = selected ? TFT_DARKCYAN : TFT_BLACK;
    if (selected) d.fillRoundRect(5, y - 2, 230, 16, 3, bg);
    d.setTextColor(selected ? TFT_WHITE : TFT_LIGHTGREY, bg); d.setCursor(10, y + 2); d.print(selected ? "> " : "  "); d.print(settingName[i]); d.setCursor(178, y + 2);
    if (i == 0) d.print(cfg.wifiOn ? "ON" : "OFF"); else if (i == 1) d.print(cfg.bleOn ? "ON" : "OFF"); else if (i == 2) d.printf("%3u", cfg.brightness);
  }
  d.setTextColor(TFT_DARKGREY, TFT_BLACK); d.setCursor(8, 106); d.print("A/D changes brightness"); footer("W/S move  ENTER toggle  DEL");
}
void render() { if (page == HOME) homeView(); else if (page == WIFI) wifiView(); else if (page == BLE) bleView(); else if (page == IR) irView(); else settingsView(); }

void scanWifi() {
  if (!cfg.wifiOn) { setNotice("WiFi is OFF in Settings", WARN); render(); return; }
  setNotice("Scanning local broadcasts...", INFO); render(); WiFi.mode(WIFI_STA); WiFi.disconnect(false, false);
  int found = WiFi.scanNetworks(false, true); wifiCount = found > 0 ? found : 0;
  for (uint8_t i = 0; i < MAX_RESULTS; ++i) wifiNames[i] = "";
  for (uint8_t i = 0; i < MAX_RESULTS && i < wifiCount; ++i) { String name = WiFi.SSID(i); wifiNames[i] = name.length() ? name : "<hidden network>"; }
  WiFi.scanDelete(); setNotice(found >= 0 ? "Scan complete" : "Scan unavailable - try again", found >= 0 ? GOOD : WARN); render();
}
void scanBle() {
  if (!cfg.bleOn) { setNotice("BLE is OFF in Settings", WARN); render(); return; }
  setNotice("Listening for BLE broadcasts...", INFO); render(); NimBLEDevice::init("BlueFox"); NimBLEScan *scan = NimBLEDevice::getScan(); scan->setActiveScan(false);
  NimBLEScanResults results = scan->getResults(2000, false); bleCount = results.getCount() > 0 ? results.getCount() : 0;
  for (uint8_t i = 0; i < MAX_RESULTS; ++i) bleNames[i] = "";
  for (uint8_t i = 0; i < MAX_RESULTS && i < bleCount; ++i) { const NimBLEAdvertisedDevice *device = results.getDevice(i); String name = device ? String(device->getName().c_str()) : ""; bleNames[i] = name.length() ? name : "<unnamed BLE device>"; }
  scan->clearResults(); NimBLEDevice::deinit(true); setNotice("Scan complete", GOOD); render();
}
void testIr() { ledcSetup(0, 38000, 8); ledcAttachPin(IR_TX_PIN, 0); ledcWrite(0, 128); delay(80); ledcWrite(0, 0); ledcDetachPin(IR_TX_PIN); setNotice("38 kHz local test sent", GOOD); render(); }
void move(int amount) { if (page == HOME) cursor = (cursor + MENU_COUNT + amount) % MENU_COUNT; else if (page == SETTINGS) settingCursor = (settingCursor + SETTINGS_COUNT + amount) % SETTINGS_COUNT; render(); }
void changeBrightness(int amount) { if (page == SETTINGS && settingCursor == 2) { brightness(static_cast<int>(cfg.brightness) + amount * 16); setNotice("Screen brightness changed", GOOD); render(); } }
void back() { if (page != HOME) { page = HOME; setNotice("READY  Select a module", INFO); render(); } }
void selectItem() {
  if (page == HOME) { page = static_cast<Page>(cursor + 1); setNotice("READY", INFO); render(); return; }
  if (page == WIFI) { scanWifi(); return; } if (page == BLE) { scanBle(); return; } if (page == IR) { testIr(); return; }
  if (settingCursor == 0) { cfg.wifiOn = !cfg.wifiOn; setNotice("WiFi radio setting changed", GOOD); render(); }
  else if (settingCursor == 1) { cfg.bleOn = !cfg.bleOn; setNotice("BLE radio setting changed", GOOD); render(); } else if (settingCursor == 3) back();
}
void handleKey(char key) {
  // W/S/A/D are direct keys, deliberately avoiding the Cardputer Fn-arrow chord.
  if (key == 'w' || key == 'W' || key == 'k' || key == 'K' || key == ';') move(-1);
  else if (key == 's' || key == 'S' || key == 'j' || key == 'J' || key == '.') move(1);
  else if (key == 'a' || key == 'A' || key == ',') changeBrightness(-1);
  else if (key == 'd' || key == 'D' || key == '/') changeBrightness(1);
  else if (key == '1') { page = WIFI; render(); } else if (key == '2') { page = BLE; render(); } else if (key == '3') { page = IR; render(); } else if (key == '4') { page = SETTINGS; render(); }
}
void setup() { auto m5cfg = M5.config(); M5Cardputer.begin(m5cfg, true); M5Cardputer.Display.setTextWrap(false); brightness(cfg.brightness); render(); }
void loop() {
  M5Cardputer.update();
  if (M5Cardputer.Keyboard.isChange() && M5Cardputer.Keyboard.isPressed()) {
    const auto keys = M5Cardputer.Keyboard.keysState();
    // One logical action per physical press; enter and delete are handled separately.
    if (keys.enter) selectItem(); else if (keys.del) back(); else for (const auto key : keys.word) handleKey(key);
  }
  delay(8);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik