Skip to content

feat: add M5Atom Echo/Lite and Atom VoiceS3R (ESP32-S3) board support + multi-board READMEMain - #44

Open
simeononsecurity wants to merge 71 commits into
colonelpanichacks:mainfrom
simeononsecurity:main
Open

simeononsecurity wants to merge 71 commits into
colonelpanichacks:mainfrom
simeononsecurity:main

Conversation

@simeononsecurity

@simeononsecurity simeononsecurity commented Jun 3, 2026

Copy link
Copy Markdown

Summary

Adds PlatformIO build environments and firmware support for four additional boards — M5Stack Atom Echo, Atom Lite, Atom Voice, and Atom VoiceS3R (ESP32-S3), plus generic ESP32 DevKit with a piezo buzzer, all validated with real hardware. The original xiao_esp32s3 environment is preserved unchanged.

Justification

The Flock You firmware is a great tool, but until now it was only easily buildable for the Seeed XIAO ESP32-S3. A lot of people already own M5Stack Atom boards or a generic ESP32 DevKit and shouldn't need to buy specific hardware just to get started. Making the project flashable on common, cheap, widely-available boards lowers the barrier to entry significantly, you can grab whatever ESP32 you have on your bench, clone the repo, pick your environment, and be scanning in minutes. The M5Stack Atom series in particular is compact, self-contained, and battery-friendly, making it a natural fit for field use. Adding proper platformio.ini environments means contributors don't have to reverse-engineer build flags; it just works with a single pio run command.

New board environments

Environment Board Audio Tested
xiao_esp32s3 Seeed XIAO ESP32-S3 ✅ preserved (original)
esp32dev Generic ESP32 DevKit Piezo buzzer (GPIO) ✅ hardware-verified
m5atom-echo M5Stack Atom Echo Built-in speaker
m5atom-lite M5Stack Atom Lite NeoPixel LED feedback ✅ hardware-verified
m5atom-voice M5Stack Atom Voice SPM1423 PDM mic / speaker ✅ hardware-verified
m5atom-voices3r M5Stack Atom VoiceS3R (ESP32-S3) ES8311 I²S + NS4150B amp ✅ hardware-verified

Changes

platformio.ini

  • Added [env:esp32dev] generic ESP32 DevKit with passive piezo on configurable GPIO pin
  • Added [env:m5atom-echo], [env:m5atom-lite] — standard ESP32 Atom boards using Adafruit NeoPixel for LED
  • Added [env:m5atom-voice] M5Stack Atom Voice with SPM1423 PDM microphone and speaker support
  • Added [env:m5atom-voices3r] ESP32-S3 with espressif32@6.7.0, qio_opi PSRAM, ARDUINO_USB_CDC_ON_BOOT, and M5Unified@^0.2.2 for ES8311 I²S speaker
  • Added partitions_4mb.csv for M5Stack boards (XIAO keeps original partitions.csv)

main.cpp

  • #ifndef TESTING_MODE guard so -DTESTING_MODE=1 in build_flags works without source edits
  • Adafruit NeoPixel #include guarded for Echo/Lite only VoiceS3R and Voice builds no longer need that dep
  • USE_M5ATOM_VOICES3R hardware block: M5.begin() init, M5.Speaker.setVolume(200), Serial1 mirror skipped (USB-CDC only)
  • USE_M5ATOM_VOICE hardware block: M5Unified speaker init for the original Atom Voice
  • GPIO piezo buzzer support for esp32dev tone()/noTone() on configurable pin
  • M5.Speaker.tone() branches in startupBeep(), newDetectChirp(), and heartbeatBeep() startup plays the SMB World 1-1 overworld riff through supported speakers; piezo boards play simplified tones

README.md

  • New Supported Hardware table with all six environments
  • Per-board pio run -e <env> -t upload and screen monitor commands
  • VoiceS3R-specific section: kill-port-holder one-liner + esptool --before usb-reset fallback for ESP32-S3 native USB-CDC
  • Generic ESP32 piezo wiring notes
  • Testing mode documentation (-DTESTING_MODE=1)

Testing

All boards were field-tested:

  • Generic ESP32 + piezo: boot tone sounds, WiFi promiscuous mode scans channels 1/6/11, Flock OUI detections produce audible chirp on the piezo. Serial JSON output confirmed at 115200 baud.
  • Atom Lite: LED feedback on detection confirmed, serial output verified.
  • Atom Voice: Speaker tones confirmed on boot and detection events.
  • Atom VoiceS3R: Boot tune plays through the ES8311/NS4150B speaker, Flock OUI detections produce two-tone ascending chirp. Serial JSON output confirmed on /dev/tty.usbmodem* at 115200 baud.

colonelpanichacks and others added 30 commits April 24, 2026 06:40
Adds Michael / DeFlockJoplin's high-precision detection method on top of
the NitekryDPaul baseline: a Flock camera is flagged when it transmits a
Probe Request (type=0 subtype=4) with a wildcard SSID IE (tag 0 len 0)
AND its addr2 matches the OUI list. Drive-test in Joplin: 11/12 cameras
caught with only 2 false positives.

- New AlertType ALERT_WILDCARD_PROBE, emitted as detection_method
  'wifi_wildcard_probe' (high-precision class)
- Wildcard-probe hits suppress the addr2 broad alert for the same frame
  to prevent double counting; non-probe OUI matches still emit as
  'wifi_oui_addr2'
- IE parser returns tri-state (1=wildcard / 0=directed / -1=no SSID IE),
  with FCS-trailer retry only on the -1 no-IE case
- addr1 receiver-side sleeper-catch and the optional addr3 + SSID paths
  are unchanged — wildcard is purely additive
- 31st OUI 82:6b:f2 added to target_ouis[] and to the dataset doc; it's
  the OUI of the 12th camera in Michael's drive-test that the original
  30 didn't catch
- README explains the wildcard-probe method, credits Michael with a link
  to github.com/DeflockJoplin/flock-you, and bumps Acknowledgments

Source: https://github.com/DeflockJoplin/flock-you
Hardware support added
- M5Atom Echo (USE_M5ATOM_ECHO): NeoPixel LED + GPIO25 buzzer
- M5Atom Lite (USE_M5ATOM_LITE): NeoPixel LED, no buzzer
- Atom VoiceS3R (USE_M5ATOM_VOICES3R): ESP32-S3-PICO-1-N8R8, ES8311
  I2S speaker via M5Unified, no RGB LED, button on GPIO41

platformio.ini
- [env:m5atom-echo]: Adafruit NeoPixel, usbserial port, 1.5Mbaud upload
- [env:m5atom-lite]: same board, no buzzer flag
- [env:m5atom-voices3r]: espressif32@6.7.0, esp32-s3-devkitc-1, qio_opi
  PSRAM, ARDUINO_USB_CDC_ON_BOOT, M5Unified@^0.2.2

main.cpp
- TESTING_MODE guarded with #ifndef so build flag can override it
- Adafruit NeoPixel include guarded for Echo/Lite only (no NeoPixel on S3R)
- M5Unified speaker: M5.Speaker.tone() used for all audio on VoiceS3R
  - Startup: SMB World 1-1 overworld riff (E E _E_ C E G)
  - Detection chirp: two ascending tones (2000→2800 Hz)
  - Heartbeat beep: two monotone 1500 Hz pulses
- Serial1 UART mirror skipped on VoiceS3R (USB-CDC only)
- setup(): M5.begin() + Speaker.setVolume(200) for VoiceS3R

New scripts
- flash_atom.sh: auto-detects /dev/tty.usbserial-*, venv auto-load
- flash_voices3r.sh: targets /dev/tty.usbmodem* (ESP32-S3 native USB-CDC),
  kills port-holding processes, uses brew esptool with --before usb-reset
- Both scripts activate venv automatically (searches .venv/venv/env/api/venv
  and ~/.platformio/penv in priority order)
- partitions_4mb.csv: custom partition table for all supported boards

Add -DTESTING_MODE=1 to any env's build_flags to alert on all WiFi frames.
…g mode docs

- Restored [env:xiao_esp32s3] (Seeed XIAO ESP32-S3, original upstream board)
- Added Supported Hardware table: xiao_esp32s3 / esp32dev / m5atom-echo /
  m5atom-lite / m5atom-voices3r with port types, audio/LED notes
- Replaced generic 'pio run' section with board-specific flash + monitor
  commands for all five environments
- Added VoiceS3R section: kill-port-holder one-liner + esptool fallback
  for ESP32-S3 native USB-CDC (fixes 'port busy' upload errors)
- Added Testing mode section: -DTESTING_MODE=1 build flag instructions
- Pin maps for XIAO ESP32-S3 (original) and Atom VoiceS3R
- Removed flash_atom.sh and flash_voices3r.sh (commands in README instead)
…38/38)

