SquachWatch · Volume 6
SquachWatch Volume 6 — Programming it
A developer's guide to the SquachWatch-CYD source: the build environments, the architecture, the data types, the signature tables, the PC emulator and the host tests
6.1 Orientation: the repository

SquachWatch-CYD is a single Arduino-framework PlatformIO project that builds for about two dozen environments from one shared source tree. All figures in this volume are from the repository as cloned for this series (README at v1.36.0, 2026-10-12); every path is relative to the repository root. Nothing in this volume was built or run for the series — it is a reading of the source.
6.1.1 The layout
SquachWatch-CYD/
├── platformio.ini every board's [env:…]; the pinned platform; shared flags
├── extra_script.py pre-build: version stamp, SQW_ENV, -fno-exceptions, C5 TFT_eSPI patch
├── partitions_*.csv OTA two-slot tables (4 MB, 8 MB, 16 MB, CrowPanel, T-Watch)
├── boards/ variants/ custom board JSON + variant pins for the ESP32-C5 boards
├── include/ 152 headers
│ ├── state.h DetectionType, Detection, Confidence, AppState, ButtonId
│ ├── signatures.h the five table structs + lookup functions
│ ├── detection.h class DetectionEngine
│ ├── *_user_setup.h one TFT_eSPI config per board (pulled in with -include)
│ ├── ui_*.h one header per screen
│ ├── lora_*.h T-Watch LoRa decoders (Meshtastic, MeshCore, LoRaWAN, APRS, FANET)
│ └── mesh*.h, ota_*.h SquachMesh protocol/crypto; OTA installer + transports
├── src/ 123 files; main.cpp (≈9,250 lines) is setup()/loop() + state machine
├── docs/ BUILD, DESIGN, DETECTIONS, FAQ, PINOUT, LORA, NM-CYD-C5, CROWPANEL7, …
├── sim/ PC emulator: shims + harnesses; builds src/ natively and to WASM
├── test/ 51 host test programs, plain g++ + make, no framework
├── tools/ sign_firmware.py, flash_known.py, wigle_dump.py, ttf2gfx.py, …
├── web-flasher/ squachwatch.com: flasher page, manifests, update/, emulator/
└── .github/workflows/ build.yml (CI), release-flasher.yml (release + Pages)
Header and source counts are from a directory listing; line counts from wc -l.
6.1.2 Read DESIGN.md as history
docs/DESIGN.md calls itself “the single source of truth” and in the next paragraph disclaims it: “Read this as history. This is the v1.0 contract … where this file and the code disagree, the code is right.” It is still the best map of the intent — the precedence rule for signatures, the original four-state machine, the UI-module calling convention, the SquachWare palette — and this volume uses it that way, noting each place the code has moved on.
Table 1 — 1.2 Read DESIGN.md as history
| DESIGN.md (v1.0) says | The code now has | Where |
|---|---|---|
10 DetectionType values | 18 (adds SAMSUNG_TAG … HACKER) | include/state.h |
4 AppState values | 39 (0–38) | include/state.h |
Detection::vendor is char[12] | a const char* into the tables | include/state.h |
| Confidence per type | per signature row, carried on each sighting | include/state.h, include/signatures.h |
| Alert auto-dismiss 5 s | 10 s | README.md, “Usage” |
| No SD log, no OTA, no GPS | all three exist | docs/DESIGN.md §14 footnote |
| MIT | GPL-3.0 | docs/DESIGN.md §13 |
6.2 Toolchain and the build system
6.2.1 Install and first build
PlatformIO, either as the VS Code extension or the CLI (pipx install platformio, or Homebrew on macOS), then:
git clone https://github.com/skizzophrenic/SquachWatch-CYD
cd SquachWatch-CYD
pio run -e cyd-fast -t upload # build + flash one board
pio device monitor -e cyd-fast # console at the env's monitor_speed
The first build downloads roughly 200 MB of toolchain and libraries. Two rules from the build guide are worth treating as hard: always pass -e (a bare pio run -t upload flashes the three default environments in turn to whatever is plugged in), and run one pio run at a time, because “Two builds running at once can wipe each other’s .pio/build folders.”
6.2.2 The shared [env] section
Table 2 — 2.2 The shared [env] section
| Setting | Value | Why (from the comments) |
|---|---|---|
platform | [email protected] (pinned) | Arduino-ESP32 2.0.14 + GCC 8.4.0. Unpinned, the platform silently moved to Arduino core 3.3.9 / GCC 14 on 2026-09-12; core 3 removes ledcSetup()/ledcAttachPin(), which the backlight code uses on every board — “a quiet upgrade here is a broken release, not a warning.” |
board / framework | esp32dev / arduino | |
upload_speed | 921600 | Drop to 115200 if uploads fail |
board_build.partitions | partitions_ota.csv | Two 1.9 MB app slots since v1.7.0; NVS and coredump at the old offsets |
monitor_speed | 921600 default; 2,000,000 on CYDs | AWOK’s CP210x garbles at 2 Mbaud |
extra_scripts | pre:extra_script.py | See §2.4 |
lib_deps | bodmer/TFT_eSPI@^2.5.43; XPT2046_Touchscreen from GitHub master; h2zero/NimBLE-Arduino@^2.5.1 | The XPT2046 registry package “is stuck at 0.0.0-alpha (2019)“ |
build_unflags | -DILI9341_DRIVER -DST7789_DRIVER -DST7796_DRIVER | Clear TFT_eSPI defaults so each board header decides |
default_envs | cyd, cyd-ili9341, awok | What pio run and CI build by default |
All rows: platformio.ini, [platformio] and [env].
The NimBLE role flags are a heap story. Two flag sets are defined and each environment picks one:
nimble_flags_cyd =
-DCONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED
-DCONFIG_BT_NIMBLE_ROLE_PERIPHERAL_DISABLED
-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
-DCONFIG_BT_NIMBLE_MAX_BONDS=1
The CYDs drop the peripheral role (and with it Bluetooth OTA’s GATT server) to win back heap; the broadcaster role stays on because SquachMesh must advertise. The S3 boards use nimble_flags_mesh, which keeps the peripheral role for Bluetooth updates.
6.2.3 The environments
Table 3 — 2.3 The environments
| Environment | Chip | Key -D flags | Status / note |
|---|---|---|---|
cyd | ESP32 | CYD, SQUACH_MESH, SERIAL_BAUD=2000000 | Default; 2.8” ST7789 at 40 MHz |
cyd-fast | ESP32 | + SPI_FREQUENCY=80000000 | Flasher default for the 2.8” |
cyd-ili9341, cyd-ili9341-fast | ESP32 | CYD + ILI9341 header | Same board, older panel |
cyd-gps, cyd-ili9341-gps | ESP32 | + CYD_GPS | Not in flasher/CI/releases |
cyd-crowd, cyd-fast-crowd, cyd-ili9341-crowd | ESP32 | + CROWD_BENCH | Bench: renders 1–30 Squachys and times them |
cyd-fast-flood | ESP32 | + FLOOD_BENCH | Bench: FLOOD N console feeds fake adverts |
cyd35, cyd35-fast, cyd35c-fast | ESP32 | CYD35 (+CYD35C) | cyd35 frozen (boot loop documented); the -fast builds ship as BETA |
rlphantom, rlphantom-r | ESP32 | CYD, RLPHANTOM / RLPHANTOM_R | Capacitive build never run on hardware; resistive ships |
freenove32, cyd32c | ESP32 | CYD + FREENOVE32 / CYD32C | 3.2” boards |
awok | ESP32 | AWOK | Touch on the display bus; 921600 console |
twatch-s3, twatch-s3-loratx | ESP32-S3 | TWATCH_S3, SQUACH_LORA, SQW_S3, BOARD_HAS_PSRAM; RadioLib 7.7.1, XPowersLib | 16 MB; -loratx is a bench transmitter |
sticks3, cardputer-adv | ESP32-S3 | SQW_SMALL, SQW_FLASH_8MB | 8 MB parts |
freenove-s3, freenove-s3-40, freenove-s3-35 | ESP32-S3 | FREENOVE_S3* | -40 not on the flasher |
tdeck-plus | ESP32-S3 | TDECK_PLUS, CYD_GPS, CYD_GPS_RX=44 | GPS wardriving |
crowpanel7 (+ -native, -paneltest, -probe, -touchtrace) | ESP32-S3 | CROWPANEL7 | Extras are bench builds |
nm-cyd-c5 | ESP32-C5 | NM_CYD_C5, SQW_WIFI_5G, SQW_C5_DMA_HZ=80000000 | pioarduino platform 55.03.312-1 |
marauder-v8 | ESP32-C5 | + MARAUDER_V8, CYD_GPS_RX=14, SQW_C5_DMA_HZ=40000000 |
All rows: platformio.ini. The C5 environments carry their own platform line (Arduino 3.3 / IDF 5.5), and the build guide warns that its installer “can break pio for every other board” — build them with a separate PLATFORMIO_CORE_DIR.
6.2.4 What extra_script.py injects
Table 4 — 2.4 What extrascript.py injects
| Flag | Value | Used for |
|---|---|---|
FIRMWARE_VERSION | git describe --tags --always --dirty, or "unknown"; SQW_VERSION env var overrides on the bench | Splash stamp, Diary screen, update comparisons, squad hellos |
SQW_ENV | The PlatformIO environment name | Bound into the OTA signature (Vol 5 §7.2) |
-fno-exceptions | always | No catch anywhere; the unwind tables were 61.7 KB of the 3.2” build |
| TFT_eSPI patch | nm-cyd-c5 only | Drops RockBase’s C5 processor port into the downloaded TFT_eSPI; raises rather than warns if anything is unexpected |
All rows: extra_script.py.
6.2.5 Build-flag dictionary
The application code is the same for every board; differences live in #if blocks keyed on these macros.
Table 5 — 2.5 Build-flag dictionary
| Macro | Meaning |
|---|---|
CYD | The plain-ESP32 Sunton family code paths (also set on the C5 CYD-shaped boards) |
CYD35, CYD35C, CYD32C, RLPHANTOM_R, FREENOVE32, AWOK | Per-board pin, touch and backlight differences |
SQW_S3, SQW_SMALL, SQW_FLASH_8MB | ESP32-S3 family; small-screen layout; 8 MB flash |
SQUACH_MESH | Compile SquachMesh (set on every shipping env) |
SQUACH_LORA | LoRa receive stack (T-Watch) |
CYD_GPS, CYD_GPS_RX, CYD_GPS_BAUD, CYD_GPS_SERIAL | GPS support and its UART |
SQW_WIFI_5G | 5 GHz scanning (ESP32-C5 only; static_asserted against the SoC) |
SPI_FREQUENCY | Display write clock; guarded with #ifndef in the board header |
SERIAL_BAUD | Console rate; main.cpp falls back to 921600 |
CROWD_BENCH, FLOOD_BENCH, LORA_BENCH_TX, CROWPANEL7_* | Bench-only instrumentation |
Sources: platformio.ini; src/detection.cpp (the 5 GHz static_assert); docs/BUILD.md, “Next steps”.
Warning — After editing any
*_user_setup.h, “delete.pio/build/<env>/lib*/TFT_eSPIbefore building, or the board keeps the old display settings.” ChangingSPI_FREQUENCYby build flag rather than by header edit exists partly for this reason: a flag change forces a rebuild.
6.3 Architecture
6.3.1 The data path
┌───────────────── radio callbacks (driver / NimBLE task context) ─────────────────┐
│ │
│ esp_wifi promiscuous rx_cb NimBLE scan callback │
│ (MGMT + DATA filter) onDiscovered / onResult → handle(adv) │
│ probe req: addr2 ─┐ │ │
│ data: addr1/addr2 ┼─▶ postWiFi() ├─ watch / hunt RSSI checks │
│ beacon: BSSID+SSID┘ (IRAM_ATTR) ├─ mfg ID → AirTag / iBeacon tests │
│ deauth/disassoc ───▶ postDeauth() ├─ Remote ID decoder │
│ pwnagotchi JSON, Remote ID beacons ├─ 16-bit service UUIDs, Flock GATT UUID │
│ └─ advertised name → postBle(Detection) │
└──────────────┬───────────────────────────────────────┬────────────────────────────┘
│ 8-entry volatile mailboxes │
▼ ▼
┌──────────────────────────────── DetectionEngine::loop() ─────────────────────────┐
│ processWiFiQ(): pwnagotchi > drone > evil twin > lookupOui() > lookupSsid() │
│ processDeauthQ(): ≥6 frames in 3 s, 15 s cooldown → DEAUTH │
│ Settings::typeEnabled() filter → dedupe/merge by MAC+type → pushLog() │
│ expireStale() (STALE_MS 60 s), channel hop, black-box queue, lifetime counts │
│ 64-row RAM ring _log[] ── latest() ── countByType() ── logAt() │
└───────────────────────────────┬──────────────────────────────────────────────────┘
│ const DetectionEngine&
▼
┌────────────── main.cpp loop(): switch (state) over 39 AppStates ──────────────────┐
│ uiXxxTick(canvas, now, engine) draws; hit-test functions return taps; │
│ latest() changing → enterAlert(); SD log, status light, Squachy, SquachMesh │
└───────────────────────────────────────────────────────────────────────────────────┘
The pattern is the classic split: radio callbacks do the minimum — copy a MAC, an RSSI, a channel and an SSID into a fixed-size volatile ring (WIFI_Q_CAP = 8, DEAUTH_Q_CAP = 8) — and loop() does every table walk and allocation. Bounded tables are a deliberate rule: the evil-twin tracker is a fixed 24-entry table because “an unbounded map of every SSID in range is exactly the kind of growth that crashed the BLE path.”
6.3.2 Boot order
The v1.0 contract’s setup() was eight lines and deferred the engine until after the splash. The current setup() is long; its order, condensed from src/main.cpp:
Table 6 — The v1.0 contract's setup() was eight lines and deferred the engine until after the splash. The current setup() is long; its order, condensed from src/main.cpp
| Step | What happens |
|---|---|
| 1 | Serial.begin(SERIAL_BAUD); crash/reset-reason bookkeeping |
| 2 | tft.init() |
| 3 | Settings::load() (before rotation, so the saved rotation applies) |
| 4 | Security::begin(); OtaCore::boot() (is this boot an update on probation, or the aftermath of a rollback?) |
| 5 | Rotation, theme, StatusLight::begin() |
| 6 | Optional boot-time update check over a saved Wi-Fi network (OtaWifi::bootCheck) — before Bluetooth starts |
| 7 | Touch probe per board (XPT2046, GT911, FT6336, CST/ST77922…) |
| 8 | 1.2 s “Hold anywhere now to reset touch calibration…” window; five-target calibration if none is saved |
| 9 | Backlight; BlackBox::begin() and a boot record (crash summary if the last boot died) |
| 10 | wardriveBegin() on the watch |
| 11 | engine.init() — Wi-Fi promiscuous + NimBLE scan |
| 12 | Lora::begin() (LoRa builds); Bingo, Dex, Regulars; MeshTalk::begin() (mesh builds) |
| 13 | Duress-wipe bookkeeping, then enterBoot() — or straight to CLEAR after a duress restart |
Source: src/main.cpp, setup() (starting at line 3990 in the copy read).
6.3.3 The state machine
DESIGN.md’s diagram is the right mental model for the core loop, with the states that have grown around it:
┌────────┐ ~3 s (9 s with a crash card)
power ──▶│ BOOT ├──┬──▶ COLOR_CHECK (first boot) ──┐
└────────┘ ├──▶ SYS_PROPS (update known) ──┤
├──▶ DESK (left in desk mode) │
▼ │
┌───────────── CLEAR (main) ◀────────────────────┘
new │ │ ▲ │ ▲
detection ────┘ │ │ │ tap │ tap / 10 s
▼ │ ▼ │
ALERT ───────────┘ WATCH_ALERT ◀── watched device back
│ HUNT ◀── (alert HUNT / raw-scan long-press)
▼
LOG ── long-press ──▶ watch / hunt / ignore
│
CLEAR ─▶ SETTINGS ─┬─ DETECTION_FILTER ─ BEACON_WARN ┬─ SECURITY ─ PIN_ENTRY
├─ POWER_SAVER ─ STATUS_LIGHT ├─ UPDATE ─ WIFI_PASS / NUDGE / SQUAD_UPDATE
├─ MESH_WARN ─ MESH_MENU ─ PHONE / MESH_PHRASE / SQUAD / INVITE
├─ DIARY OUTFIT BINGO DEX DIAGNOSTICS IGNORE_LIST WIFI_NETS ─ WIFI_ADD
└─ LORA_CHAT (watch) LOCKED ◀── PIN lock from anywhere
Table 7 — 3.3 The state machine
| Group | AppState values |
|---|---|
| Core loop | BOOT 0, CLEAR 1, ALERT 2, LOG 3, WATCH_ALERT 8, HUNT 10, RAWSCAN 7, DESK 32 |
| Setup and checks | COLOR_CHECK 11, SYS_PROPS 35, DIAGNOSTICS 9 |
| Settings tree | SETTINGS 4, DETECTION_FILTER 12, BEACON_WARN 21, IGNORE_LIST 14, POWER_SAVER 15, STATUS_LIGHT 28 |
| Security | SECURITY 22, LOCKED 23, PIN_ENTRY 24 |
| Updates and networks | UPDATE 26, WIFI_PASS 27, WIFI_NETS 33, WIFI_ADD 34, NUDGE 29, SQUAD_UPDATE 30 |
| SquachMesh | MESH_MENU 17, MESH_WARN 18, MESH_PHRASE 19, MESH_COMPOSE 20, SQUAD 25, INVITE 31, PHONE 16 |
| Squachy and extras | DIARY 5, OUTFIT 6, OUTFIT_UNLOCK 13, BINGO 36, DEX 37 |
| Watch | LORA_CHAT 38 |
Source: include/state.h, enum class AppState. (Value 36 was once CROWD; it was folded into the SquachMesh menu and the number reused for BINGO.)
6.3.4 The UI-module convention
Every screen is a pair of files, include/ui_x.h and src/ui_x.cpp, exposing free functions rather than classes: an Init that draws the static parts, a Tick called every frame with the current time (and the engine, read-only, where the screen shows detections), and pure hit-test functions that take a coordinate and the screen size and return whether a control was hit. From the HUNT screen’s header:
void uiHuntInit(TFT_eSPI& t);
void uiHuntTick(TFT_eSPI& t, uint32_t now, const DetectionEngine& eng, bool advance = true);
bool uiHuntHitBack(int x, int y, int screenW, int screenH);
bool uiHuntHitStop(int x, int y, int screenW, int screenH);
The TFT_eSPI& is usually an off-screen sprite (canvas) that main.cpp pushes to the panel, which is what lets the emulator substitute its own TFT_eSPI and render the same functions to a PNG (§7). Taking const DetectionEngine& is what lets the emulator substitute a stub engine without touching any screen.
6.4 The core data types and the engine API
6.4.1 DetectionType
enum class DetectionType : uint8_t {
UNKNOWN = 0, FLOCK = 1, AXON = 2, META = 3, SKIMMER = 4, RAVEN = 5,
AIRTAG = 6, DRONE = 7, ALPR = 8, CAMERA = 9, SAMSUNG_TAG = 10,
GOOGLE_TAG = 11, TILE = 12, RING = 13, DEAUTH = 14, EVILTWIN = 15,
IBEACON = 16, HACKER = 17,
COUNT = 18
};
(Condensed from include/state.h; comments removed.) DEAUTH and EVILTWIN are behavioural — “rate-detected, not a signature match” — and HACKER “takes exact signatures only,” explicitly excluding bare Espressif silicon, because “A nyanBOX, an ESP32 Marauder and a SquachWatch are the same chip.” Vol 7 covers what each type matches; here the point is that the enum’s order is load-bearing — several tables are indexed by it (§6.3).
6.4.2 Confidence
enum class Confidence : uint8_t { LOW_CONF, MED_CONF, HIGH_CONF };
The _CONF suffix is there because “plain LOW/MEDIUM/HIGH collide with the Arduino core’s pin-state macros through the preprocessor, which enum class scoping does not protect against.” Confidence moved from the type to the sighting because “A FLOCK hit off Flock Safety’s own registered OUI and a FLOCK hit off a generic Espressif module block are the same DetectionType and are not remotely the same claim.” confidenceLabel() and confidencePercent() turn it into “HIGH CONF” and an approximate 90/60/30 — “an honest approximation, not a measured stat.”
6.4.3 Detection
Table 8 — 4.3 Detection
| Field | Type | Purpose |
|---|---|---|
mac[6] | uint8_t | Transmitter address (BSSID for beacons) |
rssi, channel | int8_t, uint8_t | Last signal; channel 0 for BLE |
type | DetectionType | |
restored | uint8_t | Row came from the flash black box, not this boot |
prevRssi, prevAt | int8_t, uint8_t | Signal ~2 s ago, for the LOG’s closer/further arrow |
vendor | const char* | Pointer into the signature tables or a literal — “never a copy”; read through vendorText() |
name[20] | char | BLE name, SSID, Pwnagotchi name, iBeacon ID |
firstSeen, lastSeen | uint32_t | millis() stamps |
hits | uint16_t | Sightings (counts differently per radio — not used for snooze) |
alerts, quietBar, lastAlertMs, askedMin | AUTO SNOOZE bookkeeping | |
conf | Confidence | Grade of the row that actually matched |
active | bool | Cleared after STALE_MS (60 s) without a sighting |
Source: include/state.h, struct Detection. The vendor pointer is a RAM decision documented in place: a 200-row log was “carrying two hundred copies of ‘Apple’.“
6.4.4 DetectionEngine
The public surface the UI reads, from include/detection.h:
class DetectionEngine {
public:
bool init();
void loop();
void clearLog();
uint8_t logCount() const;
const Detection* logAt(uint8_t idx) const; // 0 = newest
const Detection* latest() const;
bool latestIsNew() const;
uint16_t countByType(DetectionType t) const;
uint32_t lifetimeTotal() const;
uint32_t lifetimeTypeCount(DetectionType t) const;
void resetLifetime();
// producers, called from radio callbacks:
void IRAM_ATTR postWiFi(const uint8_t* mac, int8_t rssi, uint8_t channel,
const char* ssid = nullptr, bool encrypted = false,
bool pwnagotchi = false, bool drone = false);
void IRAM_ATTR postDeauth(const uint8_t* mac, int8_t rssi, uint8_t channel);
void postBle(Detection d);
// ... raw scan, watch, hunt, radio rest/wake, Remote ID merge
};
Table 9 — 4.4 DetectionEngine
| Constant | Value | Comment in the source |
|---|---|---|
LOG_CAP | 64 | Cut from 200, which “cost 11.6 KB of the board’s static RAM”; history beyond it is in the flash black box |
WIFI_Q_CAP | 8 | IRAM mailbox depth |
STALE_MS | 60 000 | ”Some Flock cameras only probe every 30+ seconds, so don’t drop it much lower” |
DEAUTH_THRESHOLD / _WINDOW_MS / _COOLDOWN_MS | 6 / 3000 / 15000 | Burst, not single frames |
AP_CAP | 24 | Evil-twin SSID table |
RAW_BLE_CAP | 20 | Raw BLE scanner rows |
Source: include/detection.h, private section.
6.5 The signature tables and how a match is made
6.5.1 The five tables
struct OuiEntry { uint8_t b[3]; const char* name; DetectionType type; Confidence conf; };
struct UuidEntry { uint16_t uuid; const char* name; DetectionType type; };
struct NameEntry { const char* name; DetectionType type; };
struct SsidEntry { const char* prefix; const char* name; DetectionType type; };
struct MfgIdEntry { uint16_t mfgId; const char* name; DetectionType type; };
(include/signatures.h.) Only OuiEntry carries its own grade; the others fall back to the per-type confidenceFor(). The data lives in src/signatures.cpp:
Table 10 — (include/signatures.h.) Only OuiEntry carries its own grade; the others fall back to the per-type confidenceFor(). The data lives in src/signatures.cpp
| Table | Rows (as read) | Matched against | Lookup | Rule |
|---|---|---|---|---|
kOuiTable | 97 (49 FLOCK, 15 RING, 17 CAMERA, 7 ALPR, 3 AXON, 3 SKIMMER, 3 HACKER) | Wi-Fi transmitter / BSSID first three octets | lookupOui(mac, &conf) | First match wins, top to bottom |
kUuidTable | 15 | BLE 16-bit service UUIDs | lookupUuid(), uuidName() | Exact |
kBtClassicNames | 7 | BLE advertised name | lookupBtName() | Case-insensitive exact, then hard-coded substrings |
kSsidPrefixes | 8 | Beacon SSID | lookupSsid(), ssidVendorName() | Case-insensitive prefix; bare "Flock" is a special exact case |
kMfgIdTable | 7 | BLE company ID (first two bytes of manufacturer data, little-endian) | lookupMfgId(), mfgIdName() | Exact |
Row counts are from counting the initialisers in the copy read; each table’s count is computed with sizeof (const uint16_t kOuiCount = sizeof(kOuiTable) / sizeof(kOuiTable[0]);), so nothing needs updating by hand when a row is added. Three payload tests sit beside the tables: isAirTagPayload() (ported from nyanBOX), isIBeacon() (exact 25-byte Apple layout) and pwnagotchiName() (reads the unit’s self-description out of a beacon frame).
6.5.2 What a row looks like
From kOuiTable, a High row registered to the product’s own maker beside a Low row on a module vendor’s block:
{{0xB4, 0x1E, 0x52}, "Flock-MA-L", DetectionType::FLOCK, Confidence::HIGH_CONF},
{{0x24, 0x0A, 0xC4}, "Flock-ESP32", DetectionType::FLOCK, Confidence::LOW_CONF},
and from the BLE tables:
{0xFD5A, "SmartTag", DetectionType::SAMSUNG_TAG}, // Samsung's own SIG-assigned UUID
{0x0E29, "Flipper", DetectionType::HACKER},
{"Pineapple_", "Pineapple", DetectionType::HACKER},
The grading rule is written above the struct: HIGH when “the block is registered to the company that makes the product,” MED when registered to a much broader parent (“Amazon owns Ring, and also Echo, Fire TV and Kindle”), LOW for module/ODM vendors “whose parts are in everything” or blocks absent from the IEEE registry. “The LOW rows are not mistakes … saying so is the difference between a detector and a rumour.”
6.5.3 Precedence
lookupOui() returns the first row that matches, so the order of kOuiTable encodes priority. The file header states it — “Axon > Flock > ALPR > cameras > ESP32 family > skimmer” — from DESIGN.md §6.2. In the current table the separate “ESP32 family → CAMERA” block of the v1.0 design is gone: Espressif prefixes sit in the FLOCK block at LOW.
6.5.4 The Wi-Fi path
processWiFiQ() runs each dequeued frame through a fixed ladder:
dequeued frame (mac, rssi, channel, ssid, encrypted, pwnagotchi, drone)
│ checkWatchWifi / checkHuntWifi; channel activity for the waterfall
▼
pwnagotchi flag? ──yes──▶ HACKER, HIGH
│ no
drone flag? ──────yes──▶ DRONE, confidenceFor(DRONE)
│ no
SSID present and noteApBeacon() says "same SSID, different hardware,
disagrees about encryption"? ──yes──▶ EVILTWIN
│ no
lookupOui(mac, &conf) ──hit──▶ type, row's own grade
│ miss, SSID present
lookupSsid(ssid) ──hit──▶ type, confidenceFor(type)
│ miss
drop
│
Settings::typeEnabled(t)? ── no ──▶ drop
│
same MAC + same type already in the log? ──▶ merge (hits++, rssi, reactivate)
│ new
build Detection: vendor = SSID-table label | OUI-table label | literal
pushLog() → latest() → ALERT
6.5.5 The Bluetooth path
The BLE scan callback builds a Detection and tries, in order: the manufacturer ID (lookupMfgId; an Apple 0x004C hit must then pass isAirTagPayload() or isIBeacon(), otherwise it is dropped); the raw payload against the AirTag patterns; the Remote ID decoder; the advertised 16-bit service UUIDs; a 128-bit Flock GATT UUID; and finally the advertised name via lookupBtName(). A HACKER match by name alone is graded MED, by an exact signature HIGH. The OUI table is consulted on the Wi-Fi path only.
Warning — The BLE handler does not walk
kUuidTable. It loops over its own hard-codedstatic const uint16_t kKnown16[]list and callslookupUuid()only for a UUID on that list. A new row added tokUuidTablealone will never fire; the UUID must be added tokKnown16[]as well. The build guide’s “append tokOuiTable” instruction is complete for OUIs only.
6.6 Adding a signature, and adding a type
6.6.1 The project’s stated procedure
“Add a new signature: append to
kOuiTableinsrc/signatures.cpp, with the matchingDetectionTypefrominclude/state.hand a confidence grade (see DETECTIONS.md). Runmake -C test— it checks for duplicate prefixes and labels too long for the screen — then re-flash.”
The author’s bar for contributions is provenance: “PRs welcome, especially new detection signatures with real sources behind them … I don’t want to flag your neighbour’s baby monitor as a Flock camera because someone guessed at a prefix.” The table’s own history shows why: an IEEE-registry audit found a “Vigilant” ALPR prefix that belonged to Sonos, a “Hikvision” one registered to Amazon and a “Sierra” one registered to SPECTRA - TEK.
6.6.2 Step by step, per table
Table 11 — 6.2 Step by step, per table
| Step | OUI (Wi-Fi) | 16-bit service UUID | Company ID | BLE name | SSID prefix |
|---|---|---|---|---|---|
| 1. Source it | IEEE MA-L registry entry for the prefix | Bluetooth SIG assigned numbers | SIG company identifier list | A capture of the real advert | Vendor documentation or a capture |
| 2. Pick type and grade | Grade on the row (HIGH/MED/LOW per §5.2) | Type-level grade via confidenceFor() | same | same; HACKER by name is MED | same |
| 3. Place the row | In its type’s block — order is priority | Anywhere in kUuidTable | kMfgIdTable | kBtClassicNames (exact) or a strcasestr line in lookupBtName() | kSsidPrefixes |
| 4. Extra wiring | none | Add to kKnown16[] in detection.cpp | Apple 0x004C has payload tests | none | none |
| 5. Label | ≤ 11 characters (tested) | used as vendor | used as vendor | name itself shown | used as vendor |
| 6. MORE INFO page | add the label to a DeviceInfo::kDevices entry if the type has device pages | same | same | same (name rule) | same |
| 7. Document | Row under the type in docs/DETECTIONS.md, with source and grade | same | same | same | same |
| 8. Test | make -C test | same | same | same | same |
| 9. Verify | Real hardware — the emulator cannot exercise matching (§7.4) | same | same | same | same |
Sources: src/signatures.cpp; src/detection.cpp; include/device_info.h; test/signatures_test.cpp; test/device_info_test.cpp; docs/BUILD.md; sim/README.md.
6.6.3 Worked example: one OUI row
Suppose an IEEE lookup shows a prefix AA:BB:CC (a placeholder, not a real assignment) registered to a camera maker. The edit is one line in the “Specific camera vendors” block of kOuiTable:
// ---- Specific camera vendors ----
{{0x2C, 0xAA, 0x8E}, "Wyze", DetectionType::CAMERA, Confidence::HIGH_CONF},
// ...
{{0xAA, 0xBB, 0xCC}, "ExampleCam", DetectionType::CAMERA, Confidence::HIGH_CONF}, // IEEE MA-L, registered <date>
Then make -C test. signatures_test will fail the change if the prefix duplicates an existing row (“lookupOui returns on the first hit, so the second row is dead code that reads as a maintained signature”), if the label is empty or longer than 11 characters, if the type is UNKNOWN, or if a locally-administered prefix (bit 1 of the first octet set) is graded above LOW. The address AA:… used here would in fact trip that last check — 0xAA has bit 1 set — which is a good illustration of what the test is for. device_info_test fails if CAMERA has device pages and the new label reaches none of them.
A test for the new row, in the house style (illustrative; ck() and suite() are from test/test_util.h):
suite("ExampleCam");
Confidence conf = Confidence::LOW_CONF;
const uint8_t mac[6] = { 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33 };
ck("ExampleCam's block is CAMERA", lookupOui(mac, &conf) == DetectionType::CAMERA);
ck("...and graded HIGH", conf == Confidence::HIGH_CONF);
6.6.4 Adding a whole new DetectionType
A new type is a much larger change than a new row. DetectionType::HACKER alone is referenced in eighteen files across src/, include/, sim/ and test/ (a grep of the tree): the theme’s colour and icon, the alert screen, the main-screen counters, the DEX, BINGO, Squachy’s reactions and emote scripts, the pets, SquachMesh heads-up policy, the emulator’s synthetic detections and the device-info pages. Two compile-time guards catch the most likely omissions:
static_assert(EXPLAIN_TEXT_N == (uint8_t)DetectionType::COUNT,
"every DetectionType needs a MORE INFO entry, in enum order");
in src/detection_info.cpp (“Without it explain() quietly clamps out-of-range to 0, so the new detection would show UNKNOWN’s text — wrong, plausible, and invisible”), and a static_assert that COUNT fits the 32-type SpamWatch table in src/detection.cpp. Append new types before COUNT, never renumber — the lifetime per-type counts are saved to NVS as one array indexed by the enum (the typetot key in saveLifetimeByType(), src/detection.cpp), so renumbering would silently reassign every board’s history.
6.7 The PC emulator (sim/)
6.7.1 What it is
sim/ compiles the firmware’s real theme.cpp, squachy.cpp and ui_*.cpp against small shims for Arduino.h, Preferences.h, SPI.h, Wire.h, XPT2046_Touchscreen.h and TFT_eSPI.h. The mechanism is the include path: “The sim directory goes first on the include path, which is the whole mechanism.” The TFT shim implements the same six virtual primitives TFT_eSPI itself exposes for sprites (drawPixel, drawChar, readPixel, setWindow, pushColor, begin/end_nin_write) against an RGB565 buffer, so every higher-level shape and text call comes along. Needs only g++ and make; PNGs are written uncompressed by hand, “No SDL, no zlib.”
src/theme.cpp src/ui_*.cpp src/squachy.cpp src/main.cpp (live only)
│ │ │
▼ ▼ ▼
┌───────────── sim/ shims first on -I path ─────────────┐
│ Arduino.h TFT_eSPI.h (6 primitives → RGB565 buffer) │
│ Preferences.h (RAM, or files under $SQUACHSIM_NVS) │
│ Wire.h (NACK → resistive path) XPT2046_Touchscreen.h │
│ detection_sim.cpp replaces detection.cpp + sd_log.cpp │
└───────────────┬───────────────────────┬───────────────┘
▼ ▼ ▼
squachsim squachsim-live squachsim.wasm
(one screen → PNG) (real setup()/loop()) (make wasm, em++)
→ squachwatch.com/emulator
6.7.2 Running it
cd sim
make -j8
./squachsim clear out.png
./squachsim alert out.png --portrait
./squachsim clear out/fire.png --bg 6
make shots # clear, log, alert, settings → out/
make live && ./squachsim-live
On Windows the squachsim.bat and squachgui.bat wrappers run everything inside WSL; squachgui builds, starts a local server (port 842, or SQUACHSIM_PORT) and opens a browser GUI with a Live tab and a Gallery tab. SQUACHWATCH=/path/to/checkout points the Makefile at any branch.
Table 12 — 7.2 Running it
squachsim option | Effect |
|---|---|
| screen | clear log alert settings diary hunt rawscan watchalert colorcheck boot |
--portrait | 240×320 instead of 320×240 |
--bg N / --theme N | Background 0–9 / palette index |
--frames N | Warm-up frames before capture (default 90) — animated scenes build state |
--onboard | Let the first-boot walkthrough run |
--sequence N, --raw PATH | Several frames; raw RGB888 for the GUI |
6.7.3 The live emulator’s protocol
squachsim-live compiles the real src/main.cpp and calls its setup() and loop(); commands arrive on stdin, frames leave on stdout, and the firmware’s Serial goes to stderr “so it can never corrupt the frame stream.”
Table 13 — 7.3 The live emulator's protocol
| Command | Effect |
|---|---|
D <x> <y> / M <x> <y> / U | Press / move / release (converted to raw touch-driver space by inverting pollTouch()’s mapping) |
S [n] | Step n loop() iterations, emit a frame |
T <type> [rssi] | Inject a synthetic detection (T 6 or T AIRTAG) |
R | Report AppState on stderr |
Q | Quit |
Each frame is FRM <w> <h> <bytes> <state>\n plus raw RGB888. Time is virtual — millis() advances 33 ms per step and delay() advances it rather than sleeping — so a recorded tap sequence replays identically.
6.7.4 What the emulator does not test
Table 14 — 7.4 What the emulator does not test
| Not covered | Why |
|---|---|
| Signature matching | detection_sim.cpp replaces the engine; “Signature-matching changes still need hardware.” |
| The DETECTION FILTER | The stub’s postBle() deliberately has no typeEnabled() guard |
| Pixel-exact shapes | drawWideLine (Squachy’s arms), fillRoundRect corners, fillEllipse, drawArc are approximations |
| Panel behaviour | No colour order, inversion, calibration drift, pressure threshold |
| Performance and memory | ”No real heap ceiling, no SPI bandwidth. A screen can render beautifully here and run at 3fps on the device.” |
Source: sim/README.md, “What this is not”.
Note — The animated captures used throughout this series (
docs/*.gifin the repository) are rendered by this emulator through thesim/make_*.pyscripts — “That is the firmware itself, not a mockup.”
6.8 The host tests (test/)
6.8.1 Running them
make -C test # build and run every test
make -C test clean
The suite is deliberately framework-free: test/test_util.h is four inline functions (suite, ck, ckf, report), “Deliberately not a framework: these tests exist to be run by anyone with a compiler and no setup.” The include path mirrors the emulator’s — sim/ first — so the units compile on a desktop, and each test links only the source files under test, because “a test that needs a display driver to check a coordinate is not a test anybody runs.” The rationale for testing decoders on a host: “an endianness slip in a coordinate field produces a plausible-looking wrong answer, not a crash, and nothing on the device would ever tell you.”
6.8.2 What the 51 tests cover
Table 15 — 8.2 What the 51 tests cover
| Area | Tests |
|---|---|
| Signature tables and payload matchers | signatures_test, ibeacon_test, hacker_test, pwnagotchi_test, device_info_test, spam_test |
| Remote ID | remote_id_test, emitter_roundtrip_test |
| Log, black box, SD and WiGLE formats | blackbox_test, logindex_test, sd_row_test, wigle_test, wardrive_test, wardrive_seen_test, gnss_test |
| Settings, security, ignore list | security_test, ignore_list_test, autoquiet_test, bgretire_test, hunt_test, regulars_test |
| SquachMesh | squachmesh_test, meshmsg_test, meshcrypto_test, mesh_airtime_test, mesh_tx_policy_test, mesh_appearance_test, mesh_primary_transport_test, mesh_visit_appearance_test, qwerty_test, emote_script_test |
| LoRa decoders (T-Watch) | lora_meshtastic_test, lora_meshcore_test, lora_lorawan_test, lora_ham_test, lora_crypto_test, lora_channels_test, lora_airtime_test, lora_nodes_test, lora_ident_test, lora_enrich_test, lora_feed_test, lora_json_test, lora_msgs_test, lora_survey_test |
| Emulator fidelity and UI arithmetic | shim_fidelity_test, framepush_test, touchfit_test |
| Squachy extras | bingo_test, dex_test |
Source: the TESTS := list in test/Makefile.
6.8.3 Continuous integration
.github/workflows/build.yml runs three jobs: a PlatformIO build of the default environments plus crowpanel7, nm-cyd-c5, marauder-v8 and tdeck-plus; make -C test; and make -C sim followed by rendering one frame. Releases are cut by pushing a v*.*.* tag, and the same run rebuilds and redeploys the web flasher, signing each OTA image with the OTA_SIGNING_KEY secret through tools/sign_firmware.py.
6.9 Porting to another board
The build guide’s recipe:
- Add an
[env:yourboard]toplatformio.ini. - Write
include/yourboard_user_setup.h— the TFT_eSPI driver, pins,SPI_FREQUENCYguarded with#ifndef, RGB order and inversion — and pull it in with-include $PROJECT_DIR/include/yourboard_user_setup.hplus-DUSER_SETUP_LOADED=1. - Add a
-Dflag naming the board; put touch, backlight and pin differences in#ifblocks keyed on it. - Pick
nimble_flags_cydornimble_flags_mesh, a partition table that matches the flash size, andSERIAL_BAUD/monitor_speed. - Delete
.pio/build/<env>/lib*/TFT_eSPIafter every header edit.
Table 16 — 9. Porting to another board
| Board-specific concern | Where it lives | Example |
|---|---|---|
| Display driver and pins | include/<board>_user_setup.h | cyd_user_setup.h: ST7789, MISO 12 / MOSI 13 / SCLK 14 / CS 15 / DC 2, RST −1, BL 21, BGR |
| Touch controller | setup() touch probe in main.cpp | XPT2046 on its own SPI; GT911 / FT6336 over I²C |
| SD chip-select | src/sd_log.cpp, SD_CS_PIN | 5 on the CYD, 14 on AWOK, 10 on the C5 boards, 39 on the T-Deck Plus |
| Status light pins | src/status_light.cpp, STATUS_LIGHT_HW | GPIO 4/16/17 RGB on the CYD; WS2812 elsewhere; compiled out where unverified |
| Battery | main.cpp, boardBattery* / PMU code | Vol 4 §4.3 |
The project’s contributed ports — the AWOK (bkbroiler), CrowPanel 7 (DH5DAX), NM-CYD-C5 (quietradio), Freenove S3 (greasy-electronics), 3.5” capacitive (VV-B0Y) — are all worked examples of this recipe. The platformio.ini comments record each board’s open questions honestly, e.g. that rlphantom (capacitive) “has never run on a real board” and is compiled by CI only “so the code does not rot.”
Tip — For an ESP32-C5 or any chip newer than Arduino core 2.0.14, follow the
nm-cyd-c5pattern: its ownplatformline, customboards/JSON andvariants/pins, and a separatePLATFORMIO_CORE_DIRso its toolchain does not disturb the pinned [email protected] builds.
6.10 Contributing, licence and forks
Table 17 — 10. Contributing, licence and forks
| Topic | What the project says |
|---|---|
| Licence | GPL-3.0. “Fork it, break it, make it weirder. If you ship a changed version, share your source under the same license.” |
| Bug reports | Board, version (UPDATE FIRMWARE screen), expected vs actual, a photo; for crashes the boot crash card, DIAGNOSTICS and a serial log at the env’s monitor_speed |
| False-positive reports | Type, confidence, first half of the address, name, and what it really was — with PRIVACY MODE on for photos |
| Signature PRs | Real sources required; provenance recorded in docs/DETECTIONS.md |
| Third-party data | Flock OUIs from colonelpanichacks/flock-you (MIT) redistributed as data with credit; AirTag check ported from nyanBOX (MIT) |
| Ideas backlog | docs/IDEAS.md — “things worth building, with their cost” |
Known forks at the time of writing, from the series brief’s survey of GitHub: your-dnsp/DNSquachWatch (more OUIs, FPV), bsid3z/countergaze (JC3248W535EN 320×480 board), Nipahc/SquachWatch-CYD (Hosyond 3.2” ST7789P3), retrodroid32, and VV-B0Y’s 3.5” capacitive port, which was upstreamed as cyd35c-fast. None was built or reviewed for this volume. A fork that only adds OUI rows is the easiest kind to bring back upstream: the tables are data, the tests check their shape, and DETECTIONS.md is where the evidence goes.
6.11 Cheatsheet updates from this volume
Table 18 — 11. Cheatsheet updates from this volume
| Item | Value |
|---|---|
| Build one board | pio run -e <env> -t upload — always -e, one build at a time |
| Platform | [email protected] pinned (Arduino 2.0.14); C5 boards use pioarduino 55.03.312-1 with their own PLATFORMIO_CORE_DIR |
| Default envs | cyd, cyd-ili9341, awok |
| Version stamp | git describe --tags --always --dirty → FIRMWARE_VERSION; env name → SQW_ENV |
| Core types | include/state.h: DetectionType (18 + COUNT), Detection, Confidence, AppState (39) |
| Engine | include/detection.h: init(), loop(), latest(), logAt(), countByType(); LOG_CAP 64, STALE_MS 60 s |
| Tables | src/signatures.cpp: kOuiTable (97, per-row grade, order = priority), kUuidTable, kBtClassicNames, kSsidPrefixes, kMfgIdTable |
| New BLE UUID | add to kUuidTable and kKnown16[] in src/detection.cpp |
| New type | append before COUNT; MORE INFO text in detection_info.cpp (static_assert) + theme, icons, DEX, pages |
| Tests | make -C test — 51 programs, plain g++ |
| Emulator | make -C sim -j8; ./squachsim <screen> out.png; make live; make wasm (em++) — UI only, not matching |
| After a header edit | delete .pio/build/<env>/lib*/TFT_eSPI |
Comments (0)