DSTIKE Hackheld · Volume 11

DSTIKE Hackheld Volume 11 — Writing Your Own Code II — PlatformIO + Advanced Patterns

Production-grade development, async patterns, OTA, a Spacehuhn fork, and starter apps

Contents

SectionTopic
1Why PlatformIO
2Installation
3Project layout
4platformio.ini reference
5Building the Spacehuhn firmware
6The async event loop
7Persistent state with LittleFS
8OTA updates
9Watchdog and crash recovery
10Sample app — beacon-logger
11Sample app — Spacehuhn fork with hardcoded allowlist
12Memory + performance discipline
13What’s next

1. Why PlatformIO

PlatformIO is what you graduate to when the Arduino IDE feels limiting. The wins:

  • Version-pinned dependencies: lib_deps = adafruit/Adafruit SSD1306 @ ^2.5.7 — exact version captured per project.
  • CLI-first: pio run, pio upload, pio device monitor — scriptable, CI-able, no GUI required.
  • Multiple environments: build the same code for both esp8266 and esp32 from one project; develop on a fast desktop, deploy to multiple device variants.
  • Better static analysis: pio check runs cppcheck / clang-tidy / etc. against your code.
  • VS Code integration: superior to Arduino IDE 2.x for any non-trivial project; full IntelliSense, debugger support (where the chip supports it).
PlatformIO — production-grade development environment for embedded targets. CLI-first, IDE-agnostic, dependency-managed.
PlatformIO — production-grade development environment for embedded targets. CLI-first, IDE-agnostic, dependency-managed.

Figure 11.1 — PlatformIO. Photo via Wikimedia Commons (CC-licensed; see photo_credits.txt).

2. Installation

pip install platformio

For VS Code integration, install the PlatformIO IDE extension from the VS Code marketplace. The extension wraps the CLI and adds project-tree + build-status panels.

Verify:

pio --version
pio device list   # Should show the CH340 COM port when Hackheld is plugged in

3. Project layout

A typical PlatformIO project for the Hackheld:

my-hackheld-project/
├── platformio.ini         ← config: target board, dependencies, build flags
├── src/                   ← main code (.cpp / .h)
│   └── main.cpp
├── lib/                   ← project-private libraries (not from registry)
├── include/               ← project-wide headers
├── data/                  ← files to flash to LittleFS partition
└── .pio/                  ← build outputs (gitignored)

Create a new project:

mkdir hackheld-test
cd hackheld-test
pio project init --board esp12e

This generates a skeletal platformio.ini you then customize.

4. platformio.ini reference

A working config for the Hackheld:

; PlatformIO config — DSTIKE Hackheld

[platformio]
default_envs = hackheld

[env:hackheld]
platform = espressif8266
board = esp12e
framework = arduino

; Flash settings to match the Hackheld's ESP-12 module
board_build.flash_mode = dio
board_build.f_cpu = 80000000L
board_build.filesystem = littlefs

; Build / upload
upload_speed = 460800
monitor_speed = 115200
upload_port = /dev/ttyUSB0     ; adjust per host

; Compile flags
build_flags =
    -D HACKHELD
    -D OLED_ADDR=0x3C
    -Wno-unused-variable
    -Os                          ; size-optimised (matters on 80 KB DRAM)

; Library dependencies — pinned versions
lib_deps =
    adafruit/Adafruit GFX Library @ ^1.11.5
    adafruit/Adafruit SSD1306 @ ^2.5.7
    thomasfredericks/Bounce2 @ ^2.71
    me-no-dev/ESPAsyncTCP @ ^1.2.2
    me-no-dev/ESP Async WebServer @ ^1.2.3
    bblanchon/ArduinoJson @ ^7.0.0

; Optional: arrange for an OTA fallback environment
[env:hackheld-ota]
extends = env:hackheld
upload_protocol = espota
upload_port = 192.168.4.1
upload_flags =
    --auth=hackheld_ota_pw

Then:

pio run                      # build
pio run --target upload      # build + upload
pio device monitor           # serial monitor
pio run -e hackheld-ota --target upload  # OTA upload

5. Building the Spacehuhn firmware

The Spacehuhn esp8266_deauther upstream is an Arduino-IDE project, but it builds cleanly under PlatformIO with the right platformio.ini:

[env:spacehuhn]
platform = espressif8266
board = esp12e
framework = arduino
board_build.flash_mode = dio
board_build.f_cpu = 80000000L
upload_speed = 460800
monitor_speed = 115200

src_filter = +<*> -<.git/> -<.svn/>
src_dir = esp8266_deauther/esp8266_deauther
build_flags =
    -D DEAUTHER_VERSION="\"2.6.1-custom\""
    -D USE_HACKHELD

Clone the upstream:

git clone https://github.com/SpacehuhnTech/esp8266_deauther.git
cd esp8266_deauther
# Now in the repo root. Copy the platformio.ini above here.
pio run
pio run --target upload