Core firmware (main.cpp):
- Three OUI confidence tiers: HIGH(32)/MFR(6)/SoundThinking(1) — PR#39
- ALERT_LAA_SSID: detects 'Flock Camera net.' locally-administered-MAC cameras
- checkSeqMac(): :DE/:DF sequential last-byte pair heuristic for dual-band LAA cams
- Confidence score 0-100 per detection, stored in SPIFFS + emitted in JSON
- BLE_COEX_MODE=1: ESP-IDF SW coexistence — continuous NimBLE scan, no promisc pause
- BLE_COEX_MODE=0: fallback manual time-mux (5s pause/60s) for boards with issues
- Fixed BLE mfr-ID: 0x09C8 (XUNTONG/Flock) was incorrectly 0x05A7 (Assa Abloy)
- Full 128-bit Raven GATT service UUIDs (GainSec research, 8 UUIDs)
- CHIRP_MIN_CONFIDENCE=30: mfr-OUI alone (20pts) logs silently, no false beeps
- channelBand(ch): JSON protocol field is band-aware (wifi_2_4ghz / wifi_5ghz)
- 5 GHz guard: documents ESP32-C5 path, prevents ch149/157 on 2.4GHz-only hw
- addr3 CHECK_ADDR3=1 (was 0): BSSID fallback always active
- ENABLE_SSID_MATCH=1 (was 0): required for LAA-MAC camera class

Detection patterns (fy_detect.h — new shared header):
- fy_oui_high[]: 32 exclusively-Flock OUIs (NitekryDPaul + DeFlockJoplin + dougborg/PR#39)
- fy_oui_mfr[]: 6 Liteon/USI contract-manufacturer OUIs
- fy_oui_soundthinking[]: 1 SoundThinking/ShotSpotter OUI (avenstewart/PR#39)
- fy_ble_mfr_ids[]: {0x09C8} XUNTONG (corrected)
- fy_raven_uuids[]: 8 full 128-bit Raven GATT service UUIDs
- Pure matching functions: fyCheckFlockHighMAC/MfrMAC/SoundThinkingMAC/BLEName/BLEMfrID
- fyCheckRavenUUIDFromStrings(): hardware-independent UUID matching
- fyEstimateRavenFW(): firmware version heuristic from UUID categories

platformio.ini:
- 10 environments: 5 WiFi-only + 5 BLE-COEX twins for all supported boards
- [env:native]: host Unity test runner (pio test -e native)
- All -ble envs: -DENABLE_BLE_SCAN=1 -DBLE_COEX_MODE=1

Native unit tests (test/) — 38/38 PASS:
- test_ble_matching: all 32 high-OUIs, 6 mfr-OUIs, SoundThinking isolation,
  BLE name substring, mfr-ID 0x09C8 match + reject old 0x05A7 (22 tests)
- test_uuid_matching: all 8 Raven UUIDs, case-insensitive, fw version
  estimation, old short-form UUID rejection (16 tests)

New files:
- fy_detect.h: shared detection pattern library
- test/: native Unity test suite
- flash.sh: unified multi-board flasher with device identification
- flash_atom.sh: M5Atom Lite quick-flash script
- flash_voices3r.sh: Atom VoiceS3R two-stage USB CDC flasher
- DETECTION_IMPROVEMENTS.md: full design rationale, score tables, examples
- SOLDERLESS_BUILD_GUIDE.md: breadboard build (-11, no soldering)
- CASE_DESIGN.md: 3D-printable enclosure design guide
- hardware/: PCB schematic, BOM, OpenSCAD case, renders

.gitignore:
- Added SPIFFS runtime files: session.json, session.tmp, prev_session.json

Build sizes (all within 4MB limits):
- WiFi-only (esp32dev): Flash 26%, RAM 19%
- BLE COEX (esp32dev-ble): Flash 34%, RAM 22%
- M5Atom VoiceS3R BLE: Flash 40%, RAM 23%
Auto-discovers /dev/cu.usbserial-* (Atom Lite/Echo/Voice FTDI) and
/dev/cu.usbmodem* (VoiceS3R native USB CDC) every 2 seconds.

- Color-coded, labeled output per device (6 ANSI colors, stable)
- Auto-reconnects when a device is unplugged / replugged / reflashed
- Backs off when esptool/pio/platformio holds the port (lsof check)
- Non-exclusive port open so flash tools can take over at any time
- PID management via /tmp/fymon.PID/ temp dir (bash 3.2 compatible)
- Ctrl-C kills all background monitor subprocesses cleanly

Usage: ./monitor_all.sh [baud]   (default 115200)
On macOS, 'while read < /dev/cu.*' opens the character device
non-blockingly and read() returns EOF immediately if no data is
waiting — causing the connect/disconnect bounce seen in testing.

'cat $port | while IFS= read -r line' correctly blocks on the
serial fd and only exits when the port disappears or is reset,
giving live streaming output as intended.
Root cause: re-opening /dev/cu.* with cat or bash '<' redirect each
iteration pulsed DTR on the FTDI chip, which triggered the ESP32
auto-reset circuit (DTR→EN capacitor).  The ESP32 reset, the
bootloader output arrived on the still-open fd, then the fd got a
hangup/EOF → 'disconnected' loop with no visible output.

Fix:
  exec 3<>"$port"           # O_RDWR open — DTR asserted once, stays up
  stty -f "$port" ... clocal -hupcl  # configure in-place, no retrigger
  while IFS= read -r line <&3  # VMIN=1 raw read blocks correctly
  line="${line%$'\r'}"    # strip trailing CR from ESP32 Serial.println()

Also documents firmware output format in header:
  [flockyou] log lines  (startup, heartbeat, detections)
  {"event":"detection",...} JSON lines  (one per alert, for Flask)
…evice' prompt

After a successful flash, show_boot_output() opens the port with
exec 3<>$port (O_RDWR — same trick as monitor_all.sh) and streams
serial lines until:
  • '[flockyou] scanning' heartbeat arrives → '✅ Firmware confirmed running'
  • 15 s elapses with no firmware output → warning printed

This gives a visual confirmation that the freshly-flashed firmware
actually booted correctly before you swap in the next device.

Implementation details:
  • BOOT_PORT global set by flash_device() — usbmodem gets RESTART_PORT,
    FTDI gets the same port (stays up through pio upload reset)
  • stty -f $port 115200 raw cs8 -cstopb -parenb clocal -hupcl
    configures in-place on the already-open fd (no DTR re-pulse)
  • read -r -t 2 line <&3 — 2 s blocking read per line, outer loop
    checks elapsed time against 15 s deadline
  • Trailing \r stripped from ESP32 Serial.println() \r\n output
  • Graceful skip if port is unavailable (prints warning, continues)
HEARTBEAT_MS=30000 means the scanning heartbeat is 30 s away after
boot — too long to wait.  '[flockyou] OUIs:' is the last startup
banner line printed right before scanning begins and appears within
~2 s of boot.  Exit on that instead.

Also increase fallback timeout 15s→35s and clean up the timeout
message ('Firmware running.' instead of 'heartbeat not yet received').
- .github/workflows/build-firmware.yml: builds 5 BLE envs on push to main,
  generates per-board manifest JSONs, deploys to gh-pages via
  JamesIves/github-pages-deploy-action
- docs/index.html: board selector dropdown + esp-web-install-button,
  Chrome/Edge Web Serial API, dark theme, hardware reference table

Boards: esp32dev-ble, m5atom-lite-ble, m5atom-echo-ble,
        m5atom-voice-ble, m5atom-voices3r-ble (ESP32-S3)

To enable: repo Settings → Pages → Source: gh-pages branch
Adds a 'Verify build artifacts' step between builds and packaging
that lists .pio/build/ and explicitly checks each firmware.bin exists,
providing clear diagnostics if any build silently failed.
- platformio.ini: new envs lilygo-t-dongle-c5 and lilygo-t-dongle-c5-ble
- main.cpp: USE_LILYGO_T_DONGLE_C5 hardware config block (GPIO8 LED)
- workflow: build + manifest for lilygo-t-dongle-c5-ble (RISC-V, bootloader at 0x0)
- docs/index.html: T-Dongle C5 option in board dropdown + chip table row
- platformio.ini: pin all envs to espressif32@6.7.0 (was ^6.3.0 causing
  potential platform version conflicts across envs in the same job)
- platformio.ini: add boards_dir = boards; C5 envs now use custom board JSON
  (boards/lilygo-t-dongle-c5.json) instead of unknown esp32-c5-devkitm-1
- workflow: remove restore-keys (prevents stale partial-platform cache hits)
- workflow: lilygo-t-dongle-c5-ble step gets continue-on-error: true (C5 experimental)
- workflow: verify step only fails on required envs; C5 is advisory only
- workflow: C5 firmware copy and manifest generation are conditional
- c5_display.h: new helper for LILYGO T-Dongle C5 hardware
  - ST7735S 80x160 TFT via Adafruit_ST7735 (SPI: GPIO5/6/4/2/3, BL=GPIO1)
  - WS2812B RGB LED on GPIO11 via Adafruit_NeoPixel
  - c5DisplayInit(), c5DisplayScanning(), c5DisplayDetection(), c5DisplayScore()
  - LED colour coding: idle=green, caution=amber, alert=red

- main.cpp: wire up C5 display in all call sites
  - USE_C5_DISPLAY=1 replaces plain GPIO8 LED for T_DONGLE_C5 builds
  - c5DisplayInit() called in setup()
  - c5DisplayScanning(ch, detCount) called from printHeartbeat()
  - c5DisplayDetection(type, mac, conf, rssi, ch) called from drainAlertQueue()

- platformio.ini: add display lib_deps to both C5 envs
  - Adafruit ST7735/ST7789 + GFX + NeoPixel libraries
  - -DUSE_C5_DISPLAY=1 build flag for both lilygo-t-dongle-c5 envs
- Add lilygo-t-dongle-c5 and lilygo-t-dongle-c5-ble to environment table
- New '📺 LILYGO T-Dongle C5 — Display & RGB LED' section with:
  - TFT display state/colour table (startup, scanning, detection tiers)
  - Full pin reference (TFT + RGB LED + BOOT button)
  - Flash commands for both C5 environments
  - Experimental status note
- Tab switcher: Flash Firmware | Live Dashboard
- Dashboard uses Web Serial API (Chrome/Edge) to parse JSON detections
  from firmware: {event,detection_method,mac_address,oui,rssi,channel,confidence,...}
- Browser Geolocation API for GPS wardriving (no extra hardware needed)
- Leaflet.js map with color-coded markers (green=low/yellow=med/red=high conf)
- Real-time detection table + sidebar feed
- localStorage persistence across page reloads
- JSON / CSV / KML export
- Firmware selector expanded: Standard, T-Dongle C5, ATOM, Voices3r
…tiles

- Fix CSS specificity bug: #pane-flash ID rule was overriding .tab-pane{display:none}
  causing flash pane to always be visible behind dashboard
- Dashboard pane now uses position:fixed (top:45px/left:right:bottom:0) so it
  fills the viewport as a true overlay without page scrolling
- Switch map tiles from OSM (referrer issues) to CartoDB Dark Matter which
  loads reliably from any domain including GitHub Pages
- Replace broken manifest.json/manifest-c5.json/manifest-atom.json refs
  with correct CI-generated names: manifest-esp32dev-ble.json,
  manifest-m5atom-lite-ble.json, manifest-m5atom-echo-ble.json,
  manifest-m5atom-voice-ble.json, manifest-m5atom-voices3r-ble.json,
  manifest-lilygo-t-dongle-c5-ble.json
- Redesign Flash tab: numbered wizard steps 1→2→3 (Connect→Select→Flash)
- Show all 6 board variants with chip family badges
- Add Open Serial Monitor button that connects and switches to Dashboard
- Modern top nav with brand, tab switcher, GitHub/Docs links
- Preserve full Live Dashboard tab unchanged
- main.cpp: wrap SPIFFS.begin(true) with esp_log_level_set("SPIFFS",
  ESP_LOG_NONE) before and ESP_LOG_WARN after, so the expected
  'mount failed, -10025' on freshly-erased flash is suppressed.
- sdkconfig.defaults (new): CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y
  prevents the ESP-IDF init-time 'No core dump partition found!'
  warning that fires ~500 ms before setup() on every boot.
- platformio.ini: add board_build.sdkconfig_defaults = sdkconfig.defaults
  to all 12 hardware environments so the overlay is applied at build time.
…ye-spy)

Add tryOpenPort() helper — same as eye-spy fix. Handles the rare case
where a USB CDC board causes a DTR-triggered reset during port.open(),
causing Chrome to throw 'Failed to open serial port'.
- LED_FLASH_MS: 120ms → 30000ms for Atom Lite, Atom Voice, LilyGo T-Dongle-C5
  (120ms was imperceptible; camera now holds red LED for 30 seconds on detection)
- HB_DEVICE_ACTIVE_MS: 3000ms → 120000ms
  (was shorter than HB_BEEP_INTERVAL_MS=10000ms so heartbeat beep could never fire)
- flash.sh FTDI menu options 1/2/3 now default to BLE variants:
    1) m5atom-lite-ble   (Atom Lite + BLE)
    2) m5atom-echo-ble   (Atom Echo + BLE)
    3) m5atom-voice-ble  (Atom Voice + BLE)
  Default (*) also falls through to m5atom-lite-ble
