# PDL RID Sensor — v1.5 (fleet release)

**Build ID `e4047611fd1c`** · git `ac92539` on branch
`rid-sensor-fleet-hardening-2026-08-02` (github.com/markl02us/pipboy_build)

Verified running on **all six physical units** at time of release. This is not a
test build — it is the image currently fielded.

---

## Supported boards

| Board | Flash / PSRAM | Models | GPS | Role |
|---|---|---|---|---|
| Heltec WiFi LoRa 32 **V4** | 16 MB / 2 MB | 2 resident | on-board GNSS header | full sensor |
| Heltec WiFi LoRa 32 **V3** | 8 MB / none | none (no PSRAM) | optional, GPIO 5/6 | relay + RID detector |
| LilyGo **T-Deck Plus** | 16 MB / 8 MB | 2 resident | on-board (Plus only) | full sensor + screen |

A V3 **cannot** run the TFLite models — it has no PSRAM. It remains a full
BLE/WiFi Remote-ID detector and LoRa mesh relay, which is its intended role.

## Binaries

Each is a complete image for one named unit. `SENSOR_NAME` and
`DEFAULT_SENSOR_ID` are compiled in, so flashing Spencer01's image onto another
board gives that board Spencer01's identity.

Flash over USB:

    esptool --port <COM> write_flash 0x10000 <file>.bin

Or over the air (unit must already run a dual-app-partition image):

    pio run -e <env>_ota -t upload

## Partition tables — READ BEFORE UPGRADING FROM v1.4 OR EARLIER

v1.5 requires **dual-app A/B partitions**. Earlier releases shipped a
single-slot table, which makes firmware OTA structurally impossible — there is
nowhere to stage an incoming image.

Moving to A/B needs one **full erase + USB flash per unit**, which wipes the
filesystem and NVS:

    pio run -e <env> -t erase
    pio run -e <env> -t upload
    pio run -e <env> -t uploadfs        # restores data/models
    # then re-issue WIFI_REMEMBER / C2_WIFI_CONNECT

After that, every subsequent update is wireless.

Tables: `partitions_8mb_ota.csv` (3 MB slots), `partitions_16mb_ota.csv`
(4 MB slots). Confirm actual flash size with `esptool flash-id` before choosing
— declaring 16 MB on an 8 MB board produces a unit that bricks on first OTA.

## Remote management (HTTP, port 80)

All previously serial-only, which is unusable on a battery unit.

    GET /status                                 full health + build id
    GET /set-location?lat=<deg>&lon=<deg>       static position (GPS overrides)
    GET /c2-config?host=&port=&path=            OpenForge C2 endpoint
    GET /mqtt-config?broker=&port=&client=      MQTT broker (applies next boot)
    GET /rnode-enable                           Reticulum announce
    GET /reboot
    GET /gps-raw                                last ~12 raw NMEA sentences,
                                                plus active rx/tx/baud
    POST /peer-detection                        peer relay ingest (33-byte hex,
                                                Content-Type: text/plain)

**These are unauthenticated on the LAN.** Same trust model as the existing
status server. Do not expose a unit to an untrusted network.

## Reporting

One detection fans out to LoRa mesh, BLE companion, OpenForge C2, MQTT
(`pdl/<name>/detection`), and the WiFi peer relay. Position and timestamp both
resolve through single shared helpers, so every transport carries identical
values — verified live: MQTT `lat 386200000 / lon -781800000` against C2
`38.62 / -78.18`, timestamps within 2 s.

Every unit also emits `pdl/<name>/status` every 60 s regardless of activity, so
**absence of a heartbeat means a dead unit** rather than a quiet one.

## GPS wiring (V3, optional)

    GPS TX  -> GPIO 5      GPS RX  -> GPIO 6
    VCC     -> 3V3         GND     -> GND

Leave GPIO 34 unconnected. **GPIO 33–38 are SPI flash on a V3** — driving them
breaks UART reception. v1.5 no longer scans; it opens 5/6 directly at boot.

Confirm with `gps_chars_processed` climbing in `/status`. That counts raw bytes
before parsing, so it proves wiring independently of satellite acquisition —
but see the warning below: climbing bytes do **not** prove the baud is right.

## GPS on the T-Deck — READ THIS BEFORE DIAGNOSING ONE

The T-Deck Plus ships with **either** of two GNSS modules, on the **same**
GPIO 44 (RX) / 43 (TX) UART, at **different baud rates**:

| module | baud |
|---|---|
| Quectel L76K | 9600 |
| u-blox MIA-M10Q | 38400 (the variant on current stock) |

Nothing on the board or in the wiring says which is fitted. v1.5 probes 38400
first, then 9600, in both pin orientations, and locks on the first candidate
that yields **checksum-valid NMEA**.

A **base T-Deck has no GPS at all** — LilyGo repurposed its Grove pins for GPS
on the Plus. So a Grove GPS module plugs into a base T-Deck and works with this
firmware unchanged (cross TX→44, RX→43; wrong orientation self-corrects).

**A wrong baud rate does not look like a broken GPS — it looks like a broken
antenna.** A UART clocked at the wrong speed still delivers a steady, plausible
byte stream. Earlier firmware locked its pin scan on raw byte count, so a
38400 module "passed" the 9600 candidate in under a second and latched forever,
reporting live GPS traffic and zero satellites indefinitely. Read `/gps-raw`
rather than guessing:

| what you see | what it means |
|---|---|
| bytes flowing, 0 sats, many checksum failures | **wrong baud** — firmware |
| clean NMEA, `$G?GSV` shows 0 satellites in view | antenna or no sky |
| clean NMEA, GSV has real C/N0 but GGA quality 0 | fine, still acquiring |
| **0 bytes on every pin and baud** | **no module fitted** |

An antenna fault can only change satellite count. It can never stop a module
transmitting on the UART, so 0 bytes always means no module — never a bad
antenna.

## Serial diagnostics

    REPORT_STATUS            full JSON
    GPS_PINS <rx> <tx>       force a UART pair, halt autoscan
    GPS_STATS / GPS_RESET    byte counters
    GPS_WHERE                which pins the UART is actually open on
    GPS_LOOPBACK             UART self-test (jumper TX->RX)
    TEST_BLE_ODID <hex>      inject a synthetic Remote-ID advert
    SET_LOCATION <lat> <lon>

## Known limitations

* No unit tests for `ntp_timesync`, `ota_firmware`, `peer_relay`,
  `tdeck_display`, or the C2 self-healing logic. `mesh_validate` has 18, and
  `peer_relay` is verified end to end on hardware (below) but has no automated
  regression test.
* `POST /peer-detection` reads its body via the ESP32 WebServer's `plain`
  argument, which is **not populated** if the client sends
  `Content-Type: application/x-www-form-urlencoded`. `peer_relay.cpp` sends
  `text/plain` so the production path is correct, but a third-party integrator
  posting form-encoded gets a confusing `400 expected 33-byte hex`.
* **T-Deck battery reads 0** — `BATT_ADC_PIN` is an unverified guess (GPIO 4).
* Endpoint authentication and pull-based OTA are **not implemented**.
* rmap / meshmap integration does **not** work. Reticulum announces are emitted
  on the local LoRa radio but reach no public network — that needs an RNS host
  bridging outward, and PDL's LoRa parameters are not RNode-compatible.
* OTA push (espota) fails roughly 1 upload in 4 and needs retrying.

## Fixed in v1.5

* **T-Deck GPS baud was hardcoded 9600** on a board that ships a 38400-baud
  u-blox M10Q half the time. TDeck02 went 0 → 12 satellites on the fix.
* **The pin scanner locked on raw byte count**, which proves only that
  something drives the pin — never that the baud is right. Now requires
  checksum-valid NMEA. Gateway01 (10 sats) and Spencer01 (14 sats) still lock
  at 9600 under the stricter rule, so the Heltec units did not regress.
* **GPS_EN drove GPIO 34 on V3 — that is SPI flash.** Toggled every 6 s,
  breaking UART1 reception on every V3.
* GPS pin autoscan swept GPIO 33–42 on V3 (flash + JTAG).
* `mqtt_publish_detection()` had **no call site anywhere** — MQTT never published.
* MQTT required a duplicate WiFi SSID in its own NVS namespace or silently idled.
* Every unit posted the identical `gateway_id`, so all six collided in C2's
  fleet catalog and overwrote each other.
* `sensor_type` hardcoded `HELTEC_V4` on every board.
* Position and timestamp diverged between C2 and MQTT for the same detection.
* `mesh_deserialize_event()` validated nothing — foreign Meshtastic frames
  became phantom drone contacts at impossible coordinates (lat 107.29).
* OTA aborted at ~60%: `ArduinoOTA.handle()` blocks, starving the task watchdog.
* Reticulum `announced:false` everywhere — the enable flag latched at boot.
* C2 heartbeat could wedge indefinitely while the unit looked healthy on every
  other axis (hit three units).