This builds the same binary as the Arduino IDE produces — useful for custom modifications.

6. The async event loop

ESP8266 doesn’t have a preemptive RTOS by default (only NONOS / cooperative). The Arduino-ESP8266 core’s loop() is the single thread; long blocking operations starve the Wi-Fi stack and trigger watchdog resets.

The pattern:

void loop() {
    // Bad: blocks for 5 seconds
    delay(5000);
    
    // Good: non-blocking
    static uint32_t lastTick = 0;
    if (millis() - lastTick > 5000) {
        lastTick = millis();
        do_periodic_work();
    }
    yield();    // Give the Wi-Fi stack a chance to run
}

Three async patterns that work well on the Hackheld:

Pattern A — Periodic timer in loop() (shown above). Simple. Works for tasks that run every few seconds.

Pattern B — Ticker library (built-in to Arduino-ESP8266):

#include <Ticker.h>
Ticker scanTicker;

void scanCallback() {
    int n = WiFi.scanNetworks();
    Serial.printf("Found %d APs\n", n);
}

void setup() {
    scanTicker.attach(30.0, scanCallback);  // scan every 30 seconds
}

void loop() {
    yield();
}

Ticker callbacks must be fast (a few ms max) — long ticker callbacks trigger watchdog. For long-running work, set a flag in the callback and do the work in loop():

volatile bool scanFlag = false;

void scanCallback() { scanFlag = true; }

void loop() {
    if (scanFlag) {
        scanFlag = false;
        WiFi.scanNetworks();  // Long-running, but in loop() not in interrupt context
    }
    yield();
}

Pattern C — ESPAsyncWebServer event-driven: the web server (used in Vol 10 §10) is non-blocking by design. Request handlers run very fast and return. For long-running work, kick off a background task and return immediately.

7. Persistent state with LittleFS

LittleFS replaces SPIFFS in newer Arduino-ESP8266 cores. Same API surface; better performance; recoverable from power failure (SPIFFS isn’t always).

#include <LittleFS.h>

void setup() {
    Serial.begin(115200);
    if (!LittleFS.begin()) {
        Serial.println("LittleFS mount failed; formatting");
        LittleFS.format();
        LittleFS.begin();
    }
    
    // Write a file
    File f = LittleFS.open("/state.txt", "w");
    f.println("captured at " + String(millis()));
    f.close();
    
    // Read it back
    f = LittleFS.open("/state.txt", "r");
    Serial.println(f.readString());
    f.close();
}

For structured state, use ArduinoJson:

#include <ArduinoJson.h>

void saveState() {
    JsonDocument doc;
    doc["last_attack_time"] = lastAttackTime;
    doc["attack_count"] = attackCount;
    
    File f = LittleFS.open("/state.json", "w");
    serializeJson(doc, f);
    f.close();
}

void loadState() {
    File f = LittleFS.open("/state.json", "r");
    if (!f) return;
    
    JsonDocument doc;
    deserializeJson(doc, f);
    lastAttackTime = doc["last_attack_time"];
    attackCount = doc["attack_count"];
    f.close();
}

In platformio.ini set board_build.filesystem = littlefs and PlatformIO does the right thing on upload.

8. OTA updates

Once your custom firmware works, you don’t need to plug in USB-C for every update. The ESP8266 Arduino-ESP8266 core has built-in OTA support — wire it in once, then pio run --target upload --upload-port 192.168.4.1 flashes over Wi-Fi.

#include <ArduinoOTA.h>

void setup() {
    // ... existing setup ...
    
    ArduinoOTA.setHostname("hackheld-jeff");
    ArduinoOTA.setPassword("hackheld_ota_pw");  // Required to prevent random OTA writes
    ArduinoOTA.begin();
    
    Serial.println("OTA ready");
}

void loop() {
    ArduinoOTA.handle();
    // ... rest of loop ...
}

In platformio.ini:

[env:hackheld-ota]
extends = env:hackheld
upload_protocol = espota
upload_port = 192.168.4.1
upload_flags =
    --auth=hackheld_ota_pw

Now:

pio run -e hackheld-ota --target upload

Watch the device’s OLED — it should briefly show “OTA in progress” and reboot into the new firmware.

Don’t put OTA in production firmware without authentication. A device with OTA + no password is one accidentally-discoverable AP away from being remotely re-flashed by anyone.

9. Watchdog and crash recovery

The ESP8266 has a hardware watchdog that triggers a reset if the chip doesn’t service it within ~3 seconds. Tight loops without yield() cause this.

When a watchdog fires, the chip reboots and the next-boot console output shows:

ets Jan  8 2013,rst cause:4, boot mode:(3,7)

wdt reset

rst cause:4 = watchdog reset. To recover: fix the offending loop in your code.

For crashes (exceptions, panics):

Exception (29):
epc1=0x40220cd1 epc2=0x00000000 epc3=0x00000000 excvaddr=0x00000000 depc=0x00000000

