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

Figure 1 — A release teaser for a rendering change: the firmware stopped re-sending screen rows that had not changed since the last frame, the kind of SPI-bandwidth work the display layer in this…
Figure 1 — A release teaser for a rendering change: the firmware stopped re-sending screen rows that had not changed since the last frame, the kind of SPI-bandwidth work the display layer in this volume is built around. Image: SquachWatch-CYD firmware screen capture (skizzophrenic, GPL-3.0).

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) saysThe code now hasWhere
10 DetectionType values18 (adds SAMSUNG_TAG … HACKER)include/state.h
4 AppState values39 (0–38)include/state.h
Detection::vendor is char[12]a const char* into the tablesinclude/state.h
Confidence per typeper signature row, carried on each sightinginclude/state.h, include/signatures.h
Alert auto-dismiss 5 s10 sREADME.md, “Usage”
No SD log, no OTA, no GPSall three existdocs/DESIGN.md §14 footnote
MITGPL-3.0docs/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

SettingValueWhy (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 / frameworkesp32dev / arduino
upload_speed921600Drop to 115200 if uploads fail
board_build.partitionspartitions_ota.csvTwo 1.9 MB app slots since v1.7.0; NVS and coredump at the old offsets
monitor_speed921600 default; 2,000,000 on CYDsAWOK’s CP210x garbles at 2 Mbaud
extra_scriptspre:extra_script.pySee §2.4
lib_depsbodmer/TFT_eSPI@^2.5.43; XPT2046_Touchscreen from GitHub master; h2zero/NimBLE-Arduino@^2.5.1The XPT2046 registry package “is stuck at 0.0.0-alpha (2019)“
build_unflags-DILI9341_DRIVER -DST7789_DRIVER -DST7796_DRIVERClear TFT_eSPI defaults so each board header decides
default_envscyd, cyd-ili9341, awokWhat 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

EnvironmentChipKey -D flagsStatus / note
cydESP32CYD, SQUACH_MESH, SERIAL_BAUD=2000000Default; 2.8” ST7789 at 40 MHz
cyd-fastESP32+ SPI_FREQUENCY=80000000Flasher default for the 2.8”
cyd-ili9341, cyd-ili9341-fastESP32CYD + ILI9341 headerSame board, older panel
cyd-gps, cyd-ili9341-gpsESP32+ CYD_GPSNot in flasher/CI/releases
cyd-crowd, cyd-fast-crowd, cyd-ili9341-crowdESP32+ CROWD_BENCHBench: renders 1–30 Squachys and times them
cyd-fast-floodESP32+ FLOOD_BENCHBench: FLOOD N console feeds fake adverts
cyd35, cyd35-fast, cyd35c-fastESP32CYD35 (+CYD35C)cyd35 frozen (boot loop documented); the -fast builds ship as BETA
rlphantom, rlphantom-rESP32CYD, RLPHANTOM / RLPHANTOM_RCapacitive build never run on hardware; resistive ships
freenove32, cyd32cESP32CYD + FREENOVE32 / CYD32C3.2” boards
awokESP32AWOKTouch on the display bus; 921600 console
twatch-s3, twatch-s3-loratxESP32-S3TWATCH_S3, SQUACH_LORA, SQW_S3, BOARD_HAS_PSRAM; RadioLib 7.7.1, XPowersLib16 MB; -loratx is a bench transmitter
sticks3, cardputer-advESP32-S3SQW_SMALL, SQW_FLASH_8MB8 MB parts
freenove-s3, freenove-s3-40, freenove-s3-35ESP32-S3FREENOVE_S3*-40 not on the flasher
tdeck-plusESP32-S3TDECK_PLUS, CYD_GPS, CYD_GPS_RX=44GPS wardriving
crowpanel7 (+ -native, -paneltest, -probe, -touchtrace)ESP32-S3CROWPANEL7Extras are bench builds
nm-cyd-c5ESP32-C5NM_CYD_C5, SQW_WIFI_5G, SQW_C5_DMA_HZ=80000000pioarduino platform 55.03.312-1
marauder-v8ESP32-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

FlagValueUsed for
FIRMWARE_VERSIONgit describe --tags --always --dirty, or "unknown"; SQW_VERSION env var overrides on the benchSplash stamp, Diary screen, update comparisons, squad hellos
SQW_ENVThe PlatformIO environment nameBound into the OTA signature (Vol 5 §7.2)
-fno-exceptionsalwaysNo catch anywhere; the unwind tables were 61.7 KB of the 3.2” build
TFT_eSPI patchnm-cyd-c5 onlyDrops 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

MacroMeaning
CYDThe plain-ESP32 Sunton family code paths (also set on the C5 CYD-shaped boards)
CYD35, CYD35C, CYD32C, RLPHANTOM_R, FREENOVE32, AWOKPer-board pin, touch and backlight differences
SQW_S3, SQW_SMALL, SQW_FLASH_8MBESP32-S3 family; small-screen layout; 8 MB flash
SQUACH_MESHCompile SquachMesh (set on every shipping env)
SQUACH_LORALoRa receive stack (T-Watch)
CYD_GPS, CYD_GPS_RX, CYD_GPS_BAUD, CYD_GPS_SERIALGPS support and its UART
SQW_WIFI_5G5 GHz scanning (ESP32-C5 only; static_asserted against the SoC)
SPI_FREQUENCYDisplay write clock; guarded with #ifndef in the board header
SERIAL_BAUDConsole 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_eSPI before building, or the board keeps the old display settings.” Changing SPI_FREQUENCY by 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

StepWhat happens
1Serial.begin(SERIAL_BAUD); crash/reset-reason bookkeeping
2tft.init()
3Settings::load() (before rotation, so the saved rotation applies)
4Security::begin(); OtaCore::boot() (is this boot an update on probation, or the aftermath of a rollback?)
5Rotation, theme, StatusLight::begin()
6Optional boot-time update check over a saved Wi-Fi network (OtaWifi::bootCheck) — before Bluetooth starts
7Touch probe per board (XPT2046, GT911, FT6336, CST/ST77922…)
81.2 s “Hold anywhere now to reset touch calibration…” window; five-target calibration if none is saved
9Backlight; BlackBox::begin() and a boot record (crash summary if the last boot died)
10wardriveBegin() on the watch
11engine.init() — Wi-Fi promiscuous + NimBLE scan
12Lora::begin() (LoRa builds); Bingo, Dex, Regulars; MeshTalk::begin() (mesh builds)
13Duress-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

GroupAppState values
Core loopBOOT 0, CLEAR 1, ALERT 2, LOG 3, WATCH_ALERT 8, HUNT 10, RAWSCAN 7, DESK 32
Setup and checksCOLOR_CHECK 11, SYS_PROPS 35, DIAGNOSTICS 9
Settings treeSETTINGS 4, DETECTION_FILTER 12, BEACON_WARN 21, IGNORE_LIST 14, POWER_SAVER 15, STATUS_LIGHT 28
SecuritySECURITY 22, LOCKED 23, PIN_ENTRY 24
Updates and networksUPDATE 26, WIFI_PASS 27, WIFI_NETS 33, WIFI_ADD 34, NUDGE 29, SQUAD_UPDATE 30
SquachMeshMESH_MENU 17, MESH_WARN 18, MESH_PHRASE 19, MESH_COMPOSE 20, SQUAD 25, INVITE 31, PHONE 16
Squachy and extrasDIARY 5, OUTFIT 6, OUTFIT_UNLOCK 13, BINGO 36, DEX 37
WatchLORA_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

FieldTypePurpose
mac[6]uint8_tTransmitter address (BSSID for beacons)
rssi, channelint8_t, uint8_tLast signal; channel 0 for BLE
typeDetectionType
restoreduint8_tRow came from the flash black box, not this boot
prevRssi, prevAtint8_t, uint8_tSignal ~2 s ago, for the LOG’s closer/further arrow
vendorconst char*Pointer into the signature tables or a literal — “never a copy”; read through vendorText()
name[20]charBLE name, SSID, Pwnagotchi name, iBeacon ID
firstSeen, lastSeenuint32_tmillis() stamps
hitsuint16_tSightings (counts differently per radio — not used for snooze)
alerts, quietBar, lastAlertMs, askedMinAUTO SNOOZE bookkeeping
confConfidenceGrade of the row that actually matched
activeboolCleared 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

ConstantValueComment in the source
LOG_CAP64Cut from 200, which “cost 11.6 KB of the board’s static RAM”; history beyond it is in the flash black box
WIFI_Q_CAP8IRAM mailbox depth
STALE_MS60 000”Some Flock cameras only probe every 30+ seconds, so don’t drop it much lower”
DEAUTH_THRESHOLD / _WINDOW_MS / _COOLDOWN_MS6 / 3000 / 15000Burst, not single frames
AP_CAP24Evil-twin SSID table
RAW_BLE_CAP20Raw 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

TableRows (as read)Matched againstLookupRule
kOuiTable97 (49 FLOCK, 15 RING, 17 CAMERA, 7 ALPR, 3 AXON, 3 SKIMMER, 3 HACKER)Wi-Fi transmitter / BSSID first three octetslookupOui(mac, &conf)First match wins, top to bottom
kUuidTable15BLE 16-bit service UUIDslookupUuid(), uuidName()Exact
kBtClassicNames7BLE advertised namelookupBtName()Case-insensitive exact, then hard-coded substrings
kSsidPrefixes8Beacon SSIDlookupSsid(), ssidVendorName()Case-insensitive prefix; bare "Flock" is a special exact case
kMfgIdTable7BLE 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-coded static const uint16_t kKnown16[] list and calls lookupUuid() only for a UUID on that list. A new row added to kUuidTable alone will never fire; the UUID must be added to kKnown16[] as well. The build guide’s “append to kOuiTable” 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 kOuiTable in src/signatures.cpp, with the matching DetectionType from include/state.h and a confidence grade (see DETECTIONS.md). Run make -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

StepOUI (Wi-Fi)16-bit service UUIDCompany IDBLE nameSSID prefix
1. Source itIEEE MA-L registry entry for the prefixBluetooth SIG assigned numbersSIG company identifier listA capture of the real advertVendor documentation or a capture
2. Pick type and gradeGrade on the row (HIGH/MED/LOW per §5.2)Type-level grade via confidenceFor()samesame; HACKER by name is MEDsame
3. Place the rowIn its type’s block — order is priorityAnywhere in kUuidTablekMfgIdTablekBtClassicNames (exact) or a strcasestr line in lookupBtName()kSsidPrefixes
4. Extra wiringnoneAdd to kKnown16[] in detection.cppApple 0x004C has payload testsnonenone
5. Label≤ 11 characters (tested)used as vendorused as vendorname itself shownused as vendor
6. MORE INFO pageadd the label to a DeviceInfo::kDevices entry if the type has device pagessamesamesame (name rule)same
7. DocumentRow under the type in docs/DETECTIONS.md, with source and gradesamesamesamesame
8. Testmake -C testsamesamesamesame
9. VerifyReal hardware — the emulator cannot exercise matching (§7.4)samesamesamesame

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 optionEffect
screenclear log alert settings diary hunt rawscan watchalert colorcheck boot
--portrait240×320 instead of 320×240
--bg N / --theme NBackground 0–9 / palette index
--frames NWarm-up frames before capture (default 90) — animated scenes build state
--onboardLet the first-boot walkthrough run
--sequence N, --raw PATHSeveral 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

CommandEffect
D <x> <y> / M <x> <y> / UPress / 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)
RReport AppState on stderr
QQuit

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 coveredWhy
Signature matchingdetection_sim.cpp replaces the engine; “Signature-matching changes still need hardware.”
The DETECTION FILTERThe stub’s postBle() deliberately has no typeEnabled() guard
Pixel-exact shapesdrawWideLine (Squachy’s arms), fillRoundRect corners, fillEllipse, drawArc are approximations
Panel behaviourNo 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/*.gif in the repository) are rendered by this emulator through the sim/make_*.py scripts — “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

AreaTests
Signature tables and payload matcherssignatures_test, ibeacon_test, hacker_test, pwnagotchi_test, device_info_test, spam_test
Remote IDremote_id_test, emitter_roundtrip_test
Log, black box, SD and WiGLE formatsblackbox_test, logindex_test, sd_row_test, wigle_test, wardrive_test, wardrive_seen_test, gnss_test
Settings, security, ignore listsecurity_test, ignore_list_test, autoquiet_test, bgretire_test, hunt_test, regulars_test
SquachMeshsquachmesh_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 arithmeticshim_fidelity_test, framepush_test, touchfit_test
Squachy extrasbingo_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:

  1. Add an [env:yourboard] to platformio.ini.
  2. Write include/yourboard_user_setup.h — the TFT_eSPI driver, pins, SPI_FREQUENCY guarded with #ifndef, RGB order and inversion — and pull it in with -include $PROJECT_DIR/include/yourboard_user_setup.h plus -DUSER_SETUP_LOADED=1.
  3. Add a -D flag naming the board; put touch, backlight and pin differences in #if blocks keyed on it.
  4. Pick nimble_flags_cyd or nimble_flags_mesh, a partition table that matches the flash size, and SERIAL_BAUD/monitor_speed.
  5. Delete .pio/build/<env>/lib*/TFT_eSPI after every header edit.