- LICENSE: Apache 2.0 with 'Copyright 2024 SimeonOnSecurity' footer
- NOTICE: attribution requirement for redistributors / derivative works
- main.cpp: SPDX-License-Identifier + SPDX-FileCopyrightText header
- README.md: license badge + author badge

Any redistribution or derivative work must retain the NOTICE file and
credit SimeonOnSecurity (https://github.com/simeononsecurity).
- m5basic_display.h: full display driver using M5Unified (LovyanGFX)
  - Scanning screen: channel, mode, OUI counts, runtime, SPIFFS status
  - Detection screen: method (large), MAC, RSSI, channel, SSID, confidence
    bar with label, time since last detection, session count
  - Button A: force SPIFFS session save
  - Button B: cycle brightness (40/160/255)
  - Button C: force channel hop + clear alert screen
- main.cpp: USE_M5BASIC guards for config, M5Unified init, display hooks
  in printHeartbeat() + drainAlertQueue() + loop()
- platformio.ini: m5stack-basic + m5stack-basic-ble environments
  (board=m5stack-core-esp32, lib=m5stack/M5Unified@^0.2.2)
…t loop)

Symptom (reported on Atom Echo, real hardware):
  E (444) esp_core_dump_flash: No core dump partition found!
  ets Jun  8 2016 00:22:57
  rst:0x7 (TG0WDT_SYS_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
  flash read err, 1000
  ets_main.c 371
  [repeats forever]

Root cause: all m5atom-* environments (echo, echo-ble, lite, lite-ble,
voice, voice-ble) used upload_speed = 1500000, matching the m5stack-atom
board's aggressive default. Many Atom Lite/Echo/Voice units' onboard
USB-serial bridge (CH9102/CP210x, varies by production batch/cable) can't
reliably sustain 1.5 Mbaud. A flaky upload at that speed can silently
corrupt bytes written to the bootloader or app partition; the ROM
bootloader then fails to read a valid second-stage image on the next
boot, producing the endless TG0/TG1WDT_SYS_RESET + "flash read err, 1000"
loop shown above -- this happens before any application code (or even
the second-stage bootloader) gets a chance to run, so it's a flashing
transport problem, not a firmware logic bug.

Fix: lower upload_speed to 921600 for all six m5atom-* environments --
the same conservative rate already used successfully by every other
environment in this file (esp32dev, esp32dev-ble, lilygo-t-dongle-c5[-ble]).

Verified m5atom-echo and m5atom-echo-ble still build successfully
(upload_speed only affects the flashing step, not compilation).

If a unit is already stuck in this boot loop from a prior bad flash at
1.5 Mbaud, a full flash erase before reflashing clears any corrupted
residual bootloader/partition data:
  pio run -e m5atom-echo -t erase
  pio run -e m5atom-echo -t upload
Follow-up to the reverted "lower upload_speed" attempt. Real hardware
testing showed baud-rate reliability on M5Atom's USB-serial bridges
(CH9102/CP210x, varies by production batch) does not scale simply with
speed:

  - At the board-default 1500000 baud, upload completes ("Flash
    complete!") but the device then boot-loops with repeated
    "flash read err, 1000" (corrupted bootloader/app image from an
    unstable high-speed write).

  - At a lower 921600 baud, upload itself fails outright with
    "Unable to verify flash chip connection (No serial data
    received.)" right as esptool switches speeds -- worse, not better.

Since neither a fixed higher nor fixed lower upload_speed is
universally correct across different physical units/cables, reverted
the platformio.ini upload_speed change entirely (back to the vendor
default 1500000 for all m5atom-* environments) and instead made
flash.sh's flash_device() resilient: if the primary `pio run -t upload`
attempt fails, it now automatically retries once with a full flash
erase followed by an upload at a different, conservative fallback baud
(460800, via PLATFORMIO_UPLOAD_SPEED env var override -- no
platformio.ini changes needed). If that also fails, it prints an
explicit manual fallback command using 115200 baud.

This fixes flaky units without touching the config for units that
already flash reliably at the default speed, and without introducing
Yet Another regression from guessing a single "correct" fixed speed.

Verified: `bash -n flash.sh` (syntax check only -- no physical hardware
available to exercise the actual retry path in this environment).
Follow-up to f392346 (the erase+fallback-baud retry). Real hardware
testing surfaced a second bug: after the first upload attempt fails,
the device can be mid-reset for a moment (or, rarely, macOS
re-enumerates it under a different /dev/cu.usbserial-XXXX ID). The
retry logic was blindly reusing the original $port variable for both
the erase and fallback-baud upload steps, so it failed immediately
with:

  A fatal error occurred: Could not open /dev/cu.usbserial-XXXX,
  the port doesn't exist

Fix: added wait_for_usbserial_port(), which polls for up to 8s for the
expected port to reappear, falling back to whatever usbserial port IS
currently present if the device re-enumerated under a new ID. This is
now called before both the erase step and the fallback upload step
(erase can itself trigger another reset, so the port is re-checked a
second time immediately before the final upload attempt too).

Verified: bash -n flash.sh (syntax only -- no physical hardware
available in this environment to exercise the retry path end-to-end).
ROOT CAUSE FOUND after exhaustive on-device bisection (2 physical M5Atom
Echo units): the call

    Serial1.begin(MIRROR_BAUD, SERIAL_8N1, -1, MIRROR_TX_PIN);

in setup() reliably corrupts the ESP32's ability to read its own SPI
flash on every subsequent boot, producing an unrecoverable (until a full
chip erase) boot loop:

    rst:0x7/0x8 (TG0/TG1WDT_SYS_RESET) ... flash read err, 1000

This affected every board where the guard on that call is active:
ESP32 DevKit (esp32dev, esp32dev-ble) and M5Atom Echo (m5atom-echo,
m5atom-echo-ble) — M5Atom Lite/Voice, M5Basic/Core2, and M5StickC Plus SE
were never affected because their own guards already excluded them from
this Serial1.begin() call.

Diagnosis method: an exhaustive process of elimination directly on real
hardware, since the repo's git history showed the exact same failure on
a commit from ~4 days ago (predating this week's changes entirely),
ruling out any recent regression:
  - A blank sketch, and even one padded with a 900KB+ data array, boots
    and runs indefinitely on the same hardware/toolchain -- not a
    hardware or flash-chip defect.
  - Disabling SPIFFS.begin(), the WiFi/promiscuous init block, all 5
    IRAM_ATTR functions, and startupBeep() individually made no
    difference -- ruled each out.
  - Disabling ONLY this Serial1.begin() call, with the entire rest of
    the real firmware left fully intact, restored fully reliable,
    sustained boot/run behavior.
  - Re-enabling the call with an explicit (non -1) RX pin instead of -1
    still eventually crashed the device, just later in the boot
    sequence -- confirming the problem is UART1 usage on this
    hardware/pin combination in general, not merely the "-1 = auto pin"
    argument choice.

MIRROR_SERIAL was only ever an optional secondary-log-output feature
(mirroring detection output to a second UART for external hardware),
not required for core detector functionality. Given it's now proven to
reliably and permanently break boot on real hardware, it now defaults
OFF (MIRROR_SERIAL 0).

Verified on real hardware: the exact m5atom-echo-ble build the bug was
originally reported on now boots cleanly ("[flockyou] SPIFFS ready" /
"[flockyou] BLE scanner init OK") and stays up indefinitely across
multiple monitoring windows with no reboot.

Also verified: all affected + related environments still build
successfully (esp32dev, esp32dev-ble, m5atom-echo, m5atom-lite,
m5atom-lite-ble, m5atom-voice), and all 38 native unit tests pass.
The M5Atom Echo has always had an onboard SK6812 RGB LED on GPIO27,
shared hardware with Atom Lite/Voice (confirmed via git history back
to the original board-support commit cc1654a). It was disabled in
two compounding steps:

1. Commit 185abc1 ('v2' refactor, unrelated broad feature change)
   flipped USE_LED 1->0 and USE_M5_SPEAKER 1->0 for USE_M5ATOM_ECHO
   as unintentional collateral damage — its commit message never
   mentions touching Echo's LED.
2. Commit 2a6dfb0 (this session's LED/confidence modularization into
   led_neopixel.h/led_gpio.h/fy_confidence.h) further excluded
   USE_M5ATOM_ECHO from led_neopixel.h's include guard, baking the
   regression in at the header level.

Fix (main.cpp):
  - Give USE_M5ATOM_ECHO access to LED_PIN/NUM_LEDS/BUTTON_PIN in the
    'M5Atom LED support' block (deliberately NOT defining USE_M5ATOM
    itself for Echo, since Echo's buzzer pinMode() init is gated on
    '!defined(USE_M5ATOM)' and Echo, unlike Lite/Voice, uses a real
    GPIO25 buzzer).
  - Restore USE_LED=1 / USE_LED_MATRIX=1 / LED_FLASH_MS=30000 in the
    CONFIG cascade's USE_M5ATOM_ECHO block (was USE_LED=0).
  - Gate the ledMatrixBootSequence()/BUTTON_PIN pinMode() call in
    setup() on '#if defined(USE_M5ATOM) || defined(USE_M5ATOM_ECHO)'
    so it now actually fires for Echo.

Fix (led_neopixel.h):
  - Add USE_M5ATOM_ECHO to the file's include guard so ledSet()/
    ledMatrixBootSequence() compile in for Echo at all.

Verified:
  - m5atom-echo, m5atom-echo-ble, m5atom-lite, m5atom-voice, esp32dev
    all build cleanly (no regressions).
  - Native test suite: 38/38 passing (no detection/confidence logic
    touched).
  - Flashed to real M5Atom Echo hardware: boots cleanly with no
    boot-loop/crash, ledMatrixBootSequence() runs to completion
    before '[flockyou] SPIFFS ready' prints, full startup log
    reaches '[flockyou] v2 WiFi detector started' normally.
…ye-spy 1e6c94e)

Splits display/button/audio rendering off the WiFi/BLE scanning hot path
onto its own FreeRTOS task (ui_task.h), pinned to the same core as
loop() so the WiFi/BT stack (core 0) is undisturbed:

- New ui_task.h: mutex-protected scan-status snapshot (uiPublishScan),
  a generation-counted single-slot alert mailbox (uiPublishAlert) to
  support flock's dual scanning/alert screen model (unlike eye-spy's
  single-screen model), a one-shot uiForceC5Redraw() flag for instant
  C5 button feedback, button-action feedback path (uiTakeButtonAction),
  and an M5Basic-only audio request path (uiRequestAudio/uiPlayChirp/
  uiPlayHeartbeatBeep) since M5.Speaker shares the M5Unified singleton
  with the display object the UI task now exclusively owns.
