From 7285fa19b7631370bb20ed29caf8b581e78667c8 Mon Sep 17 00:00:00 2001 From: Ashin Walpola Date: Tue, 22 Sep 2026 14:41:53 +0200 Subject: [PATCH] Count and surface RX-queue drops on the ESP32-C5's promiscuous path wifi_promisc_rx_cb() fed s_rx_queue with a 0-timeout xQueueSend() and never checked whether it succeeded, so a burst of captured frames arriving faster than rx_forward_task could drain them vanished with no counter anywhere - none of oversizeDrops/txFailures/rxCrcErrors caught it. Added a rxQueueDrops counter, threaded it through the STATUS heartbeat as a new trailing uint16 (old firmware/app on either side still parse fine), and surfaced it on the CAM Pinger card. Confirmed on the bench: flashed to the production OBU (COM3) and installed the matching app build on the phone, then watched the counter over logcat against obu-cam-transmistter's ~3.3 Hz beacon - it is real (0 -> 89 -> 90 across two sessions) but bursty around connect/reconnect rather than a continuous overflow under steady single-station traffic. --- TODO.md | 138 ++++++++++++++++++ .../micr0bu/data/transport/SerialFrame.kt | 15 +- .../data/transport/UsbSerialTransport.kt | 6 +- .../ui/screens/MqttTopicViewerScreen.kt | 7 +- app/src/main/res/values/strings.xml | 2 +- .../hawhamburg/micr0bu/CamTxPvSerialTest.kt | 15 ++ obu-firmware/main/main.c | 9 +- obu-firmware/main/serial_link.c | 16 +- obu-firmware/main/serial_link.h | 16 +- 9 files changed, 209 insertions(+), 15 deletions(-) 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