Use EspExceptionDecoder (Arduino IDE) or pio device monitor with the symbol-decoding filter to translate the address into a function name and line number.

Tools → Erase Flash → "Sketch + WiFi Settings" (Arduino IDE) or pio run -t erase (PlatformIO) sometimes fixes mysterious post-crash boot loops by wiping persistent state.

10. Sample app — beacon-logger

A custom firmware that does one thing well: passively log every beacon seen, with timestamp + RSSI + channel + SSID, to LittleFS. No attacks. No web UI. Just a station that catches and saves.

// apps/beacon-logger/src/main.cpp
#include <ESP8266WiFi.h>
#include <LittleFS.h>

extern "C" {
    #include "user_interface.h"
}

File logFile;

void sniffer_cb(uint8_t *buf, uint16_t len) {
    // Beacon frames start with frame type 0x80 0x00 in the 802.11 header
    if (len < 36) return;
    if (buf[12] != 0x80) return;   // not a beacon
    
    // RSSI is in the prepended PHY header
    int8_t rssi = (int8_t)buf[0];
    
    // SSID is in the management frame's variable fields
    // For brevity, just log RSSI + first 6 bytes of source MAC (BSSID)
    if (logFile) {
        logFile.printf("%lu,%d,%02x:%02x:%02x:%02x:%02x:%02x\n",
                       millis(), rssi,
                       buf[22], buf[23], buf[24], buf[25], buf[26], buf[27]);
        logFile.flush();
    }
}

void setup() {
    Serial.begin(115200);
    LittleFS.begin();
    logFile = LittleFS.open("/beacons.csv", "a");
    
    WiFi.mode(WIFI_STA);
    WiFi.disconnect();
    wifi_set_channel(1);
    wifi_promiscuous_enable(false);
    wifi_set_promiscuous_rx_cb(sniffer_cb);
    wifi_promiscuous_enable(true);
}

void loop() {
    static uint32_t lastHop = 0;
    static int channel = 1;
    if (millis() - lastHop > 1000) {
        lastHop = millis();
        channel = (channel % 14) + 1;
        wifi_set_channel(channel);
    }
    yield();
}

Leave running on battery. Pull /beacons.csv over USB-serial (or by re-flashing with a “read LittleFS” sketch) when done.

11. Sample app — Spacehuhn fork with hardcoded allowlist

If Jeff wants to use deauth in the lab but eliminate the risk of accidentally targeting a third-party network: fork Spacehuhn, gate the attack engine on a hardcoded MAC allowlist.

Pseudo-diff against Spacehuhn Attack.cpp (you’d patch the actual file in the cloned repo):

// Add at top of Attack.cpp:
static const uint8_t ALLOWED_BSSIDS[][6] = {
    {0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33},   // My test AP #1
    {0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x34},   // My test AP #2
    // ... add MACs of YOUR equipment only ...
};
const int N_ALLOWED = sizeof(ALLOWED_BSSIDS) / 6;

static bool is_allowed(const uint8_t *mac) {
    for (int i = 0; i < N_ALLOWED; i++) {
        if (memcmp(mac, ALLOWED_BSSIDS[i], 6) == 0) return true;
    }
    return false;
}

// Then in the deauth-send function, before emitting:
if (!is_allowed(target_bssid)) {
    Serial.println("[ATTACK BLOCKED] target not in allowlist");
    return;
}

Rebuild + flash. The firmware now physically refuses to deauth any AP that isn’t in the hardcoded list. Lab discipline as code.

The same pattern applies to beacon spam and probe spam — gate the attack functions on an allowlist defined in source. For lab use this is dramatically safer than “I’ll just be careful with the targets.”

12. Memory + performance discipline

Five rules for the 80 KB DRAM:

  1. Watch ESP.getFreeHeap(). Print it periodically. If it drops below 10 KB, Wi-Fi will get unreliable.
  2. Don’t use String. Use char[] + snprintf. String concatenation fragments the heap mercilessly.
  3. Mark static text PROGMEM (or F("...") in Serial.print). Otherwise it sits in DRAM.
  4. yield() in long loops. The Wi-Fi stack needs frequent yields. A tight loop without it triggers WDT.
  5. Prefer int to int32_t for stack variables. Most ARM code defaults to int32, but on ESP8266 even 8/16-bit ops are cheaper than 32-bit floats — and floats are software-emulated and slow.

CPU: at 80 MHz the chip can do simple work (toggle a GPIO, push a byte to I²C) in microseconds. Boost to 160 MHz (board_build.f_cpu = 160000000L in platformio.ini) when you’re doing real compute — e.g., parsing JSON or running cryptographic hashes.

13. What’s next

Vol 12 — Workflows, Comparison, Legal/Ethics, Cheatsheet — operational recipes, comparison vs the modern alternatives, legal posture, and a laminate-ready cheatsheet.