- main.cpp: wired setup()/loop()/drainAlertQueue()/screenTick()/
  printHeartbeat()/newDetectChirp()/heartbeatBeep() to publish to the
  UI task instead of calling M5Unified/display functions directly from
  the scan/main task (which is not thread-safe for that object).
- m5basic_display.h: fixed a cross-task race on the mb_logBuf serial-
  mirror ring buffer (mb_logAdd() now runs on the scan task while
  mb_drawLogStrip() now runs on the UI task) by adding mb_logMux and
  rewriting mb_drawLogStrip() to snapshot-then-draw outside the lock.

LED handling intentionally NOT moved to the UI task: USE_LED never
co-occurs with a display/UI-task board, so ledFlash()/ledTick() are
unaffected.

Build-verified: m5stack-basic, m5stack-basic-ble, m5stack-core2-aws,
m5stickc-plus-se, esp32dev, m5atom-echo, m5atom-voices3r all compile
clean; native unit test suite (38/38) passes. lilygo-t-dongle-c5 hits
a pre-existing, unrelated local ESP32-C5 toolchain packaging gap
(fails in platform-espressif32's BuildFrameworks step before any
project source is compiled).
Root-cause fix for user-reported 'hit and miss' detection (silent while
stationary next to a confirmed camera but beeps while driving through an
area; some cameras only alert while stopped at a light, never on drive-by).

Ported directly from colonelpanichacks/flock-you upstream, credited there
to field observation by nsm_barri: Flock cameras hop 1 -> 6 -> 11
(ascending) at ~125ms/channel. Our previous ascending {1,6,11} scan order
at 350ms dwell could fall into a persistent bad-phase lockstep with a
camera's repeating hop cycle, so the two dwell windows never overlap for
as long as both keep repeating. Scanning in DESCENDING order (11,6,1)
makes the two radios sweep toward each other every cycle instead of
chasing in the same direction, converging on overlap far more reliably
regardless of starting phase. Dwell is dropped to 250ms (2x the camera's
observed ~125ms hop) to also shrink worst-case time-to-overlap.

Also researched jbohack/nyanBOX for comparison: its OUI list is a strict
subset of ours (no new confirmed OUIs to add) and its scan design is less
continuous than ours (fully stops WiFi radio during BLE phase, then goes
idle 30s between scans) -- nothing worth porting from it.

Deliberately did NOT copy upstream's CHECK_ADDR1=0/CHECK_ADDR3=0 or
ENABLE_SSID_MATCH=0 -- our confidence-scoring system already keeps
addr1/addr3-only hits below CHIRP_MIN_CONFIDENCE so they can't chirp
alone, and SSID matching is required for LAA-MAC 'Flock Camera net.'
detections upstream doesn't target. Also documented (but did not yet
implement) porting upstream's 802.11 IE-fingerprint signature check as
an additive CS_IE_SIG_MATCH confidence bonus for future work.

Build-verified: esp32dev, esp32dev-ble, m5atom-lite (hardware envs) and
native unit tests (38/38 passing).

