Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d4477f1e | ||
|
|
277c6b20f4 |
@@ -46,3 +46,6 @@ sdkconfig.old
|
||||
# Office lock files. Word/Excel create these beside a document while it is open
|
||||
# and remove them on close, so they are transient and machine-local.
|
||||
~$*
|
||||
|
||||
# Captures are large data files, not source (see capture/README.md).
|
||||
/capture/recordings/
|
||||
|
||||
@@ -5,148 +5,44 @@ 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
|
||||
(`V2xMapScreen`, route `v2x_map`), reached from the map button in that screen's header. Markers are
|
||||
now cached and reused across updates instead of being rebuilt on every incoming message, and
|
||||
SPATEM intersections are drawn as traffic lights at the position of the RSU's own CAM. All of that
|
||||
compiles and the unit tests pass, but none of it has been seen with live traffic.
|
||||
|
||||
Needs: the phone with the app, plus a CAM/DENM/SPATEM source - either the CiT One, or the OBU
|
||||
ESP32-C5 with a second board or a real RSU transmitting.
|
||||
|
||||
- [ ] Both hardware modes: tap the map button, confirm the map fills the screen (no status bar, no
|
||||
bottom nav) and the back button returns to the V2X Monitor.
|
||||
- [ ] Panning stays smooth while CAMs are arriving - this is what the marker reuse is for. Compare
|
||||
against the old behaviour if it still judders.
|
||||
- [ ] Touching the map stops it recentring; the location FAB resumes follow and lights up.
|
||||
- [ ] A DENM shows the warning triangle, and a SPATEM intersection shows a traffic light with the
|
||||
lamp matching the Dashboard's SignalCard for the same intersection.
|
||||
- [ ] Near a real RSU: confirm the RSU is drawn once, as a traffic light, not as a CAM pin with a
|
||||
light on top of it. If the RSU sends SPATEM but no CAM, the "signals not shown" note should
|
||||
appear instead - worth knowing which of the two the HAW RSUs actually do.
|
||||
|
||||
|
||||
### Over-the-air check of the GN lifetime fix (added 2026-09-11)
|
||||
|
||||
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
|
||||
in both `obu-firmware` and `obu-cam-transmistter`. Both still build (IDF 6.1 / 5.5.4), and the
|
||||
compiled `geonet_wrap_shb` stores the new byte, but it has not been seen on air yet. Nothing else
|
||||
compiled `geonet_wrap_shb` stores the new byte. Confirmed on air 2026-09-14. Nothing else
|
||||
reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not
|
||||
need updating alongside the firmware.
|
||||
|
||||
Needs: the phone with the app, the OBU ESP32-C5, and a **second** ESP32-C5 running
|
||||
`its-g5-receiver-firmware` to capture with.
|
||||
|
||||
- [ ] Flash `obu-firmware` (see `obu-firmware/FLASHING.md`).
|
||||
- [ ] Connect the phone, let it send CAMs, and confirm the CAM Pinger's `tx fail` counter stays 0.
|
||||
- [ ] Capture with the receiver into `its-g5-receiver-firmware/recordings/`.
|
||||
- [ ] Run `python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/<capture>.pcap`.
|
||||
The rows for the phone's pseudonym MACs must show SHB, port 2001, lifetime `0x05`, exactly
|
||||
like every other station's CAMs.
|
||||
- [ ] While the phone is connected: real-station CAMs/DENMs still reach the app (RX path unchanged).
|
||||
- [x] Flash `obu-firmware` (done 2026-09-14 on COM3; flash backed up first to
|
||||
`Documents/micrOBU_workspace/firmware-backups/COM3-2026-09-14-before-secured-rx.bin`).
|
||||
- [x] Capture with the receiver (COM8) into `its-g5-receiver-firmware/recordings/`.
|
||||
- [x] `pcap_gn_tally.py` on capture_20260914_132126.pcap: our station sends SHB, port 2001,
|
||||
lifetime `0x05`, same as both bench stations. It was `0x83` in the August captures.
|
||||
- [x] Real-station CAMs/DENMs/SPATEM still reach the app (logcat: `handleCamUper`,
|
||||
`handleDenmUper`, `handleSpatUper` all decoding, 2026-09-14).
|
||||
- [x] Our own CAMs decode on air: 397 frames from station 999999 decode with asn1tools and
|
||||
re-encode byte-identically.
|
||||
- [ ] Confirm the CAM Pinger card's `tx fail` / oversize / CRC counters are 0 (needs a look at the
|
||||
phone; not readable from the PC).
|
||||
|
||||
Partial check possible with one board and no phone: flash it, `idf.py -p COMx monitor`, and look
|
||||
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
|
||||
4 bits / unavailable(8), the bug the app fixed on 2026-08-20. Fixed in it and in obu-firmware's
|
||||
reference copy; asn1tools now decodes the CAM and re-encodes it byte-identically, and it builds on
|
||||
IDF 5.5.4. No board runs this firmware right now (the production OBU runs obu-firmware), so this
|
||||
only matters if it is flashed again:
|
||||
IDF 5.5.4. Since 2026-09-14 the spare board on COM10 runs it as a bench beacon:
|
||||
|
||||
- [ ] After flashing it: capture, run `pcap_gn_tally.py`, and decode the CAM payload with
|
||||
asn1tools (`py -3.11`, modules in `asn1/`).
|
||||
- [x] Done 2026-09-14: flashed on COM10 and captured on COM8. All 72 CAMs from station
|
||||
195936478 (0x0BADC0DE) decode with asn1tools and re-encode byte-identically, so the
|
||||
4-bit yawRateConfidence is right on air. COM10 now runs this beacon rather than
|
||||
obu-firmware - reflash it if the spare is needed as an OBU again.
|
||||
|
||||
### Signed-message reception and exact payloads (added 2026-09-11)
|
||||
|
||||
@@ -154,69 +50,21 @@ obu-firmware's `gn_unwrap.c` now unwraps TS 103 097 signed packets (signature no
|
||||
reported as V2X_RX flags bit1) and cuts every message to the length its header declares, dropping
|
||||
the 8 bytes the chip's RX appends to each frame, which were forwarded to the phone until now.
|
||||
Verified on the host (`obu-firmware/test/host`: chain, replay of all recordings against asn1tools,
|
||||
50M-iteration fuzz) and built on IDF 6.1, but not flashed: the production OBU still runs the
|
||||
2026-09-10 build. Needs the OBU with this build, the phone, and signed traffic - real vehicles or
|
||||
50M-iteration fuzz) and flashed to the production OBU on 2026-09-14. The remaining gap is signed
|
||||
traffic to receive: real vehicles or
|
||||
RSUs, since the bench CiT One sends unsigned. A second ESP32 running the receiver firmware is
|
||||
optional, but shows what was on air at the time.
|
||||
|
||||
- [ ] Flash obu-firmware (this also carries the GN lifetime fix above).
|
||||
- [x] Flash obu-firmware (done 2026-09-14, COM3).
|
||||
- [x] Unsigned bench traffic still decodes in the app, with messages now cut to their declared
|
||||
length (CAM, DENM and SPATEM all decoding in logcat after the flash).
|
||||
- [ ] Near signed traffic: signed CAMs/DENMs appear in the app, and a simultaneous capture shows
|
||||
them on air (`pcap_gn_tally.py` lists them as `secured`).
|
||||
- [ ] Unsigned bench traffic still decodes in the app as before (messages now arrive 8 bytes
|
||||
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.
|
||||
them on air (`pcap_gn_tally.py` lists them as `secured`). NOT possible at this bench: the
|
||||
CiT One transmits unsigned (`ItsGnSecurity = 0`) and nothing else here signs. Needs a drive
|
||||
past real RSUs, the CiT One switched to signed mode if its API allows, or a replay firmware
|
||||
on a spare board that re-transmits the recorded signed frames.
|
||||
- [ ] The heartbeat's oversize counter still counts over-long messages. Not exercised at the
|
||||
bench: the SPATEMs here are ~340 bytes on air, far below the cap.
|
||||
|
||||
## Set up host testing
|
||||
|
||||
@@ -253,6 +101,19 @@ Suggested order after the host tests exist:
|
||||
Dropped: building vanetza as a GN/BTP oracle. Real captures (`pcap_gn_tally.py`), the host
|
||||
round-trip test and `asn1tools` for UPER cover what it would have checked.
|
||||
|
||||
## Follow-ups found 2026-09-14
|
||||
|
||||
- [x] **Capture tooling moved into this repo** (`capture/`), with the CR-insertion fix. The
|
||||
sniffer's console inserts a CR before every LF, which also hits every 0x0a byte of the binary
|
||||
pcap stream, shifting pcap record headers and frames. A 787 KB capture parsed cleanly for
|
||||
only 82 of ~2000 records, and DENMs showed up on nonsense BTP ports. `undo_crlf()` reverses
|
||||
it on the raw stream before framing; afterwards a capture parsed to EOF and DENMs read as
|
||||
port 2002. **Every capture taken before 2026-09-14 is truncated at its first corrupted
|
||||
record** - re-measure anything derived from them.
|
||||
- [ ] `capture/dump_pcap.py` reads the same console and still needs the same treatment.
|
||||
- [ ] **Do not open COM3's console while the phone is attached.** Opening it toggles DTR/RTS on the
|
||||
CH343 and resets the OBU, which drops the phone's USB link and needs a manual Connect.
|
||||
|
||||
## Follow-ups found 2026-09-11
|
||||
|
||||
- [ ] **App: show the signed flag.** `V2xRxFrame.parse` in `SerialFrame.kt` only reads bit0 of
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
@@ -41,7 +40,6 @@ import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.SensorScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.V2xMapScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.MapScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
|
||||
@@ -106,13 +104,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
val navController = rememberNavController()
|
||||
|
||||
// The V2X live map is a full-bleed destination: the app's own chrome would eat a
|
||||
// third of the display on the one screen whose entire job is showing where things
|
||||
// are relative to each other. It carries its own floating back button, and system
|
||||
// back still works, so nothing becomes unreachable.
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val isFullBleed = currentBackStackEntry?.destination?.route == Screen.V2xMap.route
|
||||
|
||||
val locationLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
@@ -141,16 +132,14 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!isFullBleed) {
|
||||
StatusTopBar(
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||
usbSerialState = usbSerialState,
|
||||
)
|
||||
}
|
||||
StatusTopBar(
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||
usbSerialState = usbSerialState,
|
||||
)
|
||||
},
|
||||
bottomBar = { if (!isFullBleed) BottomNavBar(navController) },
|
||||
bottomBar = { BottomNavBar(navController) },
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
@@ -264,19 +253,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
composable(Screen.MqttViewer.route) {
|
||||
MqttTopicViewerScreen(
|
||||
viewModel = mqttViewModel,
|
||||
onOpenMap = { navController.navigate(Screen.V2xMap.route) },
|
||||
)
|
||||
}
|
||||
composable(Screen.V2xMap.route) {
|
||||
// Activity-scoped instance, like Connection below: a hiltViewModel()
|
||||
// here would be scoped to this NavBackStackEntry and torn down on the
|
||||
// way back out, taking the shared transport with it.
|
||||
V2xMapScreen(
|
||||
viewModel = mqttViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
MqttTopicViewerScreen(viewModel = mqttViewModel)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
|
||||
@@ -57,9 +57,8 @@ const val SERIAL_LINK_MAX_PAYLOAD = 512
|
||||
|
||||
/**
|
||||
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
|
||||
* [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.
|
||||
* [rxCrcErrors:2 LE]` (7 bytes). 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
|
||||
@@ -80,15 +79,6 @@ 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
|
||||
@@ -109,7 +99,6 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,15 +330,13 @@ class UsbSerialTransport @Inject constructor(
|
||||
prev.txFailures != status.txFailures ||
|
||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||
prev.status != status.status ||
|
||||
prev.capabilities != status.capabilities ||
|
||||
prev.rxQueueDrops != status.rxQueueDrops
|
||||
prev.capabilities != status.capabilities
|
||||
) {
|
||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||
"oversizeDrops=${status.oversizeDrops} " +
|
||||
"txFailures=${status.txFailures} " +
|
||||
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||
"capabilities=${status.capabilities} " +
|
||||
"rxQueueDrops=${status.rxQueueDrops}")
|
||||
"capabilities=${status.capabilities}")
|
||||
}
|
||||
_linkStatus.value = status
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ sealed class Screen(val route: String, val labelRes: Int) {
|
||||
data object Connection : Screen("connection", R.string.nav_connection)
|
||||
data object Map : Screen("map", R.string.map_title)
|
||||
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
|
||||
/** Full-screen V2X live map, opened from the V2X Monitor's map button. */
|
||||
data object V2xMap : Screen("v2x_map", R.string.v2x_map_title)
|
||||
|
||||
// Phase A — Trip Recording
|
||||
data object TripHistory : Screen("trip_history", R.string.nav_trips)
|
||||
@@ -85,7 +83,6 @@ private fun Screen.ownsRoute(route: String?): Boolean {
|
||||
route == Screen.Map.route ||
|
||||
route == Screen.Sensors.route
|
||||
Screen.Record -> route == Screen.Log.route
|
||||
Screen.MqttViewer -> route == Screen.V2xMap.route
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.LinkOff
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.VerticalAlignBottom
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
@@ -88,15 +87,11 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST) or the raw MQTT topic list
|
||||
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path).
|
||||
*
|
||||
* The live map used to be a third mode here. It is now its own full-screen destination
|
||||
* ([V2xMapScreen]), reached from the map button in this screen's header: sharing the screen with
|
||||
* the alert panel and the TX cards left the map about a third of a phone display tall, which is
|
||||
* not enough to see where anything is relative to anything else.
|
||||
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST), the raw MQTT topic list
|
||||
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path), or the V2X live map
|
||||
* (MAP, Section 13).
|
||||
*/
|
||||
private enum class TopicViewMode { LIST, TOPICS }
|
||||
private enum class TopicViewMode { LIST, TOPICS, MAP }
|
||||
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
@@ -121,7 +116,6 @@ private val WarningRedBg = Color(0xFF3A0A0A)
|
||||
@Composable
|
||||
fun MqttTopicViewerScreen(
|
||||
viewModel: MqttViewModel = hiltViewModel(),
|
||||
onOpenMap: () -> Unit = {},
|
||||
) {
|
||||
val connectionState by viewModel.connectionState.collectAsState()
|
||||
val topicMessages by viewModel.topicMessages.collectAsState()
|
||||
@@ -199,17 +193,6 @@ fun MqttTopicViewerScreen(
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// Full-screen live map. In the header rather than in the view-mode row below, so it
|
||||
// is reachable from the message detail pane too and does not move around as the
|
||||
// available view modes change with the selected hardware.
|
||||
IconButton(onClick = onOpenMap) {
|
||||
Icon(
|
||||
Icons.Default.Map,
|
||||
contentDescription = stringResource(R.string.v2x_map_title),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
ConnectionChip(effectiveState)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
IconButton(
|
||||
@@ -352,38 +335,49 @@ private fun TopicListPane(
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
|
||||
// ── List / Topics toggle ──────────────────────────────────────────────
|
||||
// ── List / Topics / Map toggle ────────────────────────────────────────────────────
|
||||
// Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
|
||||
// the road users and hazards, not the transport that carried them. The raw MQTT topic
|
||||
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
|
||||
// to", not instead of, the topic list).
|
||||
//
|
||||
// The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is
|
||||
// permanently empty, and a toggle offering a single choice is just noise.
|
||||
if (!isEsp32) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_list),
|
||||
selected = viewMode == TopicViewMode.LIST,
|
||||
) { viewMode = TopicViewMode.LIST }
|
||||
// to", not instead of, the topic list). It is hidden on the ESP32-C5 path, where there is
|
||||
// no broker and `topics` is permanently empty.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_list),
|
||||
selected = viewMode == TopicViewMode.LIST,
|
||||
) { viewMode = TopicViewMode.LIST }
|
||||
|
||||
if (!isEsp32) {
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_topics),
|
||||
selected = viewMode == TopicViewMode.TOPICS,
|
||||
) { viewMode = TopicViewMode.TOPICS }
|
||||
}
|
||||
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_map),
|
||||
selected = viewMode == TopicViewMode.MAP,
|
||||
) { viewMode = TopicViewMode.MAP }
|
||||
}
|
||||
|
||||
// ── Decoded traffic / raw topics ──────────────────────────────────────
|
||||
// ── Decoded traffic / raw topics / live map ───────────────────────────
|
||||
// TOPICS can still be the saved selection from a CiT One session after switching hardware
|
||||
// to the ESP32-C5, where that button no longer exists - fall back to the decoded list
|
||||
// rather than stranding the user on a pane they can't navigate away from.
|
||||
val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
|
||||
|
||||
if (shownMode == TopicViewMode.LIST) {
|
||||
if (shownMode == TopicViewMode.MAP) {
|
||||
V2xLiveMapView(
|
||||
own = ownCamPosition,
|
||||
remotes = remoteCamPositions,
|
||||
alerts = useCaseAlerts,
|
||||
denms = denmEvents,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else if (shownMode == TopicViewMode.LIST) {
|
||||
ReceivedCamPane(
|
||||
own = ownCamPosition,
|
||||
remotes = remoteCamPositions,
|
||||
@@ -1219,12 +1213,11 @@ private fun CamPingerCard(
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.mqtt_cam_pinger_fw_counters,
|
||||
s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.rxQueueDrops,
|
||||
s.txFailures, s.oversizeDrops, s.rxCrcErrors,
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0 ||
|
||||
s.rxQueueDrops > 0
|
||||
) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0)
|
||||
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.hawhamburg.micr0bu.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.GpsOff
|
||||
@@ -31,37 +30,25 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
|
||||
/**
|
||||
* V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the
|
||||
* ego bike's own position, every currently-tracked remote road user's last-known CAM position,
|
||||
* every live hazard (DENM) and every signalised intersection heard over SPATEM.
|
||||
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus
|
||||
* every currently-tracked remote road user's last-known CAM position, in addition to (not
|
||||
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as
|
||||
* [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here
|
||||
* always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]
|
||||
* currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc).
|
||||
*
|
||||
* Marker vocabulary, one shape per message type so the map reads without a legend:
|
||||
* - CAM — teardrop pin, tinted by that station's most severe active alert level
|
||||
* - DENM — hazard warning triangle
|
||||
* - SPATEM — traffic light, with the lamp for the intersection's leading phase lit
|
||||
*
|
||||
* Unlike [MapScreen] this has no phone-GNSS-only fallback: [own] always reflects whichever ego
|
||||
* source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] currently trusts (obu_gnss /
|
||||
* phone GNSS / CAM-topic-own — see that class's KDoc).
|
||||
*
|
||||
* **Markers are reused across updates, not rebuilt.** CAMs arrive at up to 10 Hz per station, and
|
||||
* every arrival recomposes this view; the previous version cleared the overlay list and rebuilt
|
||||
* every Marker — decoding and mutating a fresh Drawable per marker per update — which is what
|
||||
* made panning stutter under live traffic. Drawables are now loaded once per level/phase and
|
||||
* shared (osmdroid sets the icon's bounds on each draw, so sharing one instance across markers is
|
||||
* safe), and Marker objects are cached by key. The overlay list is still reordered each update,
|
||||
* which costs nothing: it moves existing references, it does not allocate.
|
||||
* Remote markers are colored by that station's most severe active alert level, if any, so a
|
||||
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same
|
||||
* severity coloring already used by [UseCaseAlertPanel].
|
||||
*/
|
||||
@Composable
|
||||
fun V2xLiveMapView(
|
||||
@@ -69,9 +56,6 @@ fun V2xLiveMapView(
|
||||
remotes: Map<Long, Cam>,
|
||||
alerts: List<UseCaseAlert>,
|
||||
denms: List<DenmEvent> = emptyList(),
|
||||
spats: List<SpatIntersection> = emptyList(),
|
||||
followOwn: Boolean = true,
|
||||
onUserPanned: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -87,17 +71,8 @@ fun V2xLiveMapView(
|
||||
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
|
||||
}
|
||||
|
||||
// Loaded once and shared by every marker that needs them. mutate() on the remote pin is still
|
||||
// essential: without it all four tinted copies would share one ConstantState and the last
|
||||
// tint applied would recolour every pin on the map.
|
||||
val icons = remember(context) { MapIcons(context) }
|
||||
val markers = remember { mutableMapOf<String, Marker>() }
|
||||
|
||||
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// Tracks whether the last update already recentred for this follow session, so re-enabling
|
||||
// follow animates once instead of fighting the rider's own panning on every frame.
|
||||
val wasFollowing = remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
@@ -114,236 +89,106 @@ fun V2xLiveMapView(
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
initOsmForV2xMap(ctx)
|
||||
MapView(ctx).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
// Raster tiles are authored for ~160 dpi; without this they are upscaled by the
|
||||
// display density and labels come out soft on a modern phone.
|
||||
isTilesScaledToDpi = true
|
||||
// The floating +/- buttons sit exactly where the rider's thumb lands and
|
||||
// duplicate pinch-zoom. Pinch and double-tap still work.
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
setMinZoomLevel(4.0)
|
||||
setMaxZoomLevel(20.0)
|
||||
controller.setZoom(17.0)
|
||||
controller.setCenter(ownGeoPoint)
|
||||
// Any touch means the rider is driving the map; follow-own hands over to them
|
||||
// until they ask for it back. false: the MapView's own gesture handling still
|
||||
// runs, this only observes.
|
||||
setOnTouchListener { _, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) onUserPanned()
|
||||
false
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
initOsmForV2xMap(ctx)
|
||||
MapView(ctx).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
controller.setZoom(17.0)
|
||||
controller.setCenter(ownGeoPoint)
|
||||
mapViewRef.value = this
|
||||
}
|
||||
mapViewRef.value = this
|
||||
}
|
||||
},
|
||||
update = { mv ->
|
||||
val now = System.currentTimeMillis()
|
||||
},
|
||||
update = { mv ->
|
||||
mv.overlays.clear()
|
||||
|
||||
// Intersections we can actually place: SPATEM carries signal state but no geometry
|
||||
// (that is MAPEM's job), so the only position available is the sending RSU's own CAM.
|
||||
val locatedSpats = spats.mapNotNull { spat ->
|
||||
remotes[spat.stationId]?.let { rsu -> spat to rsu }
|
||||
}
|
||||
// An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it:
|
||||
// two markers on one point, the lower one unreachable.
|
||||
val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet()
|
||||
|
||||
val live = mutableSetOf<String>()
|
||||
|
||||
// Own position: a centred "you are here" dot, not a pin. Own position is a fact about
|
||||
// the viewer rather than one of the tracked objects, and when both used osmdroid's
|
||||
// identical default pin the two were indistinguishable at a glance.
|
||||
markers.marker(mv, KEY_OWN, live).apply {
|
||||
position = ownGeoPoint
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = icons.own
|
||||
title = context.getString(R.string.v2x_map_own_label)
|
||||
}
|
||||
|
||||
remotes.forEach { (stationId, cam) ->
|
||||
if (stationId in spatStationIds) return@forEach
|
||||
val level = alertByStation[stationId]
|
||||
val label = when (level) {
|
||||
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
||||
AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId)
|
||||
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
||||
}
|
||||
// Teardrop pin anchored at its tip, tinted by severity, so severity no longer
|
||||
// depends on tapping the marker to read its label.
|
||||
markers.marker(mv, "$KEY_CAM$stationId", live).apply {
|
||||
position = GeoPoint(cam.latitude, cam.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = icons.remotePin(level)
|
||||
title = label
|
||||
}
|
||||
}
|
||||
|
||||
// Hazards and signals are added after the vehicle pins, so they draw on top: a hazard
|
||||
// hidden behind a CAM pin defeats the point of showing it.
|
||||
denms.forEach { denm ->
|
||||
markers.marker(mv, "$KEY_DENM${denm.dedupKey}", live).apply {
|
||||
position = GeoPoint(denm.latitude, denm.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = icons.denm
|
||||
title = denm.causeCode?.let {
|
||||
context.getString(
|
||||
R.string.v2x_map_denm_labeled,
|
||||
it,
|
||||
denm.subCauseCode ?: 0,
|
||||
denm.stationId,
|
||||
)
|
||||
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
||||
}
|
||||
}
|
||||
|
||||
locatedSpats.forEach { (spat, rsu) ->
|
||||
val phase = spat.leadingPhase(now)
|
||||
markers.marker(mv, "$KEY_SPAT${spat.key}", live).apply {
|
||||
position = GeoPoint(rsu.latitude, rsu.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = icons.signal(phase)
|
||||
title = context.getString(
|
||||
R.string.v2x_spat_rx_title, spat.state.key, spat.stationId,
|
||||
)
|
||||
snippet = spat.state.movements.joinToString(" · ") { movement ->
|
||||
val seconds = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
context.getString(R.string.v2x_spat_group, movement.signalGroup) +
|
||||
(seconds?.let { " " + context.getString(R.string.v2x_spat_countdown, it) } ?: "")
|
||||
// Own position: a centred "you are here" dot, not a pin. Own position is a fact
|
||||
// about the viewer rather than one of the tracked objects, and when both used
|
||||
// osmdroid's identical default pin the two were indistinguishable at a glance.
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = ownGeoPoint
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||
title = context.getString(R.string.v2x_map_own_label)
|
||||
}
|
||||
)
|
||||
|
||||
remotes.forEach { (stationId, cam) ->
|
||||
val level = alertByStation[stationId]
|
||||
val label = when (level) {
|
||||
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
||||
AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId)
|
||||
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
||||
}
|
||||
// Teardrop pin anchored at its tip, tinted by severity. Now that these are
|
||||
// custom drawables, per-instance tinting is possible - severity no longer
|
||||
// depends on tapping the marker to read its label. mutate() is essential:
|
||||
// without it every marker shares one ConstantState and the last tint applied
|
||||
// would recolour all of them.
|
||||
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
||||
?.mutate()
|
||||
?.apply { setTint(level.toMarkerColor()) }
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = GeoPoint(cam.latitude, cam.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = pin
|
||||
title = label
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop markers for stations, hazards and intersections that have expired, then rebuild
|
||||
// the overlay list in draw order from the cached Markers. Reordering moves references;
|
||||
// nothing here allocates a Marker or decodes a Drawable.
|
||||
markers.keys.retainAll { key ->
|
||||
(key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() }
|
||||
}
|
||||
mv.overlays.clear()
|
||||
markers.entries
|
||||
.sortedBy { (key, _) -> key.drawOrder() }
|
||||
.forEach { (_, marker) -> mv.overlays.add(marker) }
|
||||
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard
|
||||
// hidden behind a CAM pin defeats the point of showing it.
|
||||
denms.forEach { denm ->
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = GeoPoint(denm.latitude, denm.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||
title = denm.causeCode?.let {
|
||||
context.getString(
|
||||
R.string.v2x_map_denm_labeled,
|
||||
it,
|
||||
denm.subCauseCode ?: 0,
|
||||
denm.stationId,
|
||||
)
|
||||
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// setCenter, not animateTo: an animation restarted on every CAM never finishes, which
|
||||
// is exactly the judder this used to show under live traffic. The one animated move is
|
||||
// the rider re-enabling follow, where the travel is worth seeing.
|
||||
if (followOwn) {
|
||||
if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint)
|
||||
else mv.controller.animateTo(ownGeoPoint)
|
||||
}
|
||||
wasFollowing.value = followOwn
|
||||
|
||||
mv.invalidate()
|
||||
},
|
||||
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
||||
// MapViewRepository holds a null MapView, so constructing a Marker against it throws
|
||||
// NullPointerException from deep inside InfoWindow's constructor.
|
||||
//
|
||||
// This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect
|
||||
// is keyed on the lifecycle owner and disposes independently of this AndroidView, so
|
||||
// the update block above could still run against an already-detached MapView and
|
||||
// rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving,
|
||||
// because every incoming message recomposes this view and there are far more updates
|
||||
// to land in that window than there used to be.
|
||||
//
|
||||
// onRelease is the callback that actually means "this View is gone": Compose
|
||||
// guarantees no further update after it.
|
||||
onRelease = {
|
||||
markers.clear()
|
||||
it.onDetach()
|
||||
},
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Marker cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
private const val KEY_OWN = "own"
|
||||
private const val KEY_CAM = "cam:"
|
||||
private const val KEY_DENM = "denm:"
|
||||
private const val KEY_SPAT = "spat:"
|
||||
|
||||
/** Draw order: own dot at the bottom, then vehicles, with hazards and signals on top. */
|
||||
private fun String.drawOrder(): Int = when {
|
||||
this == KEY_OWN -> 0
|
||||
startsWith(KEY_CAM) -> 1
|
||||
startsWith(KEY_DENM) -> 2
|
||||
else -> 3
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached [Marker] for [key], created against [mv] on first use, recording the key in [live]
|
||||
* so the caller can drop whatever it did not ask for this update.
|
||||
*/
|
||||
private fun MutableMap<String, Marker>.marker(
|
||||
mv: MapView,
|
||||
key: String,
|
||||
live: MutableSet<String>,
|
||||
): Marker {
|
||||
live += key
|
||||
return getOrPut(key) { Marker(mv) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker artwork, loaded once per composition rather than per update.
|
||||
*
|
||||
* The remote pin is drawn white and tinted per severity here; [mutate] is what keeps the four
|
||||
* tinted copies independent, since without it they would share one ConstantState and the last
|
||||
* tint applied would recolour all of them.
|
||||
*/
|
||||
private class MapIcons(context: Context) {
|
||||
val own: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||
val denm: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||
|
||||
private val pins: Map<AlertLevel?, Drawable?> =
|
||||
(listOf(null) + AlertLevel.entries).associateWith { level ->
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
||||
?.mutate()
|
||||
?.apply { setTint(level.toMarkerColor()) }
|
||||
}
|
||||
|
||||
private val signals: Map<SignalLamp, Drawable?> = SignalLamp.entries.associateWith { lamp ->
|
||||
ContextCompat.getDrawable(context, lamp.drawableRes)
|
||||
}
|
||||
|
||||
fun remotePin(level: AlertLevel?): Drawable? = pins[level]
|
||||
fun signal(lamp: SignalLamp): Drawable? = signals[lamp]
|
||||
}
|
||||
|
||||
// ── Signal phase → lamp ───────────────────────────────────────────────────────
|
||||
|
||||
/** Which lamp of the traffic-light marker is lit. */
|
||||
private enum class SignalLamp(val drawableRes: Int) {
|
||||
RED(R.drawable.ic_map_spat_red),
|
||||
AMBER(R.drawable.ic_map_spat_amber),
|
||||
GREEN(R.drawable.ic_map_spat_green),
|
||||
DARK(R.drawable.ic_map_spat_dark),
|
||||
}
|
||||
|
||||
/**
|
||||
* The lamp to light for this intersection.
|
||||
*
|
||||
* Without MAPEM there is no lane geometry, so there is no way to know which of an intersection's
|
||||
* signal groups applies to the rider's own approach. This follows the rule the Dashboard's
|
||||
* SignalCard already uses — the group changing soonest speaks for the intersection — so the same
|
||||
* intersection reads the same way in both places rather than inventing a second convention.
|
||||
*/
|
||||
private fun SpatIntersection.leadingPhase(nowMs: Long): SignalLamp {
|
||||
val leading = state.movements.minByOrNull { movement ->
|
||||
movement.current?.secondsUntil(nowMs)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
|
||||
}
|
||||
val phase = leading?.current?.phase
|
||||
return when {
|
||||
phase == null -> SignalLamp.DARK
|
||||
phase.isGo -> SignalLamp.GREEN
|
||||
phase.isStop -> SignalLamp.RED
|
||||
phase.isTransition -> SignalLamp.AMBER
|
||||
else -> SignalLamp.DARK // UNAVAILABLE / DARK / caution
|
||||
mv.controller.animateTo(ownGeoPoint)
|
||||
mv.invalidate()
|
||||
},
|
||||
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
||||
// MapViewRepository holds a null MapView, so constructing a Marker against it throws
|
||||
// NullPointerException from deep inside InfoWindow's constructor.
|
||||
//
|
||||
// This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect
|
||||
// is keyed on the lifecycle owner and disposes independently of this AndroidView, so
|
||||
// the update block above could still run against an already-detached MapView and
|
||||
// rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving,
|
||||
// because every incoming message recomposes this view and there are far more updates
|
||||
// to land in that window than there used to be.
|
||||
//
|
||||
// onRelease is the callback that actually means "this View is gone": Compose
|
||||
// guarantees no further update after it.
|
||||
onRelease = { it.onDetach() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +228,5 @@ private fun initOsmForV2xMap(context: Context) {
|
||||
Configuration.getInstance().apply {
|
||||
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
|
||||
userAgentValue = context.packageName
|
||||
// Panning off the edge of the cache is what makes a raster map feel slow: the default
|
||||
// 600 MB cap is plenty, but the default 2 download threads are not when a pan exposes a
|
||||
// screenful of new tiles at once.
|
||||
tileDownloadThreads = 6.toShort()
|
||||
tileFileSystemThreads = 6.toShort()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
package com.hawhamburg.micr0bu.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Traffic
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||
|
||||
/**
|
||||
* Full-screen V2X live map — the map and nothing else, reached from the map button on the V2X
|
||||
* Monitor screen.
|
||||
*
|
||||
* Identical on both hardware paths. Everything drawn here comes from
|
||||
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which already merges the CiT One's MQTT
|
||||
* feed and the ESP32-C5's serial feed into one set of flows, so this screen never has to know
|
||||
* which OBU is connected.
|
||||
*
|
||||
* The chrome is deliberately minimal and floats over the map rather than boxing it in: a back
|
||||
* button, a live count per message type, and a recentre button. The counts double as the map's
|
||||
* legend — each one carries the same icon family as the marker it counts.
|
||||
*/
|
||||
@Composable
|
||||
fun V2xMapScreen(
|
||||
viewModel: MqttViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||
// Road users from the detection engine PLUS roadside units, which it deliberately does not
|
||||
// track - an RSU is what carries the traffic lights below.
|
||||
val stations by viewModel.stationsInRange.collectAsState()
|
||||
val alerts by viewModel.useCaseAlerts.collectAsState()
|
||||
val denms by viewModel.denmEvents.collectAsState()
|
||||
val spats by viewModel.spatIntersections.collectAsState()
|
||||
|
||||
// Follow is on until the rider touches the map, and comes back when they ask for it. Without
|
||||
// the hand-over, every incoming CAM would drag the viewport back to the ego position and the
|
||||
// map could not be panned at all while traffic is flowing.
|
||||
var followOwn by remember { mutableStateOf(true) }
|
||||
|
||||
// SPATEM carries no geometry of its own, so an intersection can only be placed if its RSU has
|
||||
// also been heard over CAM. Saying so is better than silently dropping it: "the map shows two
|
||||
// of the three lights I can see in the list" is otherwise an unexplained discrepancy.
|
||||
val unlocatedSpats = spats.count { it.stationId !in stations.keys }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
V2xLiveMapView(
|
||||
own = ownCamPosition,
|
||||
remotes = stations,
|
||||
alerts = alerts,
|
||||
denms = denms,
|
||||
spats = spats,
|
||||
followOwn = followOwn,
|
||||
onUserPanned = { followOwn = false },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
// ── Floating header: back + live counts, which double as the legend ──
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MapChrome {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.v2x_map_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
MapChrome {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MapCount(
|
||||
icon = Icons.Default.Place,
|
||||
tint = CamPinBlue,
|
||||
count = stations.size,
|
||||
label = stringResource(R.string.v2x_map_legend_cam),
|
||||
)
|
||||
MapCount(
|
||||
icon = Icons.Default.Warning,
|
||||
tint = HazardAmber,
|
||||
count = denms.size,
|
||||
label = stringResource(R.string.v2x_map_legend_denm),
|
||||
)
|
||||
MapCount(
|
||||
icon = Icons.Default.Traffic,
|
||||
tint = SignalGreenDot,
|
||||
count = spats.size,
|
||||
label = stringResource(R.string.v2x_map_legend_spat),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unlocatedSpats > 0) {
|
||||
MapChrome(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_map_spat_unlocated, unlocatedSpats),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recentre: lit while following, so the button also reports which mode the map is in.
|
||||
FloatingActionButton(
|
||||
onClick = { followOwn = true },
|
||||
containerColor = if (followOwn) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MyLocation,
|
||||
contentDescription = stringResource(R.string.v2x_map_follow),
|
||||
tint = if (followOwn) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A translucent pill for anything floating over the map, so chrome stays readable over tiles. */
|
||||
@Composable
|
||||
private fun MapChrome(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = modifier,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapCount(icon: ImageVector, tint: Color, count: Int, label: String) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Legend tints, matching the marker artwork rather than the theme: these name the drawables on
|
||||
// the map, so they must not shift with light/dark mode the way theme colours do.
|
||||
private val CamPinBlue = Color(0xFF78909C)
|
||||
private val HazardAmber = Color(0xFFFFC107)
|
||||
private val SignalGreenDot = Color(0xFF4CAF50)
|
||||
@@ -1,38 +0,0 @@
|
||||
<!--
|
||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||
|
||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||
|
||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||
than covering it.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||
|
||||
<!-- Housing. -->
|
||||
<path
|
||||
android:fillColor="#FF263238"
|
||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||
|
||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||
<path
|
||||
android:fillColor="#FF5A2220"
|
||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FFFFC107"
|
||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF1E4D2B"
|
||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -1,38 +0,0 @@
|
||||
<!--
|
||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||
|
||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||
|
||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||
than covering it.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||
|
||||
<!-- Housing. -->
|
||||
<path
|
||||
android:fillColor="#FF263238"
|
||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||
|
||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||
<path
|
||||
android:fillColor="#FF5A2220"
|
||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF4A2E1C"
|
||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF1E4D2B"
|
||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -1,38 +0,0 @@
|
||||
<!--
|
||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||
|
||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||
|
||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||
than covering it.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||
|
||||
<!-- Housing. -->
|
||||
<path
|
||||
android:fillColor="#FF263238"
|
||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||
|
||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||
<path
|
||||
android:fillColor="#FF5A2220"
|
||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF4A2E1C"
|
||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF4CAF50"
|
||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -1,38 +0,0 @@
|
||||
<!--
|
||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
||||
|
||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
||||
|
||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
||||
than covering it.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
||||
|
||||
<!-- Housing. -->
|
||||
<path
|
||||
android:fillColor="#FF263238"
|
||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
||||
|
||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
||||
<path
|
||||
android:fillColor="#FFFF5252"
|
||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF4A2E1C"
|
||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
<path
|
||||
android:fillColor="#FF1E4D2B"
|
||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
||||
</vector>
|
||||
@@ -142,18 +142,12 @@
|
||||
<string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
|
||||
<string name="map_title">Standortkarte</string>
|
||||
<string name="map_location_label">Aktueller Standort</string>
|
||||
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
|
||||
<string name="v2x_map_own_label">Eigen (Ego)</string>
|
||||
<string name="v2x_map_remote_plain">Extern #%1$d</string>
|
||||
<string name="v2x_map_remote_info">Extern #%1$d · Info</string>
|
||||
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
|
||||
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
|
||||
<string name="v2x_map_title">V2X-Live-Karte</string>
|
||||
<string name="v2x_map_back">Zurück</string>
|
||||
<string name="v2x_map_follow">Auf eigene Position zentrieren</string>
|
||||
<string name="v2x_map_legend_cam">Verkehrsteilnehmer (CAM)</string>
|
||||
<string name="v2x_map_legend_denm">Gefahren (DENM)</string>
|
||||
<string name="v2x_map_legend_spat">Signale (SPATEM)</string>
|
||||
<string name="v2x_map_spat_unlocated">%1$d Signal(e) nicht dargestellt - Senderposition unbekannt</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
@@ -203,6 +197,7 @@
|
||||
<string name="mqtt_no_topics">Noch keine Nachrichten</string>
|
||||
<string name="mqtt_view_list">Liste</string>
|
||||
<string name="mqtt_view_topics">Topics</string>
|
||||
<string name="mqtt_view_map">Karte</string>
|
||||
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
|
||||
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
|
||||
|
||||
|
||||
@@ -143,18 +143,12 @@
|
||||
<string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
|
||||
<string name="map_title">Location Map</string>
|
||||
<string name="map_location_label">Current Location</string>
|
||||
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
|
||||
<string name="v2x_map_own_label">Own (ego)</string>
|
||||
<string name="v2x_map_remote_plain">Remote #%1$d</string>
|
||||
<string name="v2x_map_remote_info">Remote #%1$d · Info</string>
|
||||
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
|
||||
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
|
||||
<string name="v2x_map_title">V2X Live Map</string>
|
||||
<string name="v2x_map_back">Back</string>
|
||||
<string name="v2x_map_follow">Centre on own position</string>
|
||||
<string name="v2x_map_legend_cam">Road users (CAM)</string>
|
||||
<string name="v2x_map_legend_denm">Hazards (DENM)</string>
|
||||
<string name="v2x_map_legend_spat">Signals (SPATEM)</string>
|
||||
<string name="v2x_map_spat_unlocated">%1$d signal(s) not shown - sender position unknown</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
@@ -204,6 +198,7 @@
|
||||
<string name="mqtt_no_topics">No messages yet</string>
|
||||
<string name="mqtt_view_list">List</string>
|
||||
<string name="mqtt_view_topics">Topics</string>
|
||||
<string name="mqtt_view_map">Map</string>
|
||||
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
|
||||
<string name="mqtt_no_messages">No messages on this topic yet</string>
|
||||
|
||||
@@ -264,7 +259,7 @@
|
||||
<string name="mqtt_cam_pinger_active">Pinging - 1 CAM/s over the serial link</string>
|
||||
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
|
||||
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string>
|
||||
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d · rx queue drop %4$d</string>
|
||||
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d</string>
|
||||
<string name="mqtt_cam_pinger_loopback">Own TX heard back: %1$d frames · %2$d dBm</string>
|
||||
<string name="mqtt_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string>
|
||||
<string name="mqtt_start_pinger">Start Pinger</string>
|
||||
|
||||
@@ -166,21 +166,6 @@ 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(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sniffer board and capture tooling
|
||||
|
||||
How to put an ESP32-C5 on the ITS-G5 channel as a passive sniffer, pull its captures onto this
|
||||
PC, and check what is on air. The sniffer firmware itself is the third-party
|
||||
`its-g5-receiver-firmware` checkout beside this repo; only the tooling and these notes are ours.
|
||||
|
||||
| File | What it does |
|
||||
|---|---|
|
||||
| `live_capture.py` | Streams the device's captures into a growing `.pcap` while it runs. The usual choice. |
|
||||
| `dump_pcap.py` | Pulls one capture out of the device's in-memory buffer after the fact. |
|
||||
| `../obu-firmware/test/pcap_gn_tally.py` | Tallies GeoNetworking headers per station over a `.pcap`. |
|
||||
|
||||
One-time: `pip install pyserial` (present in Python 3.11 on the bench PC, so `py -3.11` works).
|
||||
|
||||
## Which port
|
||||
|
||||
The sniffer firmware's console, and with it the pcap stream, goes out **UART0** - the board's
|
||||
USB-bridge port (a CH343, its own COM number), not the native USB-C port. A board with only one
|
||||
USB-C port cannot be used as a sniffer for this reason. On the bench this has been COM5 and, after
|
||||
a re-enumeration, COM8.
|
||||
|
||||
## Live capture (preferred)
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
```
|
||||
|
||||
It writes `recordings/capture_<timestamp>.pcap` next to itself, flushing after every packet, so
|
||||
the file can be read while it grows. Stop it with Ctrl+C. Use `-o <dir>` to write elsewhere;
|
||||
`recordings/` is gitignored, since captures are large and are data rather than source. Captures
|
||||
taken before 2026-09-14 are still in `its-g5-receiver-firmware/recordings/`; the host tests read
|
||||
both directories.
|
||||
|
||||
### The CR insertion, and why captures used to be corrupt
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that applies to every `0x0a` byte
|
||||
of the **binary** pcap stream, not only to log text. Each inserted CR shifts everything after it,
|
||||
so pcap record headers and captured frames alike come out corrupt, and the file stops being
|
||||
parseable at the first occurrence.
|
||||
|
||||
Measured on 2026-09-14: a 787 KB capture parsed cleanly for only 82 of about 2000 records, and
|
||||
DENMs appeared on nonsense BTP ports because their payloads contain `0x0a` often. `undo_crlf()` in
|
||||
`live_capture.py` reverses it on the raw stream before any framing, which is exact; afterwards a
|
||||
capture parsed to EOF and DENMs read as port 2002 again.
|
||||
|
||||
**Captures taken before 2026-09-14 are truncated at their first corrupted record.** Anything
|
||||
measured from them is worth re-checking. `dump_pcap.py` reads the same console and has not been
|
||||
given the same treatment yet.
|
||||
|
||||
## Checking a capture
|
||||
|
||||
```powershell
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
One row per station, packet type, BTP port and GN lifetime. For the messages themselves, decode
|
||||
the payloads with `asn1tools` against the modules in `../asn1/` and re-encode them: identical bytes
|
||||
mean the message was read exactly, wrong bytes mean it was not. `obu-firmware/test/check_replay.py`
|
||||
does this over a whole capture.
|
||||
|
||||
## Flashing the sniffer firmware
|
||||
|
||||
From the receiver checkout, with its **pinned** ESP-IDF (not the global 5.5.4 install):
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware
|
||||
git submodule update --init --recursive
|
||||
.\esp-idf\install.bat
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
.\esp-idf\export.ps1
|
||||
idf.py set-target esp32c5
|
||||
idf.py -p COM8 -b 921600 flash
|
||||
```
|
||||
|
||||
Its `sdkconfig` for a bare board (nothing wired) needs SPI Ethernet off, and the pcap destination
|
||||
set to Memory, both under `idf.py menuconfig`. Wired variants have ready-made configs in that
|
||||
checkout: `sdkconfig.proto-w5500`, `sdkconfig.proto-enc28j60`, `sdkconfig.proto-spi-eppp`.
|
||||
|
||||
To reflash a board that already has a built image, without a toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd its-g5-receiver-firmware\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM8 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x1e000 ota_data_initial.bin 0x20000 its-g5-receiver-firmware.bin
|
||||
```
|
||||
|
||||
## Pulling a capture after the fact
|
||||
|
||||
Only for the Memory destination, and the buffer is small (`SNIFFER_PCAP_MEMORY_SIZE`, 4096 bytes
|
||||
by default) - a smoke test, not a session. In the device console (`idf.py -p COM8 monitor`, exit
|
||||
with Ctrl+T then Ctrl+X):
|
||||
|
||||
```
|
||||
sniffer -P
|
||||
sniffer --stop
|
||||
```
|
||||
|
||||
Then, with the port free:
|
||||
|
||||
```powershell
|
||||
py -3.11 dump_pcap.py COM8
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pulls a capture off the ITS-G5 receiver's in-memory pcap buffer over the existing USB serial
|
||||
connection and saves it as a real .pcap file on this machine.
|
||||
|
||||
Requires the firmware to be built with:
|
||||
Example Configuration -> Select destination to store pcap file -> Memory
|
||||
|
||||
Usage (typical):
|
||||
1. Close idf.py monitor (only one program can hold the COM port at a time).
|
||||
2. Run a capture on the device: `sniffer -P` ... let it run ... `sniffer --stop`
|
||||
3. python dump_pcap.py COM5
|
||||
|
||||
The device has no access to this computer's filesystem, so it can't write here directly. Instead,
|
||||
`pcap --dump` streams the raw pcap bytes back over the same serial link, wrapped in plain-text
|
||||
markers ("===PCAP-DUMP-START:<len>===" ... raw bytes ... "===PCAP-DUMP-END==="). This script finds
|
||||
those markers and writes just the raw bytes out as a .pcap file.
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
START_RE = re.compile(rb"===PCAP-DUMP-START:(\d+)===\n")
|
||||
END_MARKER = b"\n===PCAP-DUMP-END===\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
parser.add_argument("-t", "--timeout", type=float, default=15.0, help="Seconds to wait for the dump to start")
|
||||
args = parser.parse_args()
|
||||
|
||||
import os
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser:
|
||||
# Nudge the console in case there's stale input, then request the dump.
|
||||
ser.reset_input_buffer()
|
||||
ser.write(b"\r\n")
|
||||
ser.write(b"pcap -f dump --dump\r\n")
|
||||
|
||||
print("Waiting for dump to start...")
|
||||
buf = b""
|
||||
match = None
|
||||
deadline = datetime.datetime.now() + datetime.timedelta(seconds=args.timeout)
|
||||
while datetime.datetime.now() < deadline:
|
||||
chunk = ser.read(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
match = START_RE.search(buf)
|
||||
if match:
|
||||
break
|
||||
if not match:
|
||||
print("Timed out waiting for '===PCAP-DUMP-START:...===' marker.\n"
|
||||
"Check that: the firmware is built with the Memory pcap destination, a capture was\n"
|
||||
"actually taken ('sniffer -P' then 'sniffer --stop'), and no other program (like\n"
|
||||
"idf.py monitor) is holding the serial port open.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
length = int(match.group(1))
|
||||
print(f"Dump starting, {length} bytes expected.")
|
||||
|
||||
# Anything after the marker in our buffer is already part of the payload.
|
||||
payload = buf[match.end():]
|
||||
remaining = length - len(payload)
|
||||
while remaining > 0:
|
||||
chunk = ser.read(min(remaining, 4096))
|
||||
if not chunk:
|
||||
print(f"Serial read timed out with {remaining} bytes still missing.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
payload += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
# Drain (and sanity-check) the trailing end marker, but don't fail hard if it's not exact.
|
||||
tail = ser.read(len(END_MARKER))
|
||||
if tail != END_MARKER:
|
||||
print("Warning: end marker didn't match exactly - payload may still be fine.", file=sys.stderr)
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
with open(outpath, "wb") as f:
|
||||
f.write(payload)
|
||||
|
||||
print(f"Saved {len(payload)} bytes to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Continuously listens on the ITS-G5 receiver's serial console and writes every captured packet into a
|
||||
live-growing .pcap file, with no console commands needed on the device side.
|
||||
|
||||
The firmware streams every packet it captures out over the same serial connection the console runs on,
|
||||
automatically, as soon as the sniffer is running (which happens on boot by default). Each packet is framed
|
||||
with plain-text markers so this script can pull the binary pcap bytes out of the stream even though
|
||||
regular log lines are interleaved with it:
|
||||
|
||||
===PCAP-LIVE-HEADER:<len>===\\n<24 raw bytes>\\n (sent once, the pcap global header)
|
||||
===PCAP-LIVE-PKT:<len>===\\n<raw bytes>\\n (sent once per captured packet)
|
||||
|
||||
Usage:
|
||||
python live_capture.py COM5
|
||||
|
||||
Runs until you press Ctrl+C. Writes to recordings/capture_<timestamp>.pcap, flushing after every packet
|
||||
so you can open the file in Wireshark while it's still being written (use "File > Open" again, or
|
||||
Wireshark's own "Follow" won't auto-refresh but re-opening will show the latest packets).
|
||||
|
||||
Install dependency once: pip install pyserial
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# \r? because the ESP console emits CRLF: on Windows the markers arrive as
|
||||
# "===PCAP-LIVE-PKT:310===\r\n", which never matched a bare \n and left the capture silently
|
||||
# empty while the device was streaming perfectly well.
|
||||
HEADER_RE = re.compile(rb"===PCAP-LIVE-HEADER:(\d+)===\r?\n")
|
||||
PKT_RE = re.compile(rb"===PCAP-LIVE-PKT:(\d+)===\r?\n")
|
||||
|
||||
LINKTYPE_ETHERNET = 1
|
||||
LINKTYPE_IEEE802_11_RADIOTAP = 127
|
||||
|
||||
|
||||
def mac_str(b):
|
||||
return ":".join(f"{x:02x}" for x in b)
|
||||
|
||||
|
||||
def build_default_pcap_header(link_type):
|
||||
"""Synthesizes the same 24-byte global pcap header the firmware would have sent, for when we
|
||||
connect after the device's one-time header already went out (see the race note in main())."""
|
||||
header = bytearray(24)
|
||||
header[0:4] = bytes([0xD4, 0xC3, 0xB2, 0xA1]) # magic (LE bytes of 0xA1B2C3D4)
|
||||
header[4:6] = (2).to_bytes(2, "little") # major version
|
||||
header[6:8] = (4).to_bytes(2, "little") # minor version
|
||||
header[16:20] = (0x40000).to_bytes(4, "little") # snaplen
|
||||
header[20:24] = link_type.to_bytes(4, "little")
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def summarize_packet(link_type, record_bytes, index):
|
||||
"""Best-effort human-readable one-line summary of a captured packet, for live feedback.
|
||||
record_bytes is the raw 16-byte pcap record header followed by the captured frame."""
|
||||
seconds = int.from_bytes(record_bytes[0:4], "little")
|
||||
microseconds = int.from_bytes(record_bytes[4:8], "little")
|
||||
cap_len = int.from_bytes(record_bytes[8:12], "little")
|
||||
frame = record_bytes[16:]
|
||||
ts = f"{seconds}.{microseconds:06d}"
|
||||
|
||||
if link_type == LINKTYPE_IEEE802_11_RADIOTAP and len(frame) >= 24:
|
||||
radiotap_len = int.from_bytes(frame[2:4], "little")
|
||||
rssi = frame[8] - 256 if frame[8] >= 128 else frame[8]
|
||||
station_id = int.from_bytes(frame[16:24], "little")
|
||||
mac_frame = frame[radiotap_len:]
|
||||
if len(mac_frame) >= 16:
|
||||
dst = mac_str(mac_frame[4:10])
|
||||
src = mac_str(mac_frame[10:16])
|
||||
else:
|
||||
dst = src = "?"
|
||||
station = f"{station_id:012x}" if station_id else "unknown"
|
||||
return (f"#{index:<5} [{ts}] len={cap_len:<5} rssi={rssi:>4}dBm "
|
||||
f"station={station} {src} -> {dst}")
|
||||
|
||||
if link_type == LINKTYPE_ETHERNET and len(frame) >= 14:
|
||||
dst = mac_str(frame[0:6])
|
||||
src = mac_str(frame[6:12])
|
||||
ethertype = int.from_bytes(frame[12:14], "big")
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} eth {src} -> {dst} type=0x{ethertype:04x}"
|
||||
|
||||
return f"#{index:<5} [{ts}] len={cap_len:<5} (unrecognized frame format)"
|
||||
|
||||
|
||||
def undo_crlf(chunk, state):
|
||||
"""Undo the CR the device console inserts before every LF.
|
||||
|
||||
ESP-IDF's newlib console converts LF to CRLF on its way out, and that happens to every 0x0A
|
||||
byte of the binary pcap stream too, not only to log text. Each inserted CR shifts everything
|
||||
after it, so pcap record headers and captured frames alike come out corrupt. This is the
|
||||
"byte inserted mid-frame" seen in older recordings; with DENM traffic on air it wrecks most
|
||||
of a capture (measured 2026-09-14: a 787 KB file parsed cleanly for only 82 records).
|
||||
|
||||
Dropping one CR immediately before each LF undoes it exactly, provided it is done on the raw
|
||||
stream before any framing and a trailing CR is carried across read boundaries. CR and LF are
|
||||
written as byte values here so the transformation cannot be confused with an escape.
|
||||
"""
|
||||
CR, LF = bytes([13]), bytes([10])
|
||||
if state["pending_cr"]:
|
||||
chunk = CR + chunk
|
||||
state["pending_cr"] = False
|
||||
if chunk.endswith(CR):
|
||||
chunk = chunk[:-1]
|
||||
state["pending_cr"] = True
|
||||
return chunk.replace(CR + LF, LF)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.outdir, exist_ok=True)
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||
|
||||
print(f"Opening {args.port} @ {args.baud}...")
|
||||
print(f"Writing live capture to {outpath}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
header_written = False
|
||||
link_type = None
|
||||
packet_count = 0
|
||||
buf = b""
|
||||
total_bytes = 0
|
||||
last_status = time.monotonic()
|
||||
printed_raw_preview = False
|
||||
|
||||
crlf_state = {"pending_cr": False}
|
||||
|
||||
with serial.Serial(args.port, args.baud, timeout=1) as ser, open(outpath, "wb") as outfile:
|
||||
def read_bytes(n):
|
||||
return undo_crlf(ser.read(n), crlf_state)
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = read_bytes(256)
|
||||
if chunk:
|
||||
buf += chunk
|
||||
total_bytes += len(chunk)
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_status >= 2:
|
||||
last_status = now
|
||||
print(f"[diagnostic] {total_bytes} raw bytes received so far, "
|
||||
f"{packet_count} packets recognized, header_written={header_written}")
|
||||
if total_bytes > 0 and not printed_raw_preview and not header_written and not PKT_RE.search(buf):
|
||||
# We're getting bytes but none of them look like our markers - show a preview
|
||||
# so we can tell whether this is plain log text (markers just haven't shown up
|
||||
# yet), garbage (baud/port mismatch), or something else entirely.
|
||||
preview = buf[:200]
|
||||
print(f"[diagnostic] no markers matched yet - raw preview: {preview!r}")
|
||||
printed_raw_preview = True
|
||||
elif total_bytes == 0:
|
||||
print("[diagnostic] zero bytes received from the port at all - this points at "
|
||||
"the wrong COM port, another program holding the port, or a port that "
|
||||
"isn't actually wired to the console/sniffer output.")
|
||||
|
||||
# The device only sends the global header once, right when the sniffer first starts
|
||||
# (typically within a second or two of boot). If this script connects even slightly
|
||||
# late - very likely right after a fresh flash, since esptool itself resets the board -
|
||||
# that header is already gone before we ever see it. Rather than blocking forever
|
||||
# waiting for a header that's never coming, look for whichever marker shows up first.
|
||||
header_match = None if header_written else HEADER_RE.search(buf)
|
||||
pkt_match = PKT_RE.search(buf)
|
||||
|
||||
if header_match and (not pkt_match or header_match.start() < pkt_match.start()):
|
||||
length = int(header_match.group(1))
|
||||
buf = buf[header_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
header_bytes = buf[:length]
|
||||
outfile.write(header_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
header_written = True
|
||||
if length >= 24:
|
||||
link_type = int.from_bytes(header_bytes[20:24], "little")
|
||||
print(f"Got pcap global header (link type {link_type}) - device is streaming.\n")
|
||||
continue
|
||||
|
||||
if not header_written and pkt_match:
|
||||
link_type = LINKTYPE_IEEE802_11_RADIOTAP
|
||||
outfile.write(build_default_pcap_header(link_type))
|
||||
outfile.flush()
|
||||
header_written = True
|
||||
print("Note: missed the device's one-time pcap header (it was likely sent before "
|
||||
"this script connected, e.g. right after a flash/reset) - assuming WLAN "
|
||||
"radiotap capture and writing a default header instead.\n")
|
||||
# fall through and process pkt_match below, don't discard this packet
|
||||
|
||||
if not pkt_match:
|
||||
# Keep the buffer from growing unbounded while waiting for a marker, but don't
|
||||
# discard anything - a marker could be split across reads.
|
||||
if len(buf) > 65536:
|
||||
buf = buf[-4096:]
|
||||
continue
|
||||
|
||||
length = int(pkt_match.group(1))
|
||||
buf = buf[pkt_match.end():]
|
||||
while len(buf) < length:
|
||||
buf += read_bytes(length - len(buf))
|
||||
record_bytes = buf[:length]
|
||||
outfile.write(record_bytes)
|
||||
outfile.flush()
|
||||
buf = buf[length:]
|
||||
packet_count += 1
|
||||
print(summarize_packet(link_type, record_bytes, packet_count))
|
||||
except KeyboardInterrupt:
|
||||
print(f"\nStopped. {packet_count} packets saved to {outpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+50
-123
@@ -1,7 +1,7 @@
|
||||
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||
|
||||
Build and flash steps are under "Build and flash" below. (`docs/04-transmit-setup.md`,
|
||||
referenced here and in the sources, is not in the repo.)
|
||||
Started. See `docs/04-transmit-setup.md` in the project root for build/flash
|
||||
steps and how to validate this against your own sniffer.
|
||||
|
||||
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4)
|
||||
|
||||
@@ -29,123 +29,6 @@ referenced an older source path under `micrOBU_workspace/v2x-obu-esp32c5/`,
|
||||
which makes `idf.py fullclean` refuse to run). If that error reappears, delete
|
||||
`build/` manually rather than fighting it.
|
||||
|
||||
## Build and flash
|
||||
|
||||
The firmware needs no button, phone or serial connection to start. On every power-up or reset,
|
||||
`app_main` sets up the radio and starts `tx_task`, which starts driving the simulated route and
|
||||
sending CAMs on 5900 MHz. A board flashed with this image starts beaconing on its own as soon as it
|
||||
gets power.
|
||||
|
||||
In a fresh PowerShell window:
|
||||
|
||||
```powershell
|
||||
$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null
|
||||
. C:\Espressif\frameworks\esp-idf-v5.5.4\export.ps1
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||
```
|
||||
|
||||
1. **Check what the board is running before you flash it.** Both this project and `obu-firmware`
|
||||
name their image `obu_firmware.bin`, so the file name tells you nothing. Read the app
|
||||
descriptor instead (replace `COMx` with the board's port):
|
||||
|
||||
```powershell
|
||||
python -m esptool --chip esp32c5 -p COMx read_flash 0x10000 0x100 $env:TEMP\desc.bin
|
||||
$b = [IO.File]::ReadAllBytes("$env:TEMP\desc.bin")
|
||||
function S($o,$n){ [Text.Encoding]::ASCII.GetString($b,$o,$n).Trim([char]0) }
|
||||
"time=" + (S 0x70 16) + " date=" + (S 0x80 16) + " idf=" + (S 0x90 32)
|
||||
```
|
||||
|
||||
`idf=v6.1...` means the board runs the production OBU (`obu-firmware`). Don't flash this beacon
|
||||
over it. `idf=v5.5.4` means this transmitter, or another bench image.
|
||||
|
||||
2. **Build:**
|
||||
|
||||
```powershell
|
||||
idf.py build
|
||||
```
|
||||
|
||||
A rebuild after small changes takes about 1-2 minutes. The output is
|
||||
`build\obu_firmware.bin`.
|
||||
|
||||
3. **Flash** (the board must be on its UART bridge port or its native USB port):
|
||||
|
||||
```powershell
|
||||
idf.py -p COMx flash
|
||||
```
|
||||
|
||||
The flash is good when esptool prints `Hash of data verified.`, then resets the board.
|
||||
|
||||
4. **Check that it's transmitting:**
|
||||
|
||||
```powershell
|
||||
idf.py -p COMx monitor
|
||||
```
|
||||
|
||||
(Exit with `Ctrl+]`.) About 1.4 s after reset you should see:
|
||||
|
||||
```
|
||||
W obu-tx: OCB @ 5900 MHz - CAM beacon armed, driving a 103-point street loop
|
||||
I obu-tx: CAM sent (119 bytes) @ 5900 MHz genDeltaT=1087 pos=53.5531770,10.0220980 50.0 km/h heading 77.0 pt1
|
||||
```
|
||||
|
||||
After that, a `CAM sent` line appears about 3 times a second, with the position, speed and
|
||||
heading changing. These lines only mean each frame was
|
||||
handed to the radio. To confirm the frames actually went out, capture them with a second
|
||||
ESP32-C5 running the receiver firmware.
|
||||
|
||||
Don't open the console of the production OBU (COM3) while the phone is attached. Opening the port
|
||||
resets that board and drops the phone's USB link. The beacon boards have no phone attached, so
|
||||
this doesn't apply to them.
|
||||
|
||||
## Simulated drive
|
||||
|
||||
The beacon pretends to be a car driving a loop through St. Georg / Berliner Tor in Hamburg, on
|
||||
the real streets. The route comes from six waypoints, which you set in `tools/make_route.py`.
|
||||
|
||||
**Changing the route:** edit `WAYPOINTS` in `tools/make_route.py`, then run
|
||||
|
||||
```powershell
|
||||
py -3.11 tools/make_route.py
|
||||
```
|
||||
|
||||
The script asks the OSRM demo server (router.project-osrm.org) for a legal driving route through the
|
||||
waypoints in order and back to the first, then writes three files:
|
||||
|
||||
- `main/route_points.h`: the street geometry, thinned to points no more than 1.5 m off the line
|
||||
(currently 103 points).
|
||||
- `tools/route_osrm.json`: OSRM's raw answer. `--offline` rebuilds the header from it without the
|
||||
network.
|
||||
- `tools/route_map.html`: the route on an OpenStreetMap map. Open it in a browser and check it
|
||||
before building.
|
||||
|
||||
Then build and flash as above. Route data (c) OpenStreetMap contributors, ODbL; routing by OSRM.
|
||||
|
||||
**Things to know about the routing:**
|
||||
- OSRM follows one-way streets and turn bans, so the loop can be longer than the waypoints suggest.
|
||||
The current one is 5.2 km, with two turn-round detours: a loop via Borgfelder Straße and
|
||||
Anckelmannsplatz between wp2 and wp3, and one round Nagelsweg, Norderstraße and Repsoldstraße
|
||||
between wp5 and wp6. To avoid a detour, move the waypoint on either side of it.
|
||||
- Each waypoint is sent with the direction towards the next one. Without it, points on divided
|
||||
roads (Beim Strohhause, for example) snap to the carriageway going the other way, and the loop
|
||||
grows to 8.4 km of U-turns.
|
||||
|
||||
**How the car drives** (`main/route.c`):
|
||||
- **Speed:** it cruises at 50 km/h (`CRUISE_MPS` in `main/main.c`). Each bend gets a speed limit
|
||||
from its radius, keeping sideways acceleration at 2 m/s², so a 90° junction turn is taken at
|
||||
about 15 km/h and a gentle curve barely slows the car. It never drops below 10 km/h
|
||||
(`MIN_CORNER_MPS`). It brakes at 2 m/s² and accelerates at 1.5 m/s², planning braking across as
|
||||
many points as a bend needs. A lap takes about 7.7 min, averaging 40 km/h.
|
||||
- **Heading:** the compass bearing of the current straight piece. It changes gradually through
|
||||
curves but jumps at sharp junction turns.
|
||||
- **When CAMs are sent:** following ETSI EN 302 637-2, the state is checked every 100 ms. A CAM goes
|
||||
out when the heading changed by more than 4°, the position by more than 4 m, or the speed by more
|
||||
than 0.5 m/s since the last one, and at least once a second. That's about 3 CAMs a second at
|
||||
50 km/h.
|
||||
- **What's filled in:** position, speed and heading go into both the CAM and the GeoNetworking
|
||||
source position vector. `genDeltaT` is milliseconds since boot.
|
||||
- **Testing:** `route.c` only uses standard headers, so you can compile it on the PC with MSYS2 gcc
|
||||
and simulate a lap.
|
||||
|
||||
## CAM encoding
|
||||
|
||||
`main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a
|
||||
@@ -161,12 +44,56 @@ hazard-light GPIO is grounded. No location/alacarte containers.
|
||||
- `main/main.c` - entry point, the `phy_11p_set`/`phy_change_channel(5900,...)`
|
||||
register hack, GPIO polling, TX loop
|
||||
- `main/denm.c` / `.h` - ASN.1 UPER encoding of a minimal DENM
|
||||
- `main/route.c` / `.h` - simulated drive round the route loop
|
||||
- `main/route_points.h` - the route, generated by `tools/make_route.py`
|
||||
- `main/geonet.c` / `.h` - GeoNetworking Basic/Common/SHB headers + BTP-B
|
||||
- `main/dot11p.c` / `.h` - 802.11 OCB (QoS Data, broadcast) frame + LLC/SNAP
|
||||
|
||||
Known gaps, tracked as TODOs in the source: no real GNSS (the CAM position comes
|
||||
from the simulated drive above), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||
Known gaps, tracked as TODOs in the source: no real GNSS (lat/long hardcoded
|
||||
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
||||
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
||||
|
||||
## Running it as a bench beacon
|
||||
|
||||
This firmware needs no phone: it beacons a CAM every second by itself
|
||||
(`TX_INTERVAL_MS`) from station `0x0BADC0DE` (195936478), stationType 5
|
||||
(passengerCar), at the hardcoded bench position, under the fixed MAC
|
||||
`02:00:00:00:00:01`, on 5900 MHz. That makes it the quickest way to put known,
|
||||
repeatable traffic on air, and it is how the 4-bit `yawRateConfidence` encoding
|
||||
was confirmed over the air on 2026-09-14.
|
||||
|
||||
A board with only one USB-C port is fine. This firmware's console is on UART0,
|
||||
so such a board shows no log output, but nothing here needs the console.
|
||||
|
||||
Flash it from the toolchain terminal (ESP-IDF 5.5.4, see the table above):
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||
idf.py -p COM10 -b 921600 flash
|
||||
```
|
||||
|
||||
Or flash the existing build without any toolchain terminal:
|
||||
|
||||
```powershell
|
||||
cd obu-cam-transmistter\build
|
||||
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM10 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 2MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x10000 obu_firmware.bin
|
||||
```
|
||||
|
||||
It starts beaconing as soon as it boots, so there is nothing to start by hand,
|
||||
and unplugging it is how you stop it.
|
||||
|
||||
**It transmits under the same MAC as the phone's CAM pinger**, so on air the two
|
||||
are told apart by station ID (195936478 here, 999999 for the pinger), never by
|
||||
source address.
|
||||
|
||||
To see what it is sending, capture on the sniffer board and decode:
|
||||
|
||||
```powershell
|
||||
cd capture
|
||||
py -3.11 live_capture.py COM8
|
||||
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||
```
|
||||
|
||||
The tally lists it as SHB / port 2001 / lifetime `0x05`. For the message itself,
|
||||
decode the payload with `asn1tools` against `asn1/cam_1_4_1.asn` +
|
||||
`asn1/cdd_1_3_1_1.asn`; re-encoding must return the identical bytes. On
|
||||
2026-09-14, 72 of 72 frames did.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# wifi_patches.c is intentionally NOT in this list anymore - superseded by
|
||||
# tx_custom.c (see that file for why). Left on disk, unused, for history.
|
||||
idf_component_register(
|
||||
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c" "route.c"
|
||||
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES esp_event esp_timer esp_netif nvs_flash driver esp_phy
|
||||
REQUIRES esp_event esp_netif nvs_flash driver esp_phy
|
||||
PRIV_REQUIRES esp_wifi
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
uint16_t speed_cm_s, uint16_t heading_ddeg,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len)
|
||||
{
|
||||
@@ -69,12 +68,12 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
||||
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
||||
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
||||
// PAI(1 bit) + Speed(15 bits, signed, 0.01 m/s), packed into 2 bytes. PAI stays 0: the
|
||||
// position has no accuracy estimate behind it.
|
||||
uint16_t spd = speed_cm_s > 0x7FFF ? 0x7FFF : speed_cm_s;
|
||||
*p++ = (uint8_t)(spd >> 8); *p++ = (uint8_t)(spd & 0xFF);
|
||||
// Heading (16 bits, 0.1 degree units, clockwise from north)
|
||||
*p++ = (uint8_t)(heading_ddeg >> 8); *p++ = (uint8_t)(heading_ddeg & 0xFF);
|
||||
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false,
|
||||
// speed 0 - which is actually correct semantics for a STATIONARY
|
||||
// vehicle beacon, not just a placeholder.
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
||||
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
||||
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
||||
|
||||
@@ -31,10 +31,6 @@
|
||||
// working (same extended header shape as CAM). Fine for a single-vehicle
|
||||
// beacon; revisit if you need real multi-hop forwarding later.
|
||||
//
|
||||
// `speed_cm_s` (0.01 m/s) and `heading_ddeg` (0.1 deg) also go into the Source Long Position
|
||||
// Vector; pass the same values as the CAM's high-frequency container. Speed is a 15-bit field, so
|
||||
// values above 32767 are clamped.
|
||||
//
|
||||
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
||||
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
||||
//
|
||||
@@ -42,7 +38,6 @@
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
uint16_t speed_cm_s, uint16_t heading_ddeg,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len);
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include <math.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_timer.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_event.h"
|
||||
@@ -18,17 +14,13 @@
|
||||
#include "geonet.h"
|
||||
#include "dot11p.h"
|
||||
#include "tx_custom.h"
|
||||
#include "route.h"
|
||||
#include "route_points.h"
|
||||
|
||||
static const char *TAG = "obu-tx";
|
||||
|
||||
// CAM beacon for a simulated car driving round a block in Hamburg (see route.c). The CAM
|
||||
// generation rules follow ETSI EN 302 637-2 clause 6.1.3: every CHECK_INTERVAL_MS the car's state
|
||||
// is compared with the last CAM sent, and a new CAM goes out when the heading changed by more than
|
||||
// 4 degrees, the position by more than 4 m, the speed by more than 0.5 m/s, or 1 s has passed.
|
||||
// There is no hazard-light gating - CAM is a continuous beacon, unlike the event-triggered DENM.
|
||||
// Transmits on 5900 MHz like the working Rust reference (esp32-c_its-companion, feat/tx-cam).
|
||||
// CAM beacon: transmit a Cooperative Awareness Message every TX_INTERVAL_MS,
|
||||
// unconditionally (no hazard-light gating - CAM is a continuous beacon, unlike
|
||||
// the event-triggered DENM). Matches the working Rust reference
|
||||
// (esp32-c_its-companion, feat/tx-cam), which beacons CAM on 5900 MHz.
|
||||
|
||||
// ISOLATION TEST for whether tx_custom.c is the blocker.
|
||||
// 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a
|
||||
@@ -63,19 +55,15 @@ static const char *TAG = "obu-tx";
|
||||
#define VEHICLE_LENGTH_DM 40 // VehicleLengthValue, 10cm steps (4.0 m)
|
||||
#define VEHICLE_WIDTH_DM 18 // VehicleWidth, 10cm steps (1.8 m)
|
||||
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
||||
#define CHECK_INTERVAL_MS 100 // T_CheckCamGen: how often the generation rules are evaluated
|
||||
#define CAM_MAX_INTERVAL_MS 1000 // T_GenCamMax: a CAM goes out at least this often
|
||||
#define CAM_HEADING_DDEG 40 // > 4 degrees heading change triggers a CAM
|
||||
#define CAM_POSITION_M 4.0 // > 4 m position change triggers a CAM
|
||||
#define CAM_SPEED_CM_S 50 // > 0.5 m/s speed change triggers a CAM
|
||||
#define TX_INTERVAL_MS 1000 // CAM beacon period (1 Hz; ITS allows 1-10 Hz)
|
||||
|
||||
// ---- Simulated drive ----
|
||||
// route_points (main/route_points.h) is the street geometry of a driving loop through six waypoints
|
||||
// in St. Georg, generated by tools/make_route.py from OpenStreetMap via OSRM. To change the route,
|
||||
// edit WAYPOINTS in that script and rerun it. No GNSS is wired in; replace with real fixes once
|
||||
// there is one.
|
||||
#define CRUISE_MPS (50.0 / 3.6) // 50 km/h, the urban limit
|
||||
#define MIN_CORNER_MPS (10.0 / 3.6) // slowest the car goes, for hairpins and U-turns
|
||||
// Bench location, hardcoded since there's no GNSS module wired in yet and
|
||||
// the unit is genuinely stationary here: 53°33'16.8"N 10°01'20.6"E, in
|
||||
// 1/10-microdegree units (decimal_degrees * 10,000,000). Replace with real
|
||||
// GNSS output once you have a fix source; until then this beats 0/0
|
||||
// ("Null Island"), which is an obvious placeholder-tell on any map.
|
||||
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
||||
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
||||
|
||||
// Single source of truth for the pseudonym/link-layer address: used both as
|
||||
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
|
||||
@@ -90,28 +78,33 @@ static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||
extern void phy_11p_set(int enable, int unused);
|
||||
extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, int unused);
|
||||
|
||||
static void send_cam(const route_state_t *car, uint16_t gen_delta)
|
||||
static void send_cam(void)
|
||||
{
|
||||
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No RTC/GNSS time here,
|
||||
// so use a free-running ms counter that advances one beacon-interval per
|
||||
// send. It wraps at 65536, which is exactly the field's defined behaviour.
|
||||
static uint16_t gen_delta = 0;
|
||||
|
||||
uint8_t frame[300];
|
||||
cam_fields_t fields = {
|
||||
.station_id = STATION_ID,
|
||||
.station_type = STATION_TYPE,
|
||||
.generation_delta_time = gen_delta,
|
||||
.latitude_tenmicrodeg = car->latitude_tenmicrodeg,
|
||||
.longitude_tenmicrodeg = car->longitude_tenmicrodeg,
|
||||
.speed_cm_s = car->speed_cm_s,
|
||||
.heading_ddeg = car->heading_ddeg,
|
||||
.latitude_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG,
|
||||
.longitude_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG,
|
||||
.speed_cm_s = 0, // stationary
|
||||
.heading_ddeg = 3601, // HeadingValue unavailable (no heading source)
|
||||
.vehicle_length_dm = VEHICLE_LENGTH_DM,
|
||||
.vehicle_width_dm = VEHICLE_WIDTH_DM,
|
||||
};
|
||||
gen_delta += TX_INTERVAL_MS;
|
||||
|
||||
uint8_t cam_payload[96];
|
||||
int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload));
|
||||
|
||||
uint8_t gn_payload[160];
|
||||
int gn_len = geonet_wrap_shb(cam_payload, cam_len, pseudonym_mac, STATION_TYPE,
|
||||
car->latitude_tenmicrodeg, car->longitude_tenmicrodeg,
|
||||
car->speed_cm_s, car->heading_ddeg,
|
||||
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||
|
||||
// qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS
|
||||
@@ -134,10 +127,7 @@ static void send_cam(const route_state_t *car, uint16_t gen_delta)
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz genDeltaT=%u pos=%.7f,%.7f %.1f km/h heading %.1f pt%d",
|
||||
frame_len, TX_FREQ_MHZ, gen_delta,
|
||||
car->latitude_tenmicrodeg / 1e7, car->longitude_tenmicrodeg / 1e7,
|
||||
car->speed_cm_s * 0.036, car->heading_ddeg / 10.0, car->segment + 1);
|
||||
ESP_LOGI(TAG, "CAM sent via STANDARD tx (%d bytes) @ %d MHz genDeltaT=%u", frame_len, TX_FREQ_MHZ, gen_delta);
|
||||
}
|
||||
#else
|
||||
// tx_custom path: submits to the driver's internal HMAC TX path,
|
||||
@@ -161,49 +151,12 @@ static void send_cam(const route_state_t *car, uint16_t gen_delta)
|
||||
}
|
||||
}
|
||||
|
||||
static bool cam_due(const route_state_t *car, const route_state_t *last, int64_t since_last_ms)
|
||||
{
|
||||
if (since_last_ms >= CAM_MAX_INTERVAL_MS) {
|
||||
return true;
|
||||
}
|
||||
int dh = abs((int)car->heading_ddeg - (int)last->heading_ddeg);
|
||||
if (dh > 1800) {
|
||||
dh = 3600 - dh;
|
||||
}
|
||||
if (dh > CAM_HEADING_DDEG) {
|
||||
return true;
|
||||
}
|
||||
if (abs((int)car->speed_cm_s - (int)last->speed_cm_s) > CAM_SPEED_CM_S) {
|
||||
return true;
|
||||
}
|
||||
// Flat-earth distance is plenty for a 4 m threshold.
|
||||
double north_m = (car->latitude_tenmicrodeg - last->latitude_tenmicrodeg) * 0.0111194930;
|
||||
double east_m = (car->longitude_tenmicrodeg - last->longitude_tenmicrodeg) * 0.0111194930
|
||||
* cos(car->latitude_tenmicrodeg / 1e7 * M_PI / 180.0);
|
||||
return north_m * north_m + east_m * east_m > CAM_POSITION_M * CAM_POSITION_M;
|
||||
}
|
||||
|
||||
static void tx_task(void *arg)
|
||||
{
|
||||
route_state_t car;
|
||||
route_state_t last_sent;
|
||||
int64_t last_sent_ms = 0;
|
||||
bool sent_any = false;
|
||||
TickType_t wake = xTaskGetTickCount();
|
||||
|
||||
route_step(0.0, &car);
|
||||
while (1) {
|
||||
int64_t now_ms = esp_timer_get_time() / 1000;
|
||||
if (!sent_any || cam_due(&car, &last_sent, now_ms - last_sent_ms)) {
|
||||
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No real clock here, so use
|
||||
// milliseconds since boot, which advances at the right rate.
|
||||
send_cam(&car, (uint16_t)now_ms);
|
||||
last_sent = car;
|
||||
last_sent_ms = now_ms;
|
||||
sent_any = true;
|
||||
}
|
||||
vTaskDelayUntil(&wake, pdMS_TO_TICKS(CHECK_INTERVAL_MS));
|
||||
route_step(CHECK_INTERVAL_MS / 1000.0, &car);
|
||||
// CAM is a continuous beacon - send every interval, unconditionally.
|
||||
send_cam();
|
||||
vTaskDelay(pdMS_TO_TICKS(TX_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,13 +255,8 @@ void app_main(void)
|
||||
phy_change_channel(TX_FREQ_MHZ, 1, 0, 0);
|
||||
ESP_LOGI(TAG, "phy_change_channel returned");
|
||||
|
||||
if (route_init(route_points, sizeof(route_points) / sizeof(route_points[0]),
|
||||
CRUISE_MPS, MIN_CORNER_MPS) != 0) {
|
||||
ESP_LOGE(TAG, "route_init failed - check route_points");
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, driving a %d-point street loop",
|
||||
TX_FREQ_MHZ, (int)(sizeof(route_points) / sizeof(route_points[0])));
|
||||
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, transmitting every %d ms",
|
||||
TX_FREQ_MHZ, TX_INTERVAL_MS);
|
||||
|
||||
xTaskCreate(tx_task, "tx_task", 4096, NULL, 5, NULL);
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
#include "route.h"
|
||||
#include <math.h>
|
||||
|
||||
#define DEG_TO_RAD (M_PI / 180.0)
|
||||
#define METRES_PER_DEG 111194.93 // mean Earth radius 6371 km; a block is small enough for a flat projection
|
||||
#define ACCEL_MPS2 1.5 // pulling away from a corner
|
||||
#define DECEL_MPS2 2.0 // braking ahead of a corner
|
||||
#define LATERAL_MPS2 2.0 // sideways acceleration a normal driver takes a bend at
|
||||
#define STRAIGHT_DEG 3.0 // kinks gentler than this are digitising noise, not bends
|
||||
#define BEND_SPAN_M 15.0 // longest segment counted towards a bend's radius (see route_init)
|
||||
|
||||
static const route_point_t *s_pts;
|
||||
static int s_n;
|
||||
static double s_len[ROUTE_MAX_POINTS]; // segment i runs from point i to point (i+1) % n
|
||||
static double s_bearing_deg[ROUTE_MAX_POINTS];
|
||||
static double s_corner_mps[ROUTE_MAX_POINTS]; // speed limit at point i, where segment i starts
|
||||
static double s_cruise_mps;
|
||||
static int s_seg;
|
||||
static double s_pos_m; // distance along the current segment
|
||||
|
||||
static double segment_speed(int seg, double pos_m)
|
||||
{
|
||||
double v = s_cruise_mps;
|
||||
double pull_away = sqrt(s_corner_mps[seg] * s_corner_mps[seg] + 2.0 * ACCEL_MPS2 * pos_m);
|
||||
int next = (seg + 1) % s_n;
|
||||
double braking = sqrt(s_corner_mps[next] * s_corner_mps[next]
|
||||
+ 2.0 * DECEL_MPS2 * (s_len[seg] - pos_m));
|
||||
if (pull_away < v) v = pull_away;
|
||||
if (braking < v) v = braking;
|
||||
return v;
|
||||
}
|
||||
|
||||
int route_init(const route_point_t *points, int n, double cruise_mps, double min_corner_mps)
|
||||
{
|
||||
if (n < 2 || n > ROUTE_MAX_POINTS) {
|
||||
return -1;
|
||||
}
|
||||
s_pts = points;
|
||||
s_n = n;
|
||||
s_cruise_mps = cruise_mps;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
const route_point_t *a = &points[i];
|
||||
const route_point_t *b = &points[(i + 1) % n];
|
||||
double mid_lat = (a->latitude_tenmicrodeg + (double)b->latitude_tenmicrodeg) / 2e7;
|
||||
double north_m = (b->latitude_tenmicrodeg - a->latitude_tenmicrodeg) / 1e7 * METRES_PER_DEG;
|
||||
double east_m = (b->longitude_tenmicrodeg - a->longitude_tenmicrodeg) / 1e7 * METRES_PER_DEG
|
||||
* cos(mid_lat * DEG_TO_RAD);
|
||||
s_len[i] = sqrt(north_m * north_m + east_m * east_m);
|
||||
if (s_len[i] < 0.01) {
|
||||
return -1;
|
||||
}
|
||||
double bearing = atan2(east_m, north_m) / DEG_TO_RAD;
|
||||
s_bearing_deg[i] = bearing < 0 ? bearing + 360.0 : bearing;
|
||||
}
|
||||
|
||||
// Speed limit at each point from how tight the bend there is. A polyline bend of angle theta
|
||||
// between segments of length L approximates an arc of radius L / theta, and a car takes a
|
||||
// radius R at sqrt(a_lat * R). L is capped at BEND_SPAN_M: at a junction the two streets can be
|
||||
// hundreds of metres long, but the car still turns within the width of the crossing.
|
||||
for (int i = 0; i < n; i++) {
|
||||
double turn = fabs(s_bearing_deg[i] - s_bearing_deg[(i + n - 1) % n]);
|
||||
if (turn > 180.0) {
|
||||
turn = 360.0 - turn;
|
||||
}
|
||||
double v = cruise_mps;
|
||||
if (turn > STRAIGHT_DEG) {
|
||||
double span = s_len[(i + n - 1) % n] < s_len[i] ? s_len[(i + n - 1) % n] : s_len[i];
|
||||
if (span > BEND_SPAN_M) {
|
||||
span = BEND_SPAN_M;
|
||||
}
|
||||
v = sqrt(LATERAL_MPS2 * span / (turn * DEG_TO_RAD));
|
||||
}
|
||||
if (v > cruise_mps) v = cruise_mps;
|
||||
if (v < min_corner_mps) v = min_corner_mps;
|
||||
s_corner_mps[i] = v;
|
||||
}
|
||||
|
||||
// A point's limit also has to respect the bends after it (the car must be able to brake for
|
||||
// them within the segments in between) and before it (it can only have sped up so much since).
|
||||
// segment_speed only looks at the two ends of a segment, so settle this here. Limits only ever
|
||||
// go down, so repeating the two passes until nothing changes terminates.
|
||||
for (int changed = 1; changed;) {
|
||||
changed = 0;
|
||||
for (int k = 0; k < 2 * n; k++) {
|
||||
int i = (2 * n - 1 - k) % n; // backwards: braking
|
||||
int next = (i + 1) % n;
|
||||
double v = sqrt(s_corner_mps[next] * s_corner_mps[next] + 2.0 * DECEL_MPS2 * s_len[i]);
|
||||
if (v < s_corner_mps[i] - 1e-9) { s_corner_mps[i] = v; changed = 1; }
|
||||
}
|
||||
for (int k = 0; k < 2 * n; k++) {
|
||||
int i = k % n; // forwards: accelerating
|
||||
int next = (i + 1) % n;
|
||||
double v = sqrt(s_corner_mps[i] * s_corner_mps[i] + 2.0 * ACCEL_MPS2 * s_len[i]);
|
||||
if (v < s_corner_mps[next] - 1e-9) { s_corner_mps[next] = v; changed = 1; }
|
||||
}
|
||||
}
|
||||
|
||||
s_seg = 0;
|
||||
s_pos_m = 0.0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void route_step(double dt_s, route_state_t *out)
|
||||
{
|
||||
// Advance with the speed at the start of the step; at 100 ms steps the error is well under a metre.
|
||||
double d = segment_speed(s_seg, s_pos_m) * dt_s;
|
||||
while (s_pos_m + d >= s_len[s_seg]) {
|
||||
d -= s_len[s_seg] - s_pos_m;
|
||||
s_seg = (s_seg + 1) % s_n;
|
||||
s_pos_m = 0.0;
|
||||
}
|
||||
s_pos_m += d;
|
||||
|
||||
const route_point_t *a = &s_pts[s_seg];
|
||||
const route_point_t *b = &s_pts[(s_seg + 1) % s_n];
|
||||
double f = s_pos_m / s_len[s_seg];
|
||||
out->latitude_tenmicrodeg = (int32_t)lround(a->latitude_tenmicrodeg
|
||||
+ f * (b->latitude_tenmicrodeg - a->latitude_tenmicrodeg));
|
||||
out->longitude_tenmicrodeg = (int32_t)lround(a->longitude_tenmicrodeg
|
||||
+ f * (b->longitude_tenmicrodeg - a->longitude_tenmicrodeg));
|
||||
out->speed_cm_s = (uint16_t)lround(segment_speed(s_seg, s_pos_m) * 100.0);
|
||||
out->heading_ddeg = (uint16_t)(lround(s_bearing_deg[s_seg] * 10.0) % 3600);
|
||||
out->segment = s_seg;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
#ifndef ROUTE_H
|
||||
#define ROUTE_H
|
||||
#include <stdint.h>
|
||||
|
||||
// Simulated drive around a closed loop of waypoints, so the beacon looks like a
|
||||
// car going round the block instead of a parked one.
|
||||
//
|
||||
// The car follows straight lines between the points and goes from the last one
|
||||
// back to the first, forever. For street-following, the points are the street
|
||||
// geometry from tools/make_route.py (main/route_points.h). Speed is a function
|
||||
// of where the car is on a segment: it cruises, brakes ahead of each bend down to
|
||||
// the speed that bend allows, and accelerates away after it. Heading is the
|
||||
// bearing of the current segment.
|
||||
//
|
||||
// Uses only standard headers, so it also compiles on the host for testing.
|
||||
|
||||
typedef struct {
|
||||
int32_t latitude_tenmicrodeg; // 1/10 microdegree, same units as the CAM
|
||||
int32_t longitude_tenmicrodeg;
|
||||
} route_point_t;
|
||||
|
||||
typedef struct {
|
||||
int32_t latitude_tenmicrodeg;
|
||||
int32_t longitude_tenmicrodeg;
|
||||
uint16_t speed_cm_s; // SpeedValue units (0.01 m/s)
|
||||
uint16_t heading_ddeg; // HeadingValue units (0.1 deg, 0..3599, 0 = north, clockwise)
|
||||
int segment; // index of the route point the car last passed
|
||||
} route_state_t;
|
||||
|
||||
#define ROUTE_MAX_POINTS 512 // keep in step with MAX_POINTS in tools/make_route.py
|
||||
|
||||
// `points` must stay valid for as long as the route is used. Returns 0, or -1
|
||||
// if n is out of range (2..ROUTE_MAX_POINTS) or a segment has zero length.
|
||||
// min_corner_mps is the slowest the car ever goes (hairpins, U-turns).
|
||||
int route_init(const route_point_t *points, int n, double cruise_mps, double min_corner_mps);
|
||||
|
||||
// Moves the car on by dt_s seconds and writes its new position into `out`.
|
||||
void route_step(double dt_s, route_state_t *out);
|
||||
|
||||
#endif
|
||||
@@ -1,116 +0,0 @@
|
||||
// GENERATED by tools/make_route.py - do not edit by hand; change WAYPOINTS there and rerun.
|
||||
// Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.
|
||||
//
|
||||
// Driving loop through 6 waypoints: 5179 m (legs 147 m, 1837 m, 381 m, 819 m, 1234 m, 761 m), 103 points after
|
||||
// simplifying to 1.5 m. Streets: Beim Strohhause, Berlinertordamm, Berliner Tor, Bei der Hauptfeuerwache, Westphalensweg, Berliner Tor, Berlinertordamm, Borgfelder Straße, Anckelmannstraße, Anckelmannsplatz, Bürgerweide, Wallstraße, Lübeckertordamm, Steindamm, Kreuzweg, Adenauerallee, Nagelsweg, Norderstraße, Repsoldstraße, Kurt-Schumacher-Allee, Kreuzweg, Adenauerallee, Kurt-Schumacher-Allee, Beim Strohhause.
|
||||
#ifndef ROUTE_POINTS_H
|
||||
#define ROUTE_POINTS_H
|
||||
#include "route.h"
|
||||
|
||||
static const route_point_t route_points[] = {
|
||||
{ 535531770, 100220980 },
|
||||
{ 535534470, 100240600 },
|
||||
{ 535535790, 100244160 },
|
||||
{ 535536400, 100244520 },
|
||||
{ 535537130, 100244160 },
|
||||
{ 535538480, 100242840 },
|
||||
{ 535538720, 100242140 },
|
||||
{ 535540390, 100240200 },
|
||||
{ 535548880, 100233930 },
|
||||
{ 535549470, 100235130 },
|
||||
{ 535554140, 100253280 },
|
||||
{ 535553570, 100254580 },
|
||||
{ 535546070, 100251700 },
|
||||
{ 535543130, 100250020 },
|
||||
{ 535542570, 100249130 },
|
||||
{ 535542760, 100247800 },
|
||||
{ 535539980, 100244600 },
|
||||
{ 535538720, 100242140 },
|
||||
{ 535538140, 100241960 },
|
||||
{ 535535720, 100243380 },
|
||||
{ 535535300, 100244470 },
|
||||
{ 535535090, 100246580 },
|
||||
{ 535536830, 100264460 },
|
||||
{ 535537600, 100270920 },
|
||||
{ 535538520, 100275220 },
|
||||
{ 535541110, 100290830 },
|
||||
{ 535540170, 100291550 },
|
||||
{ 535539890, 100294430 },
|
||||
{ 535539580, 100295330 },
|
||||
{ 535532290, 100299300 },
|
||||
{ 535527980, 100302450 },
|
||||
{ 535525130, 100291880 },
|
||||
{ 535524220, 100289760 },
|
||||
{ 535522900, 100287820 },
|
||||
{ 535522890, 100285300 },
|
||||
{ 535522610, 100282810 },
|
||||
{ 535520620, 100276050 },
|
||||
{ 535519820, 100271130 },
|
||||
{ 535519670, 100269030 },
|
||||
{ 535520220, 100267120 },
|
||||
{ 535520930, 100262840 },
|
||||
{ 535521850, 100260020 },
|
||||
{ 535522840, 100258210 },
|
||||
{ 535527790, 100257880 },
|
||||
{ 535538650, 100261370 },
|
||||
{ 535551660, 100266470 },
|
||||
{ 535555790, 100268750 },
|
||||
{ 535559620, 100271540 },
|
||||
{ 535561120, 100272050 },
|
||||
{ 535562430, 100271250 },
|
||||
{ 535563830, 100269390 },
|
||||
{ 535569610, 100260190 },
|
||||
{ 535579530, 100242810 },
|
||||
{ 535584090, 100237660 },
|
||||
{ 535585850, 100234670 },
|
||||
{ 535584970, 100230060 },
|
||||
{ 535584000, 100227260 },
|
||||
{ 535580730, 100222030 },
|
||||
{ 535579100, 100218590 },
|
||||
{ 535574500, 100208430 },
|
||||
{ 535570560, 100199010 },
|
||||
{ 535568130, 100194820 },
|
||||
{ 535564910, 100187620 },
|
||||
{ 535563990, 100184630 },
|
||||
{ 535562540, 100181160 },
|
||||
{ 535559780, 100176310 },
|
||||
{ 535542180, 100136840 },
|
||||
{ 535539720, 100133430 },
|
||||
{ 535537350, 100132010 },
|
||||
{ 535534760, 100131390 },
|
||||
{ 535523840, 100132690 },
|
||||
{ 535524980, 100155090 },
|
||||
{ 535524890, 100156360 },
|
||||
{ 535524440, 100157650 },
|
||||
{ 535523030, 100158530 },
|
||||
{ 535517610, 100158580 },
|
||||
{ 535504440, 100168810 },
|
||||
{ 535501360, 100156400 },
|
||||
{ 535499250, 100144880 },
|
||||
{ 535498470, 100133880 },
|
||||
{ 535498630, 100126150 },
|
||||
{ 535499110, 100121980 },
|
||||
{ 535504260, 100117230 },
|
||||
{ 535505720, 100115500 },
|
||||
{ 535507630, 100112310 },
|
||||
{ 535508400, 100111560 },
|
||||
{ 535510000, 100110940 },
|
||||
{ 535511390, 100119560 },
|
||||
{ 535512440, 100122280 },
|
||||
{ 535514510, 100132770 },
|
||||
{ 535515020, 100134120 },
|
||||
{ 535516630, 100135630 },
|
||||
{ 535518260, 100136100 },
|
||||
{ 535523950, 100134870 },
|
||||
{ 535524980, 100155090 },
|
||||
{ 535524890, 100156360 },
|
||||
{ 535524440, 100157650 },
|
||||
{ 535523470, 100158460 },
|
||||
{ 535518560, 100158480 },
|
||||
{ 535519070, 100162430 },
|
||||
{ 535522260, 100178080 },
|
||||
{ 535527880, 100197650 },
|
||||
{ 535530060, 100209020 },
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Turn the beacon's waypoints into a street-following route for main/route_points.h.
|
||||
|
||||
Asks the OSRM demo server (router.project-osrm.org, OpenStreetMap data) for a driving route that
|
||||
visits WAYPOINTS in order and returns to the first, thins the street geometry out, and writes:
|
||||
|
||||
main/route_points.h the C array the firmware drives (commit this)
|
||||
tools/route_osrm.json the raw OSRM answer, so the header can be regenerated offline (--offline)
|
||||
tools/route_map.html the route over an OpenStreetMap map, to check it before flashing
|
||||
|
||||
Each waypoint also gets a bearing (the direction towards the next waypoint), so OSRM snaps it onto
|
||||
the carriageway going that way. Without it, points on divided roads land on the wrong side and
|
||||
every leg grows a U-turn detour.
|
||||
|
||||
Usage (Python 3.8+, standard library only):
|
||||
py tools/make_route.py fetch from OSRM, then write all three files
|
||||
py -3 tools/make_route.py --offline rebuild from the saved route_osrm.json
|
||||
|
||||
Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import urllib.request
|
||||
|
||||
# (latitude, longitude) in decimal degrees, in driving order. The route closes back to the first.
|
||||
WAYPOINTS = [
|
||||
(53.553309, 10.022043),
|
||||
(53.553611, 10.024146),
|
||||
(53.556164, 10.027121),
|
||||
(53.558310, 10.023362),
|
||||
(53.554062, 10.013515),
|
||||
(53.551540, 10.013398),
|
||||
]
|
||||
BEARING_TOLERANCE_DEG = 60 # how far the road's direction may differ from the waypoint bearing
|
||||
SIMPLIFY_M = 1.5 # drop points that move the line by less than this
|
||||
MAX_POINTS = 512 # must match ROUTE_MAX_POINTS in main/route.h
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
PROJECT = HERE.parent
|
||||
OSRM_JSON = HERE / "route_osrm.json"
|
||||
HEADER = PROJECT / "main" / "route_points.h"
|
||||
MAP_HTML = HERE / "route_map.html"
|
||||
METRES_PER_DEG = 111194.93
|
||||
|
||||
|
||||
def to_xy(lat, lon, lat0):
|
||||
return (lon * METRES_PER_DEG * math.cos(math.radians(lat0)), lat * METRES_PER_DEG)
|
||||
|
||||
|
||||
def bearing(a, b):
|
||||
north = b[0] - a[0]
|
||||
east = (b[1] - a[1]) * math.cos(math.radians(a[0]))
|
||||
return math.degrees(math.atan2(east, north)) % 360
|
||||
|
||||
|
||||
def fetch():
|
||||
n = len(WAYPOINTS)
|
||||
pts = WAYPOINTS + [WAYPOINTS[0]]
|
||||
bearings = [round(bearing(WAYPOINTS[i], WAYPOINTS[(i + 1) % n])) for i in range(n)]
|
||||
bearings.append(bearings[0])
|
||||
url = ("https://router.project-osrm.org/route/v1/driving/"
|
||||
+ ";".join(f"{lon},{lat}" for lat, lon in pts)
|
||||
+ "?overview=full&geometries=geojson&steps=true&bearings="
|
||||
+ ";".join(f"{b},{BEARING_TOLERANCE_DEG}" for b in bearings))
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "MicrOBU-route-tool"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.load(resp)
|
||||
if data.get("code") != "Ok":
|
||||
raise SystemExit(f"OSRM error: {data.get('code')} {data.get('message')}")
|
||||
OSRM_JSON.write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
return data
|
||||
|
||||
|
||||
def simplify(xy, tol):
|
||||
"""Douglas-Peucker, iterative. Keeps the first and last point."""
|
||||
keep = [False] * len(xy)
|
||||
keep[0] = keep[-1] = True
|
||||
stack = [(0, len(xy) - 1)]
|
||||
while stack:
|
||||
a, b = stack.pop()
|
||||
(ax, ay), (bx, by) = xy[a], xy[b]
|
||||
dx, dy = bx - ax, by - ay
|
||||
seg2 = dx * dx + dy * dy
|
||||
worst, worst_d = -1, tol
|
||||
for i in range(a + 1, b):
|
||||
px, py = xy[i]
|
||||
if seg2 == 0:
|
||||
d = math.hypot(px - ax, py - ay)
|
||||
else:
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg2))
|
||||
d = math.hypot(px - ax - t * dx, py - ay - t * dy)
|
||||
if d > worst_d:
|
||||
worst, worst_d = i, d
|
||||
if worst >= 0:
|
||||
keep[worst] = True
|
||||
stack += [(a, worst), (worst, b)]
|
||||
return [i for i, k in enumerate(keep) if k]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--offline", action="store_true", help="use the saved route_osrm.json")
|
||||
args = ap.parse_args()
|
||||
data = json.loads(OSRM_JSON.read_text(encoding="utf-8")) if args.offline else fetch()
|
||||
route = data["routes"][0]
|
||||
|
||||
# GeoJSON is [lon, lat]; round to the CAM's 1/10-microdegree grid and drop repeats.
|
||||
raw = []
|
||||
for lon, lat in route["geometry"]["coordinates"]:
|
||||
p = (round(lat * 1e7), round(lon * 1e7))
|
||||
if not raw or p != raw[-1]:
|
||||
raw.append(p)
|
||||
if raw[0] == raw[-1]:
|
||||
raw.pop()
|
||||
closed = raw + [raw[0]]
|
||||
lat0 = closed[0][0] / 1e7
|
||||
xy = [to_xy(p[0] / 1e7, p[1] / 1e7, lat0) for p in closed]
|
||||
pts = [closed[i] for i in simplify(xy, SIMPLIFY_M)][:-1]
|
||||
if len(pts) > MAX_POINTS:
|
||||
raise SystemExit(f"{len(pts)} points is more than MAX_POINTS={MAX_POINTS}; raise SIMPLIFY_M")
|
||||
|
||||
streets = []
|
||||
for leg in route["legs"]:
|
||||
for step in leg["steps"]:
|
||||
if step["name"] and (not streets or streets[-1] != step["name"]):
|
||||
streets.append(step["name"])
|
||||
legs = ", ".join(f"{round(l['distance'])} m" for l in route["legs"])
|
||||
|
||||
lines = [
|
||||
"// GENERATED by tools/make_route.py - do not edit by hand; change WAYPOINTS there and rerun.",
|
||||
"// Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.",
|
||||
"//",
|
||||
f"// Driving loop through {len(WAYPOINTS)} waypoints: {round(route['distance'])} m "
|
||||
f"(legs {legs}), {len(pts)} points after",
|
||||
f"// simplifying to {SIMPLIFY_M} m. Streets: {', '.join(streets)}.",
|
||||
"#ifndef ROUTE_POINTS_H",
|
||||
"#define ROUTE_POINTS_H",
|
||||
'#include "route.h"',
|
||||
"",
|
||||
"static const route_point_t route_points[] = {",
|
||||
]
|
||||
lines += [f" {{ {lat}, {lon} }}," for lat, lon in pts]
|
||||
lines += ["};", "", "#endif", ""]
|
||||
HEADER.write_text("\n".join(lines), encoding="utf-8", newline="\n")
|
||||
|
||||
MAP_HTML.write_text(f"""<!doctype html><meta charset="utf-8"><title>Beacon route</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>html,body,#m{{height:100%;margin:0}}</style><div id="m"></div><script>
|
||||
const pts={json.dumps([[p[0] / 1e7, p[1] / 1e7] for p in pts])};
|
||||
const wps={json.dumps(WAYPOINTS)};
|
||||
const m=L.map('m');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,
|
||||
attribution:'© OpenStreetMap contributors'}}).addTo(m);
|
||||
const line=L.polyline(pts.concat([pts[0]]),{{color:'#d33',weight:4}}).addTo(m);
|
||||
wps.forEach((w,i)=>L.marker(w,{{title:'wp'+(i+1)}}).bindTooltip('wp'+(i+1),{{permanent:true}}).addTo(m));
|
||||
L.circleMarker(pts[0],{{radius:7,color:'#060'}}).bindTooltip('start').addTo(m);
|
||||
m.fitBounds(line.getBounds(),{{padding:[20,20]}});
|
||||
</script>
|
||||
""", encoding="utf-8")
|
||||
|
||||
print(f"{round(route['distance'])} m, legs {legs}")
|
||||
print(f"{len(route['geometry']['coordinates'])} OSRM points -> {len(pts)} after simplifying")
|
||||
print(f"wrote {HEADER.relative_to(PROJECT)}, {OSRM_JSON.relative_to(PROJECT)}, "
|
||||
f"{MAP_HTML.relative_to(PROJECT)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,14 +0,0 @@
|
||||
<!doctype html><meta charset="utf-8"><title>Beacon route</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>html,body,#m{height:100%;margin:0}</style><div id="m"></div><script>
|
||||
const pts=[[53.553177, 10.022098], [53.553447, 10.02406], [53.553579, 10.024416], [53.55364, 10.024452], [53.553713, 10.024416], [53.553848, 10.024284], [53.553872, 10.024214], [53.554039, 10.02402], [53.554888, 10.023393], [53.554947, 10.023513], [53.555414, 10.025328], [53.555357, 10.025458], [53.554607, 10.02517], [53.554313, 10.025002], [53.554257, 10.024913], [53.554276, 10.02478], [53.553998, 10.02446], [53.553872, 10.024214], [53.553814, 10.024196], [53.553572, 10.024338], [53.55353, 10.024447], [53.553509, 10.024658], [53.553683, 10.026446], [53.55376, 10.027092], [53.553852, 10.027522], [53.554111, 10.029083], [53.554017, 10.029155], [53.553989, 10.029443], [53.553958, 10.029533], [53.553229, 10.02993], [53.552798, 10.030245], [53.552513, 10.029188], [53.552422, 10.028976], [53.55229, 10.028782], [53.552289, 10.02853], [53.552261, 10.028281], [53.552062, 10.027605], [53.551982, 10.027113], [53.551967, 10.026903], [53.552022, 10.026712], [53.552093, 10.026284], [53.552185, 10.026002], [53.552284, 10.025821], [53.552779, 10.025788], [53.553865, 10.026137], [53.555166, 10.026647], [53.555579, 10.026875], [53.555962, 10.027154], [53.556112, 10.027205], [53.556243, 10.027125], [53.556383, 10.026939], [53.556961, 10.026019], [53.557953, 10.024281], [53.558409, 10.023766], [53.558585, 10.023467], [53.558497, 10.023006], [53.5584, 10.022726], [53.558073, 10.022203], [53.55791, 10.021859], [53.55745, 10.020843], [53.557056, 10.019901], [53.556813, 10.019482], [53.556491, 10.018762], [53.556399, 10.018463], [53.556254, 10.018116], [53.555978, 10.017631], [53.554218, 10.013684], [53.553972, 10.013343], [53.553735, 10.013201], [53.553476, 10.013139], [53.552384, 10.013269], [53.552498, 10.015509], [53.552489, 10.015636], [53.552444, 10.015765], [53.552303, 10.015853], [53.551761, 10.015858], [53.550444, 10.016881], [53.550136, 10.01564], [53.549925, 10.014488], [53.549847, 10.013388], [53.549863, 10.012615], [53.549911, 10.012198], [53.550426, 10.011723], [53.550572, 10.01155], [53.550763, 10.011231], [53.55084, 10.011156], [53.551, 10.011094], [53.551139, 10.011956], [53.551244, 10.012228], [53.551451, 10.013277], [53.551502, 10.013412], [53.551663, 10.013563], [53.551826, 10.01361], [53.552395, 10.013487], [53.552498, 10.015509], [53.552489, 10.015636], [53.552444, 10.015765], [53.552347, 10.015846], [53.551856, 10.015848], [53.551907, 10.016243], [53.552226, 10.017808], [53.552788, 10.019765], [53.553006, 10.020902]];
|
||||
const wps=[[53.553309, 10.022043], [53.553611, 10.024146], [53.556164, 10.027121], [53.55831, 10.023362], [53.554062, 10.013515], [53.55154, 10.013398]];
|
||||
const m=L.map('m');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,
|
||||
attribution:'© OpenStreetMap contributors'}).addTo(m);
|
||||
const line=L.polyline(pts.concat([pts[0]]),{color:'#d33',weight:4}).addTo(m);
|
||||
wps.forEach((w,i)=>L.marker(w,{title:'wp'+(i+1)}).bindTooltip('wp'+(i+1),{permanent:true}).addTo(m));
|
||||
L.circleMarker(pts[0],{radius:7,color:'#060'}).bindTooltip('start').addTo(m);
|
||||
m.fitBounds(line.getBounds(),{padding:[20,20]});
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -259,13 +259,8 @@ 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. 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();
|
||||
}
|
||||
// the struct out before returning, so reusing s_cb_item on the next callback is fine.
|
||||
xQueueSend(s_rx_queue, &s_cb_item, 0);
|
||||
}
|
||||
|
||||
static void rx_forward_task(void *arg)
|
||||
|
||||
@@ -21,7 +21,6 @@ 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
|
||||
@@ -46,12 +45,6 @@ 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
|
||||
@@ -172,10 +165,9 @@ 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]
|
||||
// [rx_queue_drops:2 LE] - keep in lockstep with EspLinkStatus.parse() in the app's
|
||||
// SerialFrame.kt.
|
||||
uint8_t payload[10];
|
||||
// [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];
|
||||
payload[0] = status;
|
||||
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
|
||||
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
|
||||
@@ -186,8 +178,6 @@ 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -52,14 +52,11 @@
|
||||
// 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 10 bytes:
|
||||
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 8 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;
|
||||
// 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.
|
||||
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected.
|
||||
// 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.
|
||||
@@ -168,13 +165,4 @@ 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
|
||||
|
||||
@@ -14,7 +14,9 @@ PYTHON_ASN1 = py -3.11
|
||||
FW = ../../main
|
||||
BUILD = build
|
||||
EXE = $(if $(filter Windows_NT,$(OS)),.exe,)
|
||||
RECORDINGS = $(wildcard ../../../its-g5-receiver-firmware/recordings/*.pcap)
|
||||
# Captures live in capture/recordings/ since 2026-09-14; older ones are still in the
|
||||
# receiver checkout beside this repo.
|
||||
RECORDINGS = $(wildcard ../../../capture/recordings/*.pcap ../../../its-g5-receiver-firmware/recordings/*.pcap)
|
||||
FUZZ_ITER = 2000000
|
||||
FUZZ_SEED = 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user