Table 16 — 9. Porting to another board

Board-specific concernWhere it livesExample
Display driver and pinsinclude/<board>_user_setup.hcyd_user_setup.h: ST7789, MISO 12 / MOSI 13 / SCLK 14 / CS 15 / DC 2, RST −1, BL 21, BGR
Touch controllersetup() touch probe in main.cppXPT2046 on its own SPI; GT911 / FT6336 over I²C
SD chip-selectsrc/sd_log.cpp, SD_CS_PIN5 on the CYD, 14 on AWOK, 10 on the C5 boards, 39 on the T-Deck Plus
Status light pinssrc/status_light.cpp, STATUS_LIGHT_HWGPIO 4/16/17 RGB on the CYD; WS2812 elsewhere; compiled out where unverified
Batterymain.cpp, boardBattery* / PMU codeVol 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-c5 pattern: its own platform line, custom boards/ JSON and variants/ pins, and a separate PLATFORMIO_CORE_DIR so its toolchain does not disturb the pinned [email protected] builds.


6.10 Contributing, licence and forks

Table 17 — 10. Contributing, licence and forks

TopicWhat the project says
LicenceGPL-3.0. “Fork it, break it, make it weirder. If you ship a changed version, share your source under the same license.”
Bug reportsBoard, 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 reportsType, confidence, first half of the address, name, and what it really was — with PRIVACY MODE on for photos
Signature PRsReal sources required; provenance recorded in docs/DETECTIONS.md
Third-party dataFlock OUIs from colonelpanichacks/flock-you (MIT) redistributed as data with credit; AirTag check ported from nyanBOX (MIT)
Ideas backlogdocs/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

ItemValue
Build one boardpio 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 envscyd, cyd-ili9341, awok
Version stampgit describe --tags --always --dirty → FIRMWARE_VERSION; env name → SQW_ENV
Core typesinclude/state.h: DetectionType (18 + COUNT), Detection, Confidence, AppState (39)
Engineinclude/detection.h: init(), loop(), latest(), logAt(), countByType(); LOG_CAP 64, STALE_MS 60 s
Tablessrc/signatures.cpp: kOuiTable (97, per-row grade, order = priority), kUuidTable, kBtClassicNames, kSsidPrefixes, kMfgIdTable
New BLE UUIDadd to kUuidTable and kKnown16[] in src/detection.cpp
New typeappend before COUNT; MORE INFO text in detection_info.cpp (static_assert) + theme, icons, DEX, pages
Testsmake -C test — 51 programs, plain g++
Emulatormake -C sim -j8; ./squachsim <screen> out.png; make live; make wasm (em++) — UI only, not matching
After a header editdelete .pio/build/<env>/lib*/TFT_eSPI

Comments (0)

  1. Loading…

Comments are held for moderation — nothing appears until approved.