See DETECTION_IMPROVEMENTS.md Future Work items 7-8 for deferred/researched
items.
setup() previously called initBLE() (NimBLE controller init) before
esp_wifi_start(), based on a comment claiming shared-radio coexistence
required that order. This matches a known ESP-IDF failure mode where
bringing up the BT controller before esp_wifi_start() can leave the
coexistence arbiter in a state where esp_wifi_start() hangs forever --
matching a field report of the m5atom-lite-ble build hanging right
after printing "BLE scanner init OK" and never reaching loop() (so
the LED never advances past its boot-flash color -- not a separate LED
bug, ledTick()/drainAlertQueue() only run from loop()).

Espressif's own WiFi/BT coex examples always init+start WiFi first,
then bring up the BT controller, so this reorders setup() to match.
Also adds dualPrintln() checkpoints between every major init call so
if a hang recurs, the last-printed line pinpoints exactly which call
is responsible.

Build-verified (RAM/Flash usage nominal, zero compile errors) across
m5atom-lite-ble, m5atom-lite, esp32dev-ble, esp32dev. Not yet
hardware/boot-verified -- no device was available this session.
New standalone firmware (beacon_test.cpp + beacon_frames.h, separate
from main.cpp) that broadcasts fake Flock-matching WiFi/BLE signals
covering every detection path in main.cpp's confidence-scoring logic:
OUI matches on addr1/addr2/addr3, wildcard probe requests (high-tier
and mfr-tier OUI), Flock/SoundThinking/Flock-Camera-net SSID keyword
matches, LAA-bit MAC + SSID keyword combo, sequential-MAC-pair bonus,
and BLE name/manufacturer-ID/Raven-service-UUID matches.

Intended use: flash to a SEPARATE M5Atom Lite (pio run -e
m5atom-lite-beacon -t upload) and leave it running near a real
detector to immediately and repeatedly confirm every detection path
still fires, without needing to physically locate a real Flock camera.
Cycles through 12 scenarios in shuffled order every 4s (guaranteeing
full coverage each pass); the onboard button (GPIO39) force-fires the
next scenario on demand; the NeoPixel LED pulses on each transmission
as a visual TX indicator.

WiFi frames use raw 802.11 injection via esp_wifi_80211_tx() (Beacon /
Probe Request / Probe Response only) swept across channels 1/6/11 for
reliability against a channel-hopping receiver. BLE scenarios use
NimBLE-Arduino, constructing a fresh NimBLEAdvertisementData object
per scenario to avoid field accumulation across rotations.

Adds new [env:m5atom-lite-beacon] PlatformIO environment (mirrors
m5atom-lite-ble's board/upload settings, but builds beacon_test.cpp
instead of main.cpp). Build-verified: RAM 53332B, Flash 993125B, zero
compile errors.
- New interactive menu option 9: 'Beacon Tester (Atom Lite)' — flashes
  the m5atom-lite-beacon PlatformIO environment (beacon_test.cpp),
  with an explanatory note that it's a signal broadcaster for testing
  a separate real detector, not a detector itself.
- show_boot_output() is now firmware-aware: it previously only
  recognized the real detector's '[flockyou]' log tag and 'OUIs:'
  startup banner, so flashing the beacon tester always fell through
  to a false '⚠️ No firmware output seen' warning even though it
  booted fine. It now accepts an optional env parameter and selects
  the correct tag/banner/heartbeat pattern set: '[flockyou]' + 'OUIs:'
  banner for the real detector, or '[beacon]' + 'ready — N scenarios'
  for the beacon tester.
- CI (build-firmware.yml): build, verify, package, and generate an ESP
  Web Tools manifest for the m5atom-lite-beacon environment alongside
  the existing detector builds, so it gets deployed to the public
  GitHub Pages site like every other firmware variant.

- Web flasher (docs/index.html): split the old single-step board+
  firmware picker into two steps - "Select Your Board" followed by a
  new dynamically-rendered "Select Firmware" step. Each board maps to
  its normal WiFi+BLE detector manifest plus an optional list of extra
  firmware variants. The M5Atom Lite now also offers a "Beacon Tester"
  card, which broadcasts fake Flock WiFi/BLE signals so people can
  verify a separate real detector works from home without needing to
  find an actual camera.

- The firmware-variant step always re-renders and defaults back to the
  normal detector whenever the board selection changes, so nobody can
  accidentally carry over a non-detector firmware choice across boards.
