Faro 0.12.1: CRC-checked flash state, hardened phone Wi-Fi setup, Polish UI, and a correctness pass across five LoRa boards
After shipping the initial Faro handheld UI and desktop console for MeshCore LoRa radios and expanding it to five ESP32-S3 targets, I ran the same kind of line-by-line correctness review over Faro's firmware, desktop companion client, and build pipeline. v0.12.1 is the result.
Embedded bugs rarely announce themselves on a clean bench. They wait for a battery brownout mid-save on SPIFFS, a 3-byte repeater path preset from another region, a 63-character WPA2 passphrase with punctuation, or a CMake release flag that quietly compiles your test assertions into no-ops. Every class of bug below now has a host regression test in CTest, and the firmware was verified on a live LilyGo T-Deck Plus (162 mesh contacts on EU 868) and a USB-attached Heltec V3 before tagging.
1. A truncated SPIFFS file used to wipe in-memory state
Alongside upstream MeshCore's own NVS/SPIFFS identity store, Faro writes UI preferences, contact notes, and chat history to /faro_ui.bin (FUI1) and /faro_msgs.bin (FMS1) at most once a minute via write-to-.tmp and rename(). Reviewing the loader and writer turned up three failure modes under brownout or flash wear:
- Destructive load on partial files.
loadMessages()cleared_messagesbefore reading the record loop, and ignored shortfread()counts mid-record. A truncated file wiped RAM state instead of keeping what was already valid. - No checksum over stored records, and no
fsyncbefore rename. On embedded SPIFFS,fclose()withoutfflush()+fsync(fileno(f))followed by power loss can leave a renamed file short or leave the valid payload stranded in.tmp. - Delayed history wipe. Running
clear historyin Settings › CLI cleared RAM and set a dirty flag, leaving/faro_msgs.binon flash for up to 60 seconds if power was pulled immediately.
In v0.12.1, both files append a magic + IEEE 802.3 CRC32 trailer, fsync before rename(), and load in two passes: the entire stream and optional CRC trailer are validated first without touching live state, legacy pre-0.12.1 files without the trailer still upgrade cleanly on first boot, an intact .tmp file is recovered automatically if power died mid-rename, and clear history unlinks both /faro_msgs.bin and /faro_msgs.bin.tmp immediately.
Takeaway: atomic rename is only half of crash-safe persistence. Validate the whole record stream before mutating live state, and test what happens when power drops between write and rename.
2. Multi-byte repeater paths and bounded exports
MeshCore supports 1-, 2-, and 3-byte repeater path hashes (such as the Canada 3-byte preset), packing the stride into the top two bits of path_len (hash_size = 1 << (path_len >> 6)) and the hop count into the low six bits (path_len & 0x3F).
| Area | Bug before 0.12.1 | Fix in 0.12.1 |
|---|---|---|
| Advert & RX hop counts | Treated pkt->path_len as a raw byte count in discovered-contact callbacks, displaying 0x83 (131 hops) instead of 3 hops of 3 bytes, and dropped last_snr / last_rssi on discovery. |
Decodes pathHashSize() and pathHopCount() consistently across adverts, chat headers, and contact cards, preserving link SNR/RSSI. |
AppStore::buildTracePath |
Indexed out_path[64] without checking h * hash_size + sz <= sizeof(out_path) on malformed inputs. |
Strict bounds check per hop for 1-, 2-, and 3-byte hashes before reading. |
| SD JSON & GPX exports | fwrite(buf, 1, n, f) used snprintf's return value (which can exceed sizeof(buf) on truncation), and node names with quotes or <& broke JSON/XML. |
Clamps write lengths to buffer capacity and escapes JSON strings in settings backups and XML entities in GPX tracks. |
3. Hardening phone Wi-Fi provisioning
Typing a long random router password on a thumb keyboard is painful, so 0.11.1 added a temporary 5-minute WPA2 SoftAP (Faro-XXXX) serving a tiny HTML form at 192.168.4.1. Reviewing it as an adversarial surface exposed four sharp edges:
GET /save?ssid=...&pwd=...truncated encoded passwords. A 63-character WPA2 passphrase containing punctuation expands up to 3× when URL-encoded (%XX), overflowing a small query-line read buffer, and placed credentials in the request URI.- No CSRF token and active companion port. Any page open in the phone's browser while joined to
Faro-XXXXcould forge a cross-origin request to192.168.4.1, and TCP port5000remained reachable during setup. - Background STA channel hopping. If an older unreachable SSID was stored, ESP32 STA auto-reconnect kept scanning across channels, knocking the SoftAP off its channel while the user was submitting the form.
- Modulo bias in the setup AP password. Mapping random bytes onto the character alphabet with plain
%slightly skewed character selection.
0.12.1 moves submission to POST /save with a single-use random CSRF token verified in constant time, parses a body large enough for a worst-case URL-encoded 32-byte SSID + 63-character WPA2 passphrase, generates the temporary AP password via rejection sampling, pauses both STA reconnect scanning and TCP :5000 until setup finishes or times out, and zeroes credential buffers on the stack before returning.
4. Desktop console resilience and the CMake -DNDEBUG trap
faro-console runs the exact same LVGL 9.3 UI on Linux over SDL2, talking to a radio via USB serial or Wi-Fi TCP :5000. Four transport and build fixes landed here:
- Non-blocking TCP connect &
SIGPIPEimmunity. Pointing--tcpat an offline IP previously blocked inside kernel SYN retries; it now uses non-blockingconnect()+poll()(~3 s cap) and suppressesSIGPIPEwhen a radio reboots mid-frame. - Interleaved
PUSH_*frames during command round-trips. On a busy mesh, the radio emits asynchronousPUSH_ADVERTorPUSH_RAW_DATAframes between a command and itsRESP_*. Instead of treating an interleaved push as a failed reply, the client now dispatches push frames into the store while continuing to wait for the matching response, caps incoming frames at1024bytes, sends the full 7-byterebootpayload upstream expects, appends outbound DMs and channel messages to local history immediately, and accepts full UTF-8 input (SDL_TEXTINPUT). - Assertions that vanished in release-mode tests. Building CMake with
-DCMAKE_BUILD_TYPE=RelWithDebInfoorReleaseadds-DNDEBUG, which strippedassert(...)checks — including expressions with side effects — from unit test binaries. Every test target now compiles with explicit-UNDEBUG, and side-effecting calls live outsideassert().
# Always keep assertions armed in CTest targets, even in Release / RelWithDebInfo builds
target_compile_options(test_core PRIVATE -UNDEBUG)
target_compile_options(test_companion_client PRIVATE -UNDEBUG)
target_compile_options(test_ui_sim PRIVATE -UNDEBUG)
Takeaway: if your test suite uses <cassert> and CI builds with -DCMAKE_BUILD_TYPE=Release, your tests are not running their assertions unless you pass -UNDEBUG.
5. Five boards, Polish (PL) at zero flash cost, and 522 KB more PSRAM
The rest of 0.12.1 focuses on hardware ergonomics across all five supported ESP32-S3 boards (LilyGo T-Deck / T-Deck Plus, T-Deck MAX, Elecrow ThinkNode M9, T-Beam S3 Supreme, and Heltec WiFi LoRa 32 V3):
- Polish (
PL) UI localization at zero font flash cost. All 15 screens now ship in English, Italian, German, French, and Polish (EN/IT/DE/FR/PL). Becausefaro_font_12,14, and16already included the full Latin Extended-A block (U+0100–U+017F:ą ć ę ł ń ó ś ź ż), adding Polish required zero new font bitmaps — plus fixing asnap.language % 4modulo clamp in SD settings import so the fifth language survives backup and restore. - ThinkNode M9 keypad navigation. On keypad-only hardware (
STC8Hat I2C0x6C),App::isTyping()previously returned true on every screen that contained a text box even when focus was on a button, stealing single-key shortcuts and turningEnterinto "send empty draft" instead of clicking the focused control. Focus checks, arrow-key navigation inside open LVGL dropdowns, keypad backlight sleep sync, and skipping T-Deck I2C probes are all fixed. - Measured PSRAM and idle power savings on the T-Deck Plus. Skipping LVGL SPI display flushes while
screen_offis active, callingi2s_stop()when the buzzer finishes a tone, removing aSPI.end()call that tore down the shared LoRa/TFT/SD SPI bus on failed SD mounts, and removing a leftover 525 ms cold-boot PNG tile benchmark cut boot time and raised free PSRAM on a live T-Deck Plus from 7.27 MB to 7.79 MB (+522 KB). - Same-origin Web Flasher. Browser WebSerial flashing via radeksixi.github.io/sixi-tdeck-plus now serves all five
-merged.binimages from same-origin GitHub Pages paths rather than cross-origin GitHub Release redirects, avoiding browser CORS blocks out of the box.
Getting v0.12.1
Firmware images (-merged.bin for first install at 0x0, -app.bin at 0x10000 to update while keeping your MeshCore keys, contacts, and channels), the Ubuntu amd64 desktop package (faro-desktop_0.12.1_amd64.deb), and SHA256SUMS are on the v0.12.1 release page, or flash directly from Chrome/Edge on the 1-click Web Flasher:
# Back up full 16 MB flash first, then update app partition (keeps keys, contacts & channels)
esptool --chip esp32s3 --port /dev/ttyACM0 --baud 921600 read-flash 0 ALL backup-$(date +%F).bin
esptool --chip esp32s3 --port /dev/ttyACM0 --baud 921600 write-flash 0x10000 faro-tdeck-0.12.1-app.bin
Faro is Apache-2.0 at github.com/radeksixi/sixi-tdeck-plus; the browser flasher and Mesh America catalog are at radeksixi.github.io/sixi-tdeck-plus.