diff --git a/TODO.md b/TODO.md
index 87fcc55..d727e78 100644
--- a/TODO.md
+++ b/TODO.md
@@ -5,6 +5,71 @@ Engineering to-do list. The reviewer-facing open items live in
## Waiting on hardware
+### Confirm the RX queue drop counter explains the bench-session frame drops / map flicker (added 2026-09-22)
+
+Investigated the user's report of "OBU mode keeps dropping a few frames" and "v2x screen comes
+and goes" while bench-testing against `obu-cam-transmistter`. Found a real, previously invisible
+drop path: `obu-firmware/main/main.c`'s `wifi_promisc_rx_cb()` calls `xQueueSend(s_rx_queue, ...,
+0)` (queue depth 8) without checking the return value, so a burst of promiscuously-captured
+frames arriving faster than `rx_forward_task` can drain them (each drain can legitimately block up
+to ~400ms under USB/UART contention) silently vanishes. None of the existing `EspLinkStatus`
+counters (`oversizeDrops`/`txFailures`/`rxCrcErrors`) caught this class of drop.
+
+This plausibly also explains the map symptom: `UseCaseDetectionEngine.pruneStale()` drops a remote
+station's marker after `staleRemoteMs` (3 s) with no CAM update. Measured 2026-09-22 via
+`tools/cit_one_rx_watch.py --host 192.168.40.201` against `obu-cam-transmistter`'s bench beacon
+(stationID 195936478 / 0x0BADC0DE): **75 CAMs in 25 s, ~3 Hz**, not the 1 Hz this note assumed
+earlier — faster than assumed means more promiscuous captures per second and a shorter fuse on
+`staleRemoteMs`, both of which make the queue-overflow theory more likely, not less.
+
+Fixed to be **visible**, not yet fixed to **not drop**: added a `rxQueueDrops` counter, checked
+`xQueueSend`'s return value (`main.c`), wired it through the STATUS heartbeat as a new trailing
+`uint16` field (`serial_link.c/.h`, `SerialFrame.kt`'s `EspLinkStatus`), and surfaced it on the
+CAM Pinger card (`MqttTopicViewerScreen.kt`, string `mqtt_cam_pinger_fw_counters`). Host build
+untouched (serial_link.c/main.c aren't in the host test's standard-headers-only set); IDF build
+verification is the remaining pre-flash check. Deliberately did NOT bump `s_rx_queue`'s depth from
+8 — no real burst-size data yet, and guessing a bigger number against an unmeasured memory budget
+is exactly the kind of assumption [[microbu-hw-review]] flags as needing verification first, not
+capacity that's cheap to reason your way into.
+
+Needs: a phone attached to the production OBU's native USB port, watching the CAM Pinger card,
+while `obu-cam-transmistter` (or real traffic) beacons.
+
+- [x] `idf.py build` succeeds (obu-firmware, IDF 6.1) — clean, both changed files compiled with no
+ warnings, 17% flash free.
+- [x] Reflashed the production OBU on **COM3** 2026-09-22 (hash verified). Boot log confirms the
+ new build (`21e0149-dirty`, compiled Sep 22 2026 14:14:09), clean boot, OCB @ 5900 MHz
+ TX/RX armed, `serial_link up ... 1 Hz heartbeat`, no panic. Incidentally answers part of the
+ "measure the OBU's actual transmit power" item below: this boot logged
+ `tx power: 72 quarter-dBm = 18.00 dBm (20.00 requested)` — the driver **is** clamping below
+ the requested 20 dBm at 5900 MHz, as that item suspected but had not measured.
+- [x] 25 s of steady-state console (no phone attached, `obu-cam-transmistter` beaconing nearby):
+ silent — no crash, no `oversize`/`rx queue full`/`crc` warnings. Inconclusive on its own
+ (successful forwards aren't logged, and nothing was attached to trigger the ~400 ms UART
+ stalls the theory needs), but at least rules out a crash-on-boot regression.
+- [x] Confirmed the wider bench RF path independently via the CiT One OBU broker
+ (`py -3.11 tools/cit_one_rx_watch.py --host 192.168.40.201`): heard `obu-cam-transmistter`'s
+ beacon cleanly, 75/25 s, GN source `14:00:02:00:00:00:00:01`, position in the expected
+ St. Georg route area. This is a *different* receiver from the production OBU though — it
+ shows the beacon is genuinely on air, not that COM3 forwards every one of it without drops.
+- [x] **Confirmed on real hardware, 2026-09-22.** Installed the updated debug APK (previous build
+ on the phone was from 2026-09-15, predating this fix entirely) on the Pixel 9 Pro (adb over
+ Wi-Fi), relaunched against the freshly-reflashed COM3, and read `rx queue drop` via `adb
+ logcat -s UsbSerialTransport`. The counter mechanism works end-to-end and **the bug is
+ real**: `rxQueueDrops` was 0 at the last flash (14:22), read as 89 at first reconnect
+ (14:48, ~26 min later), and 90 at a second reconnect (14:52). No `oversizeDrops`,
+ `txFailures`, or `rxCrcErrors` moved at all, and zero `decode FAILED` lines — this queue is
+ the only place frames are going missing.
+ Nuance: over a clean ~4.5 min window in between (14:48→14:52) with `obu-cam-transmistter`
+ actively beaconing at a measured **~3.33 Hz** (matches the CiT One's 75/25 s independently)
+ and 490+ CAMs decoding cleanly with steady cadence and no gaps, the counter did **not**
+ move — it only ticked at connect/reconnect moments. So this is a low-rate, bursty drop (matches
+ the user's own "a few frames" framing), not a continuous overflow under steady single-station
+ traffic; it may be specific to WiFi/PHY activity around association or reconnect rather than
+ raw beacon rate. Worth a longer, quieter-boot capture before sizing a `s_rx_queue` bump.
+ Did **not** independently confirm the map-flicker connection this session — that needs eyes
+ on the app's V2X screen while watching this same counter live, not just logcat.
+
### On-device check of the full-screen V2X live map (added 2026-09-15)
The live map moved out of the V2X Monitor's view-mode row into its own full-screen destination
@@ -51,6 +116,27 @@ Partial check possible with one board and no phone: flash it, `idf.py -p COMx mo
for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not
that it transmits correctly.
+### Measure the OBU's actual transmit power (added 2026-09-14)
+
+Nothing in this project has ever measured it. `main.c` asks for 20 dBm
+(`esp_wifi_set_max_tx_power(80)`, 0.25 dBm units) and the build's ceiling is the same
+(`CONFIG_ESP_PHY_MAX_TX_POWER=20`), but a request is a ceiling, not a guarantee: the driver clamps
+it to its own calibrated table, and 5900 MHz is above the range this chip is rated for, so the
+table actually in use is channel 177's. The firmware now reads the value back and logs it at boot,
+which records what the driver admits to, not what leaves the antenna.
+
+- [ ] Flash and `idf.py -p COM3 monitor`, then note the `tx power:` line. A value below 80 means
+ the driver clamped the request, which the code alone cannot tell you.
+- [ ] Relative check with the second ESP32-C5 on `its-g5-receiver-firmware`: capture at a measured
+ distance in a straight line, read the RSSI the receive path already reports, and record
+ distance and RSSI together. This gives a comparable number between builds and antennas,
+ which is what matters for range work, without any lab equipment.
+- [ ] Only a spectrum analyser or a calibrated reference receiver gives real radiated power. Worth
+ it only if the range result looks wrong, or if the thesis needs an absolute figure.
+
+For context: ETSI allows up to 33 dBm EIRP on the ITS band, and production OBUs sit around
+20 to 23 dBm, so the requested figure is in the right region if the PA really keys it there.
+
### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11)
Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of
@@ -80,6 +166,58 @@ optional, but shows what was on air at the time.
shorter).
- [ ] The heartbeat's oversize counter still counts over-long messages (e.g. road SPATEMs).
+### CiT One custom CAM injection over `v2x/tx/v2/cam` (added 2026-09-14)
+
+The haw-002 unit now runs the special firmware: Cohda's own CAM transmission disabled, and a
+V2X-Gateway build that accepts a `SendV2XMessage` (schemas.consider-innovation.de/its-s/
+v2x_interface.proto) carrying a UPER CAM on `v2x/tx/v2/cam`. `tools/cit_one_cam_tx.py` builds
+and publishes those from a PC; its `--self-test` passes offline, proving only that the bytes
+match `CamEncodeGoldenTest.kt` and that the protobuf wrapper round-trips. Nothing about what
+the OBU does with them is established.
+
+Reach the broker over Wi-Fi or Ethernet for now - the USB-peripheral-mode link needs the phone
+to be USB host on a `172.25.1.0/24` interface with no DHCP server, which Android cannot
+configure from inside an app.
+
+Needs: the CiT One haw-002 on the same network as a PC, and a second ESP32-C5 running
+`its-g5-receiver-firmware` sniffing G5CC (`-c 5900`) to capture with.
+
+Bench run 2026-09-14, PC -> haw-002 (192.168.3.201), captured on the RSU (192.168.3.202,
+**not** .2.202 - that address does not route). `tools/cit_one_rx_watch.py` decodes what a unit
+hears. Result: the injection path works end to end, with one blocker found.
+
+- [x] Publishes without the broker refusing the topic. 1.00 Hz, confirmed by subscribing to
+ `v2x/tx/v2/cam` on the OBU itself.
+- [x] The RSU hears our CAMs on air, 1.00 Hz, matching what we publish.
+- [x] `ItsPduHeader` **is** expected in the payload - we send it included and it decodes.
+- [x] BTP destination port 2001. GN source address `08:00:26:93:92:01:91:dc`, the OBU's.
+- [x] **Our CAM content goes out intact**: position, speed (417), heading (639), width (7) and
+ length (18) arrive byte-exact. The gateway does not touch the content.
+- [x] **The gateway overwrites `stationID`** with the OBU's own (999999 -> 4033890855, which
+ matches `own_info.stationID` on `v2x/rx/obu_gnss`). This is what the "OBU owns identity"
+ decision wants, so `--follow-obu-identity` is not needed on this unit.
+- [x] ~~BLOCKER: Cohda's own CAM is still transmitting.~~ Fixed 2026-09-14 by disabling CAM in
+ a second conf file: the RSU now hears only our stream, 0 CAMs with the stack's
+ unavailable dimensions over 30 s. Note the stack restart gave the unit a new identity
+ (stationID 4033890855 -> 2553426533, GN source `08:00:26:...` -> `08:00:a2:...`), which is
+ expected under `ItsGnLocalAddrConfMethod = 2` (anonymous, random at boot).
+- [x] Re-checked: 41 published / 41 heard over 40 s, 1.02 Hz both ends, inter-arrival a steady
+ 1.0 s. 100% delivery, no gateway rate limiting. An earlier 0.40 Hz sample was the stack
+ still settling after the restart and did not persist.
+Two topics the v6 API does not document, found by subscribing to `#` on haw-002:
+
+- `v2x/loopback/cam` - a `RecvV2XMessage` (btpHeader.type=2) carrying each CAM the unit
+ transmits, 1:1 with what we publish and **after** the gateway's stationID rewrite. This is the
+ TX confirmation we were going to ask consider it for: it makes "did my CAM go out, and under
+ which identity" answerable on the transmitting unit alone, without an RSU or a second ESP32.
+- `v2x/rx/obuinfo` at 10 Hz - the protobuf `OwnStationInfo` (binary twin of `obu_gnss`;
+ field 2 decodes to the same stationID, field 10 to the same heading). Output only, so it is
+ not the content-feed input we speculated about.
+
+- [ ] Wire `v2x/loopback/cam` into `cit_one_rx_watch.py` as a local TX check.
+- [ ] Sanity-check the rate: `--rate 4` should produce 4 CAMs/s on air, since `ItsDCCEnabled = 0`
+ on this unit.
+
## Set up host testing
- [x] Install MSYS2 UCRT64 gcc (done 2026-09-11: gcc 16.2.0, GNU Make 4.4.1; chosen over WSL,
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt
index 483c54a..2f95d3a 100644
--- a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/SerialFrame.kt
@@ -57,8 +57,9 @@ const val SERIAL_LINK_MAX_PAYLOAD = 512
/**
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
- * [rxCrcErrors:2 LE]` (7 bytes). Counters are free-running totals since firmware boot and
- * saturate at 0xFFFF rather than wrapping.
+ * [rxCrcErrors:2 LE][capabilities:1][rxQueueDrops:2 LE]` (10 bytes; the last two fields are an
+ * optional tail — see [capabilities] and [rxQueueDrops]). Counters are free-running totals since
+ * firmware boot and saturate at 0xFFFF rather than wrapping.
*
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
@@ -79,6 +80,15 @@ data class EspLinkStatus(
* the phone needs from such firmware: it accepts nothing beyond the original messages.
*/
val capabilities: Int = 0,
+ /**
+ * Promiscuously-captured frames the firmware's `wifi_promisc_rx_cb` had to drop because its
+ * RX queue (8 deep) was still full of frames `rx_forward_task` hadn't finished forwarding —
+ * bytes 8-9 of the payload. 0 for firmware that predates this field (payload of 7 or 8 bytes),
+ * which is the honest answer: such firmware drops these frames identically, it just never
+ * counted them. A nonzero, growing value here — as opposed to [oversizeDrops] — points at
+ * bursty RX outrunning the forward task rather than any one frame being too large.
+ */
+ val rxQueueDrops: Int = 0,
) {
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
@@ -99,6 +109,7 @@ data class EspLinkStatus(
txFailures = u16(3),
rxCrcErrors = u16(5),
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
+ rxQueueDrops = if (payload.size >= 10) u16(8) else 0,
)
}
}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt
index dce7efc..0af3bc0 100644
--- a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt
+++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt
@@ -330,13 +330,15 @@ class UsbSerialTransport @Inject constructor(
prev.txFailures != status.txFailures ||
prev.rxCrcErrors != status.rxCrcErrors ||
prev.status != status.status ||
- prev.capabilities != status.capabilities
+ prev.capabilities != status.capabilities ||
+ prev.rxQueueDrops != status.rxQueueDrops
) {
Log.i(TAG, "ESP32 counters: status=${status.status} " +
"oversizeDrops=${status.oversizeDrops} " +
"txFailures=${status.txFailures} " +
"rxCrcErrors=${status.rxCrcErrors} " +
- "capabilities=${status.capabilities}")
+ "capabilities=${status.capabilities} " +
+ "rxQueueDrops=${status.rxQueueDrops}")
}
_linkStatus.value = status
}
diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt
index 97586c2..e292011 100644
--- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt
+++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt
@@ -1219,11 +1219,12 @@ private fun CamPingerCard(
Text(
stringResource(
R.string.mqtt_cam_pinger_fw_counters,
- s.txFailures, s.oversizeDrops, s.rxCrcErrors,
+ s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.rxQueueDrops,
),
style = MaterialTheme.typography.labelSmall,
- color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0)
- ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
+ color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0 ||
+ s.rxQueueDrops > 0
+ ) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5d5cd9b..832e515 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -264,7 +264,7 @@
Pinging - 1 CAM/s over the serial link
Sent: %1$d
Write failures: %1$d consecutive - CAMs are not reaching the ESP32
- ESP32: tx fail %1$d · oversize %2$d · crc err %3$d
+ ESP32: tx fail %1$d · oversize %2$d · crc err %3$d · rx queue drop %4$d
Own TX heard back: %1$d frames · %2$d dBm
Own TX heard back: %1$d frames
Start Pinger
diff --git a/app/src/test/java/com/hawhamburg/micr0bu/CamTxPvSerialTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/CamTxPvSerialTest.kt
index e2b0222..af00ba2 100644
--- a/app/src/test/java/com/hawhamburg/micr0bu/CamTxPvSerialTest.kt
+++ b/app/src/test/java/com/hawhamburg/micr0bu/CamTxPvSerialTest.kt
@@ -166,6 +166,21 @@ class CamTxPvSerialTest {
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
}
+ @Test
+ fun `firmware that predates the rx queue drop counter reports zero`() {
+ // 8-byte heartbeat (status + counters + capabilities, no rx queue drops tail).
+ val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
+ assertEquals(0, status.rxQueueDrops)
+ }
+
+ @Test
+ fun `rx queue drops are read little-endian from the 10-byte payload`() {
+ // status=0, oversize=0, txFail=0, rxCrc=0, capabilities=0x01, rxQueueDrops=0x0102 (LE: 02 01)
+ val status = EspLinkStatus.parse("00000000000000010201".hexToBytes())!!
+ assertEquals(0x0102, status.rxQueueDrops)
+ assertTrue(status.supportsCamTxPv)
+ }
+
private val mac = "024d49435230".hexToBytes()
private fun vectorAt(tstMs: Long) = GnPositionVector(
diff --git a/obu-firmware/main/main.c b/obu-firmware/main/main.c
index dcf3ced..35d5bcb 100644
--- a/obu-firmware/main/main.c
+++ b/obu-firmware/main/main.c
@@ -259,8 +259,13 @@ static void wifi_promisc_rx_cb(void *recv_buf, wifi_promiscuous_pkt_type_t type)
s_cb_item.rssi = packet->rx_ctrl.rssi;
// 0 timeout: never block the WiFi driver's own task waiting for queue space. xQueueSend copies
- // the struct out before returning, so reusing s_cb_item on the next callback is fine.
- xQueueSend(s_rx_queue, &s_cb_item, 0);
+ // the struct out before returning, so reusing s_cb_item on the next callback is fine. The
+ // return value used to go unchecked, so a full queue (rx_forward_task still draining a
+ // previous burst) silently ate frames with no counter anywhere - see
+ // serial_link_note_rx_queue_drop()'s KDoc.
+ if (xQueueSend(s_rx_queue, &s_cb_item, 0) != pdTRUE) {
+ serial_link_note_rx_queue_drop();
+ }
}
static void rx_forward_task(void *arg)
diff --git a/obu-firmware/main/serial_link.c b/obu-firmware/main/serial_link.c
index 1723ee6..36bab7d 100644
--- a/obu-firmware/main/serial_link.c
+++ b/obu-firmware/main/serial_link.c
@@ -21,6 +21,7 @@ static serial_link_cam_tx_pv_cb_t s_on_cam_tx_pv;
static uint16_t s_oversize_drops;
static uint16_t s_tx_failures;
static uint16_t s_rx_crc_errors;
+static uint16_t s_rx_queue_drops;
// Serializes send_frame(): it writes a frame as four separate usb_serial_jtag_write_bytes() calls
// and shares one static CRC scratch buffer, and it's now called from three tasks (rx_forward for
@@ -45,6 +46,12 @@ void serial_link_note_oversize_drop(uint16_t btp_dest_port)
btp_dest_port, s_oversize_drops);
}
+void serial_link_note_rx_queue_drop(void)
+{
+ bump(&s_rx_queue_drops);
+ ESP_LOGW(TAG, "rx queue full, dropped a captured frame, total rx queue drops %u", s_rx_queue_drops);
+}
+
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
@@ -165,9 +172,10 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
bool serial_link_send_status(uint8_t status)
{
- // [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1] -
- // keep in lockstep with EspLinkStatus.parse() in the app's SerialFrame.kt.
- uint8_t payload[8];
+ // [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
+ // [rx_queue_drops:2 LE] - keep in lockstep with EspLinkStatus.parse() in the app's
+ // SerialFrame.kt.
+ uint8_t payload[10];
payload[0] = status;
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
@@ -178,6 +186,8 @@ bool serial_link_send_status(uint8_t status)
// What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which
// is what lets a new app keep working against firmware that predates that message.
payload[7] = SERIAL_CAP_CAM_TX_PV;
+ payload[8] = (uint8_t)(s_rx_queue_drops & 0xFF);
+ payload[9] = (uint8_t)((s_rx_queue_drops >> 8) & 0xFF);
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
}
diff --git a/obu-firmware/main/serial_link.h b/obu-firmware/main/serial_link.h
index 2b58385..505ea84 100644
--- a/obu-firmware/main/serial_link.h
+++ b/obu-firmware/main/serial_link.h
@@ -52,11 +52,14 @@
// message's own ItsPduHeader.stationID is the meaningful identifier.
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
-// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 8 bytes:
+// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 10 bytes:
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
+// [rx_queue_drops:2 LE]
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
-// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected.
+// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected;
+// rx_queue_drops (bytes 8-9) follows the same rule for an app that predates it. Either side
+// reading a payload shorter than the field it wants should treat that field as 0, not error.
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
// in the app's SerialFrame.kt.
@@ -165,4 +168,13 @@ void serial_link_note_tx_failure(void);
// whose capture buffer is smaller than the largest frames on air.
void serial_link_note_oversize_drop(uint16_t btp_dest_port);
+// Counts a promiscuously-captured frame that main.c's wifi_promisc_rx_cb() could not hand to
+// rx_forward_task because s_rx_queue was full - i.e. frames arrived faster than the forward task
+// (gn_unwrap + a blocking USB write, up to SERIAL_LINK_WRITE_TIMEOUT_MS x 4 per frame under
+// contention) could drain them. Unlike oversize_drop this is not about one frame's size; it is
+// about a burst of otherwise-forwardable frames. Previously silent - xQueueSend's return value
+// was not even checked - so a run of these had no visible symptom beyond "that station's CAM
+// count looked a little low."
+void serial_link_note_rx_queue_drop(void);
+
#endif