Root cause of a user-reported bug: the real detector's serial log showed
"BLE-Flock" hits (from the beacon-tester tool's BLE-only test scenarios),
but the LED never turned red and nothing else happened.

fyProcessBLEAdvertisedDevice() matches three BLE signal types (Flock's
0x09C8 mfr-ID, a Raven/Flock 128-bit service UUID, or a device-name
substring), but previously only:
  - printed a raw Serial line, and
  - set g_bleFlockLastSeen/g_bleFlockRssi, which is ONLY read later as a
    confidence *booster* for a SEPARATE WiFi-frame-based hit within a 60s
    correlation window (see fy_confidence.h's CS_BLE_CORR).

It never called enqueueAlert(), so a BLE-only match (no corroborating WiFi
frame) was completely invisible: no LED flash, no chirp, no detection-table
entry, no JSON/dashboard emission. This silently swallowed 3 of the 12
beacon_test.cpp scenarios (BLE mfr-ID/Raven-UUID/name, ~25% of test
coverage) and would do the same for any real BLE-only device class.

Fix:
  - Moved the ALERT QUEUE section (AlertType enum, AlertEntry, the queue,
    and enqueueAlert()) earlier in main.cpp, ahead of the BLE section, so
    BLE code can enqueue alerts directly.
  - Added three new AlertType values: ALERT_BLE_MFR_ID, ALERT_BLE_RAVEN_UUID,
    ALERT_BLE_NAME.
  - Added standalone BLE confidence tiers (CS_BLE_MFR_ID_STANDALONE=45,
    CS_BLE_UUID_STANDALONE=45, CS_BLE_NAME_STANDALONE=35, all above
    CHIRP_MIN_CONFIDENCE=30) plus a strong-RSSI bonus, mirroring the
    existing WiFi OUI-tier philosophy.
  - fyProcessBLEAdvertisedDevice() now enqueues a real alert for any
    confident BLE-only match, so it gets the same LED flash + chirp +
    detection-table entry + JSON emission as WiFi detections.
  - alertTypeToMethod() and drainAlertQueue()'s human-readable log line
    updated with BLE-aware branches (method names ble_mfr_id/
    ble_raven_uuid/ble_name; a dedicated "DETECT-BLE" log line that omits
    the meaningless WiFi channel field).
  - emitDetectionJSON() no longer mislabels BLE detections as
    "wifi_ble_..." / "wifi_unknown" — it now emits "protocol":"ble" and
    drops the "wifi_" prefix for these three method names.

Build-verified: m5atom-lite-ble (real detector target), m5atom-lite
(non-BLE build path, unaffected), and esp32dev-ble (different board/LED
config) all compile cleanly with no errors.
Root cause: the prior commit (42a15b0) that was supposed to make BLE-only
Flock matches (mfr-ID/Raven-UUID/name) enqueue a real alert only ever
touched emitDetectionJSON()'s BLE-labeling logic. fyProcessBLEAdvertisedDevice()
itself was never changed -- it still only set g_bleFlockLastSeen (a
WiFi-correlation timestamp) and printed a raw Serial line. No enqueueAlert()
call existed anywhere in the BLE path, so BLE-only detections remained
completely invisible: no LED flash, no chirp, no detection-table entry, no
JSON emission -- exactly the behavior reported.

Actual fix this time:
  - Moved the ALERT QUEUE section (AlertType enum, AlertEntry, alertQueue,
    enqueueAlert()) ahead of the BLE section so BLE code can call it.
  - Added ALERT_BLE_MFR_ID / ALERT_BLE_RAVEN_UUID / ALERT_BLE_NAME AlertType
    values and standalone confidence tiers (45/45/35, all >= CHIRP_MIN_CONFIDENCE)
    in both main.cpp (pre-fy_confidence.h duplicate, needed at compile time)
    and fy_confidence.h's computeConfidence().
  - fyProcessBLEAdvertisedDevice() now tracks which signal matched and calls
    enqueueAlert() with the BLE device's own MAC, a synthesized confidence
    score (+5 strong-RSSI bonus), channel=0, so it flows through the exact
    same drainAlertQueue() pipeline as WiFi hits: detection-table entry,
    JSON emission, chirp, and ledFlash(LED_FLASH_MS).
  - alertTypeToMethod() returns ble_mfr_id/ble_raven_uuid/ble_name so
    emitDetectionJSON()'s existing isBle check (strncmp method, "ble_")
    correctly tags protocol:"ble".
  - drainAlertQueue() gets a dedicated DETECT-BLE log line (omits the
    meaningless WiFi channel field) and BLE-aware uiPublishAlert() dispType.

Build-verified clean: m5atom-lite-ble, m5atom-lite, esp32dev-ble,
lilygo-t-dongle-c5-ble (NimBLE 2.x API path), m5atom-lite-beacon.
… build

ROOT CAUSE (explains the ENTIRE original bug report -- BLE-only Flock
detections never producing a real alert -- not just a self-test artifact):

bleCoexStart(), called directly from setup() on every single -ble
PlatformIO environment (10 across all supported boards), did:

    g_pBLEScan->start(0, false);

NimBLEScan::start() has two overloads:

    bool               start(uint32_t duration, void(*cb)(NimBLEScanResults), bool is_continue = false);  // async
    NimBLEScanResults  start(uint32_t duration, bool is_continue = false);                                 // BLOCKING

An (int, bool) argument list resolves to the BLOCKING overload, not the
intended async one. With duration=0 (BLE_HS_FOREVER internally), this
parks the calling task in ulTaskNotifyTake(pdTRUE, portMAX_DELAY) forever
-- and since bleCoexStart() runs inside setup(), setup() itself never
returned, so loop() never ran on ANY BLE_COEX_MODE build. The BLE host's
own background task could still independently invoke onResult() and print
a single "BLE-Flock rssi=..." line, but drainAlertQueue()/ledTick()/
bleScanTick() all live in loop() and never executed -- explaining every
symptom reported: no LED, no chirp, no DETECT-BLE log line, no JSON, and
eventual Task Watchdog reboot (rst:0x1 POWERON_RESET repeating).

Fixed all 3 affected call sites (bleCoexStart(), bleScanTick()'s
coex-restart branch, and the legacy non-coex bleScanStart()) by passing an
explicit typed null callback to force resolution to the async overload:

    g_pBLEScan->start(0, (void (*)(NimBLEScanResults))nullptr);

Hardware-validated via genuine two-board cross-device testing (one board
running ble_selftest.h / beacon_test.cpp as a transmitter, a second board
running the real production BLE_COEX detector as receiver):
  - setup()/loop() now complete and run continuously, zero freezes/reboots
    over repeated 60-70s capture windows
  - WiFi detections confirmed: ALERT_OUI_ADDR3 (conf=37), ALERT_LAA_SSID
    "Flock Camera net." (conf=62) -- both triggered chirp+LED
  - BLE detections confirmed: ble_mfr_id, ble_raven_uuid, ble_name -- all
    produced full DETECT-BLE -> JSON -> SPIFFS session-save pipeline
    execution with confidence scores above the chirp/LED threshold
  - Build-verified clean across all 10 -ble environments (esp32dev,
    m5atom-{lite,echo,voice,voices3r}, m5stack-{basic,core2-aws},
    m5stickc-plus-se, lilygo-t-dongle-c5 incl. NimBLE 2.x API surface)

Also adds ble_selftest.h (BLE_SELF_TEST=1, new m5atom-lite-ble-selftest
env): lets a single board periodically self-advertise each of the 3 BLE
Flock signatures and pick them back up via its own always-on coex scan,
for validating the alert pipeline without needing a second board. Fixes
two related issues found during this work:
  - Previous versions called g_pBLEScan->start()/stop() directly inside
    the self-test burst, hitting the exact same blocking-overload bug
    above and hanging after the first detection.
  - Address randomization (so repeated bursts don't look like the exact
    same device) silently failed every time with HCI 0x0C "Command
    Disallowed" (NimBLE rc=524) because ble_hs_id_set_rnd() cannot run
    while a BLE scan is active. Now briefly pauses/resumes the continuous
    scan around each randomize+advertise burst (using the same
    overload-safe async start call) -- hardware-confirmed receiving 4
    distinct random MAC addresses across 4 self-test bursts.
Adds clean-code, test-before-commit, file-decomposition, and detection-
methods reference rules, plus a self-updating meta-rule instructing
future sessions to keep this folder in sync with the codebase. Captures
lessons learned from this session's investigation (NimBLEScan overload
resolution, DTR/RTS-safe serial capture, two-board cross-device testing
methodology) so future agent sessions don't have to re-derive them.
Root cause: CHANNEL_DWELL_MS=250 meant a full {11,6,1} hop rotation took
750ms. Any Flock transmission shorter than that window could land
entirely within a dwell period on the WRONG channel and be missed for
that whole hop cycle, with no second chance until the next transmission.
Cross-device testing (beacon_test.cpp -> a real m5atom-lite-ble detector)
showed exactly this: matches succeeded when a burst happened to land in
the right dwell window and were silently missed otherwise, with no
matching-logic difference between hits and misses.

Fixes:

- main.cpp: CHANNEL_DWELL_MS 250 -> 100ms, shrinking a full rotation to
  ~300ms and greatly increasing the odds any short-lived transmission
  overlaps a dwell window on the correct channel.

- beacon_test.cpp: SWEEP_PASSES 2 -> 6 (each WiFi scenario's txSweep()
  burst grows from ~192ms to ~576ms), so the tester's own burst duration
  reliably exceeds one full detector hop rotation -- necessary for the
  test itself to be a reliable indicator now that the detector hops
  faster.

- main.cpp: new channel-lock feature (maybeLockChannel(),
  channelLockActive, CHANNEL_LOCK_TIMEOUT_MS=5000). Once a chirp-worthy
  WiFi detection fires, stop hopping and hold on that exact channel
  instead of spending 2/3 of dwell time on channels with nothing
  confirmed -- keeps receiving frames from a camera we know is live right
  now. Releases automatically after 5s of no fresh qualifying hit on that
  channel. BLE alerts never trigger this (no WiFi channel concept
  applies to them, and BLE_COEX_MODE's scan runs independently of
  currentChannel regardless).

Hardware-validated via two-board cross-device testing
(m5atom-lite-beacon -> m5atom-lite-ble): no freezes/reboots across two
independent ~90s runs, channel lock engages/releases correctly with
clean 5s-timeout cycles. Build-verified across esp32dev, esp32dev-ble,
m5atom-lite-ble, m5atom-lite-beacon, lilygo-t-dongle-c5-ble,
m5stickc-plus-se-ble, and native (38/38 unit tests passing).

Also documents (in .clinerules/04-detection-methods.md) an open,
unresolved detection-miss pattern found during this same hardware
validation: several alert types (ALERT_OUI_ADDR1/ADDR2/ADDR3,
ALERT_LAA_SSID, SEQ_MAC_PAIR_BONUS, ALERT_BLE_RAVEN_UUID,
ALERT_BLE_NAME) were never caught across ~17 combined beacon_test.cpp
fires in these runs, while structurally similar alert types (wildcard
probe, SSID, mfr OUI, SoundThinking, BLE mfr ID) were reliably caught.
Two independent full code-review passes found no coding bug explaining
this specific pattern (including specifically ruling out channel-lock as
a new cause, since beacon_test.cpp's txSweep() always covers all three
channels regardless of the detector's current channel). Flagged as a
known limitation requiring hardware instrumentation (frame-arrival
counters) as the next diagnostic step, rather than left for a future
session to silently rediscover.
Root cause: heartbeatTick() ran unconditionally every loop() iteration
and re-fired heartbeatBeep() every 10s as long as any target had been
seen within the trailing 2-minute window (HB_DEVICE_ACTIVE_MS), entirely
independent of whether a genuinely NEW detection had just occurred. This
produced confusing/'random' beeping with no corresponding fresh alert,
since the beep timing was decoupled from the detection event that
originally triggered it.

Fix: remove heartbeatTick(), its call site in loop(), the
fyLastHeartbeatAt timestamp, and the now-unused HB_DEVICE_ACTIVE_MS /
HB_BEEP_INTERVAL_MS constants. Audio feedback now fires exclusively from
newDetectChirp() inside drainAlertQueue()'s existing chirp-worthy block
(chirpWorthy && confidence >= CHIRP_MIN_CONFIDENCE) -- i.e. only on a
genuine new-detection event.

heartbeatBeep() itself (the function, plus HB_BEEP_HZ/NOTE_MS/GAP_MS) is
intentionally kept, since it's still reused as a deliberate button-press
acknowledgement sound in the HAS_SIMPLE_BUTTON handler in loop() -- that
is a user-triggered action, not ambient idle beeping, and is unaffected
by this change.

README.md's Audio Feedback section updated to remove the stale
'### Heartbeat' description and document that the new-detection chirp is
now the only runtime audio alert.

Build-verified: esp32dev, m5atom-lite (USE_BUZZER), m5stack-basic
(USE_M5_SPEAKER), esp32dev-ble (BLE_COEX_MODE) all build clean; all 38
native unit tests (test_uuid_matching, test_ble_matching) pass --
confirms fy_detect.h detection-pattern logic is unaffected.
…3R/EchoS3R identity

Atom VoiceS3R and Atom Echo S3R are the same physical board (M5Unified's
board_M5AtomEchoS3R is a deprecated alias of board_M5AtomVoiceS3R per
M5GFX/src/lgfx/boards.hpp). flash.sh's menu options 4/5 previously flashed
the non-BLE m5atom-voices3r environment while every other menu option
flashes its -ble variant -- an inconsistency, not an intentional choice.
Fixed to flash m5atom-voices3r-ble for both options, matching the web
flasher/CI convention, and switched flash_device()'s env matching from an
exact string compare to a glob (m5atom-voices3r*) plus $env-based path
substitution so the native-USB two-stage flash path works for either
environment without hardcoding the literal name in five separate places.
Documented the hardware-identity fact itself in platformio.ini (above
[env:m5atom-voices3r]) and docs/index.html so future edits don't
reintroduce a split "echos3r" environment.
… short high-confidence display time

drainAlertQueue() calls uiPublishAlert() for EVERY dequeued alert
regardless of confidence (only chirp/LED are gated on
CHIRP_MIN_CONFIDENCE). uiTaskFn() previously redrew the alert screen
unconditionally on every freshAlert, and each board's *Detection() call
unconditionally reset its own MB_ALERT_HOLD_MS/MSC_ALERT_HOLD_MS hold
timer -- so a low-confidence alert (e.g. ALERT_OUI_MFR, conf=20) arriving
mid-hold-window would cut short and overwrite a high-confidence
detection's (e.g. ALERT_OUI_ADDR2, conf=40) screen time. Symptom: rapid
screen flicker when several alerts land close together, and
'most-recently-fired-wins' instead of 'most-important-wins'.

Added uiAlertMaySupersede(), which tracks the confidence+MAC of whatever
is currently displayed and only lets a new alert redraw the screen if
it's at least as important, the same target re-firing, or the hold
window has fully elapsed. Alerts that lose this comparison are still
logged/JSON'd/counted as before -- only the on-screen draw is withheld.

Also raised the hold window itself from 4s to 15s (MB_ALERT_HOLD_MS /
MSC_ALERT_HOLD_MS / UI_ALERT_HOLD_MS, kept in sync across all three
files since ui_task.h can't reference the per-board constants -- it's
included after them by main.cpp): at 4s, even a losing low-confidence
alert's own board-level hold-reset logic could still expire the winning
alert's hold almost immediately, defeating the point of the gate above.
…D as hardware limitation

ROOT CAUSE (speaker producing no sound on real hardware): the
USE_M5ATOM_VOICES3R setup() block called M5.config()/M5.begin()/
M5.Speaker.setVolume() but never called M5.Speaker.begin() explicitly.
M5Unified's _begin_audio() (run inside M5.begin()) configures the ES8311
codec's I2S pins for this board but deliberately never calls
Speaker.begin() itself for ANY board -- that's left to the application.
M5.Speaker.tone() *can* lazily call begin() on first use
(Speaker_Class::_play_raw()), but that lazy-init guard
(`if (!begin() || (_task_handle == nullptr)) { return true; }`) returns
*success* even when the lazy begin() call fails (e.g. the ES8311 I2C
enable-register write or I2S peripheral setup failing) -- producing
total audio silence with zero error trace anywhere. Confirmed via direct
read of both the installed M5Unified 0.2.19 and the latest published
0.2.20 sources (no version bump needed/helps here).

Fixed by calling M5.Speaker.begin() explicitly and logging a failure if
it returns false, plus logging M5.getBoard() right after M5.begin() so a
board-auto-detection failure (M5Unified's I2C probe for the ES8311 codec
at addr 0x18 missing and silently falling back to board_M5StampS3Mini,
which has no speaker/mic pins configured at all) is distinguishable in
serial output from an init failure on correctly-detected hardware.

LED (also reported non-functional): confirmed by diffing M5Unified's RGB
LED pin table (_pin_table_other0[]) between the pinned 0.2.19 and the
latest published 0.2.20 that board_M5AtomVoiceS3R has NO entry in either
version, unlike Atom Lite/Matrix/Voice (SK6812 on GPIO27). This is a
genuine hardware limitation of the Atom VoiceS3R/Echo S3R module
(audio-only, no discrete addressable LED), not a library version-lag
bug or a missed init step -- documented in place so it isn't
re-investigated as a bug in a future session.

Unrelated cleanup folded in from the same setup() region: removed a
dead USE_M5ATOM_ECHO_BTN define that was never referenced anywhere, and
removed a duplicate pinMode(BUTTON_PIN, INPUT_PULLUP) call that the
HAS_SIMPLE_BUTTON block above already performs for every board that
reaches the USE_M5ATOM/USE_M5ATOM_ECHO branch.
…tection gotchas

Cross-referenced knowledge from the main.cpp Atom VoiceS3R/EchoS3R
speaker+LED fix -- per .clinerules/05-keep-rules-current.md, hardware
quirks whose root cause wasn't obvious from the code alone belong here so
a future session doesn't have to re-derive the M5Unified source
investigation from scratch.
…isidentifies unit

Root cause (confirmed on real hardware): M5Unified identifies the Atom
VoiceS3R by probing for its ES8311 codec over I2C
(_detect_i2c_device(45, 0, 0x18) in M5Unified.cpp's _check_boardtype()).
On the physical unit tested, this probe failed and M5.getBoard()
resolved to board_M5StampS3Mini (143) instead of board_M5AtomVoiceS3R
(145). board_M5StampS3Mini has zero speaker/mic pin configuration in
M5Unified's private _begin_audio(), so M5.Speaker's I2S pins were never
set and the ES8311 codec's I2C power-up sequence never ran. Every prior
"fix" that only added error logging around M5.Speaker.begin() couldn't
catch this, since begin() runs against unconfigured/wrong pins and the
whole board-identity mismatch is invisible unless M5.getBoard() itself
is logged and checked.

There is no public M5Unified API to force board identity in this
scenario: config_t's fallback_board is only consulted when
_check_boardtype() returns board_unknown, but a failed I2C probe still
resolves to a concrete (wrong) board, never board_unknown - so
fallback_board is a dead end here, and _board has no public setter.

Fix: unconditionally (regardless of what M5.getBoard() reports) replicate
the exact I2S pin config, ES8311 codec register init sequence, and
NS4150B amp-enable GPIO that M5Unified's own _begin_audio() /
_speaker_enabled_cb_atom_echos3r() would run for a correctly-detected
board_M5AtomVoiceS3R. Pin values and register sequence were read
directly out of M5Unified.cpp and cross-checked against M5Stack's
official Atom VoiceS3R pin map. This is safe on correctly-detected units
too (Speaker_Class no-ops the codec-enable callback when unset), so it's
a harmless duplicate there and the actual fix on a misdetected one.

Hardware-verified: flashed to the physical unit (esptool.py direct
invocation with --baud 115200 --no-stub, since this ESP32-S3
native-USB-Serial/JTAG board fails PlatformIO's normal upload path at
the 460800-baud switch). Serial capture confirms clean, stable boot with
no errors and a normal repeating main loop ("[flockyou] scanning...").
Also confirmed the codec-init error-logging path itself fires correctly
under artificial rapid-reset stress testing (not observed on normal
single-boot operation) - see .clinerules/01-clean-code.md for the
ES8311-not-reset-by-ESP32-reset-line caveat this surfaced.
…l upload workaround

Leftover documentation from the VoiceS3R hardware-verification task
(commits e8c3902/0bb8fe5/7a0def6) that was written during that session's
investigation but never actually staged/committed at the time.

Two findings worth preserving:
- pyserial's Serial() construction/.open() reliably triggers an
  rst:0x15 (USB_UART_CHIP_RESET) on ESP32-S3 native-USB-Serial/JTOG
  boards even with dtr/rts forced False immediately after construction,
  because the OS-level control-line assertion happens transiently during
  the open() syscall itself, before Python code can react. A naive
  reopen-on-error loop will masquerade as a firmware crash-loop. Always
  check the ROM banner's reset-reason code (rst:0x15 = tool-triggered,
  not firmware) before concluding a hang is real; prefer the
  platformio-mcp start_monitor/query_logs tools for passive health
  checks instead of manual pyserial reopen loops.
- PlatformIO's normal upload path can fail on these boards with "No
  serial data received" right after esptool requests 460800 baud
  (PLATFORMIO_UPLOAD_SPEED has no effect). Workaround is a direct
  esptool.py invocation at --baud 115200 with the four image/offset
  pairs pulled from .pio/build/<env>/.
The web flasher (docs/index.html) and its CI build (build-firmware.yml)
previously only built/published one environment per board -- whichever
one flash.sh treated as the 'default'. This left several genuinely
distinct, user-flashable firmwares (the no-BLE builds, the BLE
self-test tool, the beacon tester) unreachable from the web flasher
even though they're real PlatformIO environments anyone could already
build locally.

BLE_COEX_MODE/ENABLE_BLE_SCAN gate real conditional compilation in this
project (unlike eye-spy, where the equivalent flags are vestigial --
see the eye-spy repo's own commit for that distinction), so '-ble vs
non-ble' is a meaningful choice worth exposing, not just noise.

Changes:
- build-firmware.yml now builds and requires all 18 non-experimental
  PlatformIO environments (every -ble/non-ble pair per board, plus
  m5atom-lite-ble-selftest and m5atom-lite-beacon), refactored manifest
  generation into gen_esp32_manifest/gen_s3_manifest/gen_c5_manifest
  helpers to avoid ~20 near-duplicate manifest blocks. The two
  experimental lilygo-t-dongle-c5[-ble] environments remain in a
  separate continue-on-error step, advisory-only as before.
- docs/index.html: each board's default (BOARDS[x].normal) stays its
  -ble build, but every board now also lists a noBleVariant() entry in
  extra[] for the plain WiFi-only build. m5atom-lite's extra[] also
  gains the ble-selftest and beacon-tester tools. renderFirmwareVariants()
  already generically iterates extra[], so no JS logic changes were
  needed -- this is a data-only change.
- .clinerules/03-file-size-and-decomposition.md documents the resulting
  convention (every genuinely distinct env gets published; -ble is
  always the default) so future variant additions follow the same
  pattern instead of ad hoc one-offs.

All newly-exposed environments (m5atom-lite, m5atom-lite-ble-selftest,
esp32dev, m5stack-basic, m5stack-core2-aws, m5stickc-plus-se,
m5atom-echo, m5atom-voice, m5atom-voices3r, m5atom-lite-beacon)
build-verified successfully via platformio-mcp before this commit.
… tick

m5basicScanning() (M5Stack Basic + Core2 For AWS via USE_M5BASIC) and
m5stickcScanning() (M5StickC Plus SE) each compute a 'stale' boolean
(millis() - lastDrawMs >= 250) purely so the on-screen Runtime clock
visibly ticks even with zero new detections between draws. Both
functions previously treated 'stale' as fully equivalent to a genuine
dataChanged event (channel hop / new detection / explicit redraw
request) -- any stale tick took the exact same code path as a real
data change: fillRect(BLACK) over the entire content area, followed by
redrawing every element from scratch.

Since ui_task.h polls each board's *Scanning() function every ~50ms and
'stale' fires roughly every 250ms regardless of whether anything
actually changed, this produced a visible full-screen black flash ~4
times per second, continuously, for as long as the device was
scanning -- this was the reported 'screen flickering on update.'

Fix: split dataChanged from stale. A stale-only tick now only redraws
the Runtime/SPIFFS line in place, relying on the fact that all text
draws already use an opaque background color (setTextColor(fg, BLACK))
with fixed-width printf format specifiers (e.g. %-8s, %-3s), so
redrawing that single line at the same coordinates already fully
overwrites the previous frame's pixels with no separate clear needed.
The expensive full-clear-and-redraw path is now reserved for genuine
dataChanged events only, which are much rarer than 4Hz in practice.

SPIFFS status field padded to a fixed 3 chars (%-3s) in the stale-only
branch specifically so 'OK' (2 chars) fully overwrites a previous 'ERR'
(3 chars) with no stray trailing character left behind; the
dataChanged/full-redraw branch still uses plain %s since it always
follows a full black clear anyway.

c5_display.h (LILYGO T-Dongle C5) was checked and does NOT need this
fix: c5DisplayScanning() is only invoked from ui_task.h's 30-second
HEARTBEAT_MS-gated branch, not the ~250ms stale path used by the other
boards, so it was never redrawing at a rate that would produce visible
flicker.

Build-verified (fresh, non-cached compiles) across all 6 affected
PlatformIO environments: m5stack-basic[-ble], m5stack-core2-aws[-ble],
m5stickc-plus-se[-ble].
…redraw

The previous flicker fix (e2db1cb) split a purely time-based ~250ms
'stale' clock tick from a genuine 'dataChanged' trigger, but a user's
live-hardware retest showed the center content area was still
flickering (header bar and button bar were unaffected).

Root cause: dataChanged still included (ch != lastCh) as a trigger for
the expensive fillRect(BLACK)+full-body-redraw path. main.cpp hops the
WiFi promiscuous-mode channel every CHANNEL_DWELL_MS (100ms) while
scanning, so 'channel changed' was true almost continuously -- far more
often than the 250ms stale tick -- re-triggering the full black-flash
clear roughly 10x/second. The header/button bars never showed this
because *_header()/*_btnBar() fill their bars with their own solid
background color immediately before drawing text (never black), so
repainting them on every channel hop produces no visible flash; the
content-area path cleared to BLACK first and then drew many separate
text lines afterward, leaving a visible gap.

Fix: split the redraw trigger three ways in both m5basicScanning()
(m5basic_display.h, used by M5Stack Basic + Core2 For AWS) and
m5stickcScanning() (m5stickc_display.h):
  - headerChanged  = channel or detCount changed -> cheap header-bar-only
                      repaint (no black flash risk).
  - contentChanged = detCount changed or an explicit redraw was requested
                      -> the only case that still pays for the full
                      clear+redraw. Channel is deliberately excluded.
  - stale          = purely time-based (~250ms), unchanged from the
                      first fix -- keeps the Runtime clock ticking.

lastDrawMs (the stale-timer gate) is deliberately NOT updated on a bare
headerChanged-only tick, since headerChanged fires faster than the
250ms stale threshold and would otherwise nearly freeze the visible
Runtime clock.

Build-verified all 6 affected PlatformIO environments (m5stack-basic,
m5stack-basic-ble, m5stack-core2-aws, m5stack-core2-aws-ble,
m5stickc-plus-se, m5stickc-plus-se-ble) with fresh non-cached rebuilds.
Not yet re-verified on physical hardware by this session -- the first
fix round was also build-verified-only and later found insufficient by
the user's actual device test, so this should be treated as
code-review-confirmed (channel-hop mechanism directly confirmed via
CHANNEL_DWELL_MS in main.cpp) pending a fresh hardware retest.
Root cause: mb_drawLogStrip() unconditionally did a
fillRect(BLACK)+text-redraw on EVERY call, including from the ~250ms
"stale" tick inside m5basicScanning() that fires purely on a timer
even when no new log line has actually arrived. This produced a
small but continuous black-flash of just the log strip roughly
4x/second regardless of whether its content changed at all — this is
the same general "unconditional black-clear fires more often than
necessary" bug class as the previously-fixed channel-hop flicker
(3550e3a), just localized to this one small region instead of the
main content area.

Fix: add a monotonically-increasing mb_logVersion counter, bumped in
mb_logAdd()'s existing critical section every time a line is actually
appended. mb_drawLogStrip() now takes a `force` parameter and tracks
the version it last drew (mb_logDrawnVersion); when force=false (the
stale-tick call site) and the version hasn't changed since the last
draw, it returns immediately without touching the display at all. The
two call sites that already fillRect(BLACK) the whole region as part
of a bigger redraw (m5basicScanning()'s contentChanged path and
m5basicDetection()'s alert screen) pass force=true, since skipping
the redraw there would leave the log text blank after that clear.

Also enlarges the log window per user request: MB_LOG_LINES 3->5,
with the height change expressed as a new named MB_LOG_H constant
(replacing a hardcoded 24) rather than another magic number. Growing
the strip pushes its top edge higher up the screen, so the
Runtime/SPIFFS line above it (previously a hardcoded
`MB_BTN_Y - 40`) is repositioned via a new MB_RUNTIME_H constant to
`MB_BTN_Y - MB_LOG_H - MB_RUNTIME_H`, keeping it from overlapping the
taller strip. Scoped to m5basic_display.h only (M5Stack Basic/Core2
For AWS, 320x240) — the StickC Plus SE has no log-strip feature and
is unaffected.

Build-verified: m5stack-basic, m5stack-basic-ble, m5stack-core2-aws,
m5stack-core2-aws-ble (fresh non-cached rebuilds, all succeeded).
Pending fresh hardware retest given this task's history of prior
"fixed" reports needing follow-up correction after real device
testing.
…umeration

Root cause: the Atom VoiceS3R/Echo S3R's ESP32-S3 native USB-JTAG/Serial
peripheral advertises the identical VID:PID (303A:1001, "USB JTAG/serial
debug unit") whether the chip is running the flashed app OR still sitting
in the ROM download/bootloader after a flash. flash.sh and
flash_voices3r.sh were declaring "Device running" purely because
/dev/cu.usbmodem* reappeared after esptool's reset — which is true in
BOTH states and proves nothing about which one the board is actually in.

Confirmed via live two-board hardware testing: after a successful flash
(esptool reported "Hash of data verified", exit 0), the board produced
zero serial output across every capture window tried (8s/12s/35s+,
including a full 30s HEARTBEAT_MS window), with both existing esptool
soft-reset strategies (--before usb-reset --after hard-reset, and
--before default_reset --after hard_reset) leaving it stuck. Only a
genuine physical USB-C unplug/replug power-cycle reliably cleared the
download-mode latch and produced immediate, correct [flockyou]
scanning... heartbeat output. This matches M5Stack's own VoiceS3R docs,
which describe a manual physical-button-hold bootloader-entry procedure
rather than software-only DTR/RTS signaling — unlike Atom Lite/Echo/Voice's
FTDI/CH9102 USB-UART bridges, which have real auto-program transistor
circuits that respond reliably to software resets.

Fix: flash.sh's show_boot_output() and flash_voices3r.sh's restart step
now actually open the port and read for a "[flockyou]" tag before
declaring success, and print accurate unplug/replug guidance (not a
retry-the-flash suggestion) when no firmware output appears within the
timeout. Documented the confirmed hardware behavior in
.clinerules/02-test-before-commit.md (item 9) so future sessions don't
have to re-derive this from scratch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants