Compare commits

..
Author SHA1 Message Date
Ashin Walpola 7285fa19b7 Count and surface RX-queue drops on the ESP32-C5's promiscuous path
wifi_promisc_rx_cb() fed s_rx_queue with a 0-timeout xQueueSend() and never
checked whether it succeeded, so a burst of captured frames arriving faster
than rx_forward_task could drain them vanished with no counter anywhere -
none of oversizeDrops/txFailures/rxCrcErrors caught it. Added a rxQueueDrops
counter, threaded it through the STATUS heartbeat as a new trailing uint16
(old firmware/app on either side still parse fine), and surfaced it on the
CAM Pinger card.

Confirmed on the bench: flashed to the production OBU (COM3) and installed
the matching app build on the phone, then watched the counter over logcat
against obu-cam-transmistter's ~3.3 Hz beacon - it is real (0 -> 89 -> 90
across two sessions) but bursty around connect/reconnect rather than a
continuous overflow under steady single-station traffic.
2026-09-22 14:45:17 +02:00
Ashin Walpola 21e01499d8 Drive the bench CAM beacon round a street loop in St. Georg
The bench transmitter sent a parked car: one fixed position, speed 0,
no heading, a CAM every second. It now simulates a car driving a loop
through six waypoints around Berliner Tor on the real streets, which
makes it a moving target for the app's map and the use case detection
without taking a car out.

The route is generated, not hand-traced. tools/make_route.py asks the
OSRM demo server for a driving route through the waypoints and back to
the first, thins the 371 street points to 103 (none more than 1.5 m off
the line), and writes main/route_points.h. It also saves OSRM's answer
(--offline rebuilds from it) and a map page to check the route before
flashing. Each waypoint is sent with the direction towards the next one:
without it, points on divided roads such as Beim Strohhause snapped to
the opposite carriageway and the loop came out at 8.4 km of U-turns.
With it the loop is 5.2 km, still including two turn-round detours
that OSRM needs to reach the waypoints legally (Borgfelder Strasse /
Anckelmannsplatz, and Nagelsweg / Norderstrasse / Repsoldstrasse).
Route data (c) OpenStreetMap contributors, ODbL.

main/route.c moves the car along the points. It cruises at 50 km/h and
limits each bend to the speed that keeps sideways acceleration at
2 m/s^2, so a junction turn is taken at about 15 km/h and a gentle curve
barely slows it; braking (2 m/s^2) and acceleration (1.5 m/s^2) are
planned across as many points as a bend needs. A simulated lap on the
host is 5.16 km in 7.7 min, averaging 40 km/h.

CAMs now follow the EN 302 637-2 generation rules instead of a fixed
1 Hz: checked every 100 ms, sent on a heading change over 4 degrees, a
move over 4 m, a speed change over 0.5 m/s, or after 1 s - about 3 Hz
at 50 km/h. generationDeltaTime is milliseconds since boot. The
GeoNetworking source position vector now carries the same speed and
heading as the CAM instead of zeros.

NOTES.md gains build and flash steps (including reading a board's app
descriptor first, since both firmwares name their image
obu_firmware.bin) and a section on the simulated drive. The pointer to
docs/04-transmit-setup.md is corrected: that file is not in the repo.

Flashed to the COM8 board and checked on its console: it starts driving
on power-up and sends CAMs with changing position, speed and heading.
Not yet received over the air.
2026-09-16 14:21:44 +02:00
Ashin Walpola 01204a2c22 Give the V2X live map its own screen, and a traffic light per SPATEM
The map was a third view mode inside the V2X Monitor's topic pane, below
the use case alert panel and the DENM/CAM TX cards. On a phone that left
it about a third of the display tall, which is not enough to see where
anything is relative to anything else - the one thing a map is for. It
is now its own destination, V2xMapScreen on route v2x_map, reached from
a map button in that screen's header. The button sits in the header
rather than the view-mode row so it is also reachable from the message
detail pane and does not move as the available modes change with the
selected hardware. The status bar and bottom nav are hidden on this
route; the screen carries its own floating back button, and system back
still works. Both hardware paths get the same screen: everything drawn
comes from CamUseCaseRepository, which already merges the CiT One's MQTT
feed and the ESP32-C5's serial feed into one set of flows.

With the map gone from the toggle row, the row offers a single choice on
the ESP32-C5 path - there is no broker there and `topics` is always
empty - so it is hidden entirely in that mode.

SPATEM markers. Hazards already drew as a warning triangle; signalised
intersections did not draw at all. They now draw as a traffic light with
one lamp lit. Two things are worth knowing, because neither is forced by
the data:

- SPATEM carries signal state but no geometry, which is MAPEM's job and
  MAPEM is not decoded. The only position available is the sending RSU's
  own CAM, so the light is drawn there, and that RSU is drawn once - as
  the light, not as a CAM pin with a light on top of it. An intersection
  whose sender has not been heard over CAM cannot be placed; the map
  says how many rather than dropping them silently.
- Which lamp lights follows the rule DashboardScreen's SignalCard
  already uses, the signal group changing soonest speaking for the
  intersection, so the same intersection reads the same way in both
  places instead of inventing a second convention.

Four drawables rather than one tinted at runtime: setTint recolours
every path in a vector, so a single shared asset would turn the whole
light one flat colour and stop it reading as a traffic light.

Marker reuse. Every incoming message recomposes the map, and the update
block cleared the overlay list and rebuilt every Marker, decoding and
mutating a fresh Drawable per marker - at up to 10 Hz per station. It
also called animateTo(own) on every update, restarting the pan animation
before it could finish. Drawables are now loaded once per alert level
and phase and shared (osmdroid sets the icon's bounds on each draw, so
one instance across markers is safe), Markers are cached by key, and the
overlay list is only reordered, which moves references without
allocating. Following uses setCenter, keeping animateTo for the one move
worth seeing: the rider asking for follow back.

Follow-own now hands over to the rider on the first touch and returns
via the location button, which lights up while following. Before this
the map could not be panned at all while traffic was flowing, since the
next CAM dragged the viewport back.

Also on the map view: tiles scaled to DPI, the floating +/- buttons off
(they sit where the thumb lands and duplicate pinch), a zoom range, and
more tile threads so a pan that exposes a screenful of new tiles is not
served two at a time.

Compiles and the unit tests pass. None of it has been seen with live
traffic; TODO.md lists the on-device checks under "Waiting on hardware",
including which of the HAW RSUs send CAM alongside SPATEM.
2026-09-15 17:23:01 +02:00
34 changed files with 7532 additions and 725 deletions
-3
View File
@@ -46,6 +46,3 @@ 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/
+180 -41
View File
@@ -5,44 +5,148 @@ 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. Confirmed on air 2026-09-14. Nothing else
compiled `geonet_wrap_shb` stores the new byte, but it has not been seen on air yet. 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.
- [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).
- [ ] 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).
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. Since 2026-09-14 the spare board on COM10 runs it as a bench beacon:
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:
- [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.
- [ ] After flashing it: capture, run `pcap_gn_tally.py`, and decode the CAM payload with
asn1tools (`py -3.11`, modules in `asn1/`).
### Signed-message reception and exact payloads (added 2026-09-11)
@@ -50,21 +154,69 @@ 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 flashed to the production OBU on 2026-09-14. The remaining gap is signed
traffic to receive: real vehicles or
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
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.
- [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).
- [ ] Flash obu-firmware (this also carries the GN lifetime fix above).
- [ ] 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`). 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.
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.
## Set up host testing
@@ -101,19 +253,6 @@ 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,6 +26,7 @@ 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
@@ -40,6 +41,7 @@ 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
@@ -104,6 +106,13 @@ 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 ->
@@ -132,14 +141,16 @@ class MainActivity : AppCompatActivity() {
Scaffold(
topBar = {
StatusTopBar(
state = state,
mqttConnectionState = mqttConnectionState,
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
usbSerialState = usbSerialState,
)
if (!isFullBleed) {
StatusTopBar(
state = state,
mqttConnectionState = mqttConnectionState,
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
usbSerialState = usbSerialState,
)
}
},
bottomBar = { BottomNavBar(navController) },
bottomBar = { if (!isFullBleed) BottomNavBar(navController) },
) { innerPadding ->
NavHost(
navController = navController,
@@ -253,7 +264,19 @@ class MainActivity : AppCompatActivity() {
}
composable(Screen.MqttViewer.route) {
MqttTopicViewerScreen(viewModel = mqttViewModel)
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() },
)
}
composable(Screen.Settings.route) {
SettingsScreen(
@@ -57,8 +57,9 @@ const val SERIAL_LINK_MAX_PAYLOAD = 512
/**
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
* [rxCrcErrors:2 LE]` (7 bytes). Counters are free-running totals since firmware boot and
* saturate at 0xFFFF rather than wrapping.
* [rxCrcErrors:2 LE][capabilities:1][rxQueueDrops:2 LE]` (10 bytes; the last two fields are an
* optional tail — see [capabilities] and [rxQueueDrops]). Counters are free-running totals since
* firmware boot and saturate at 0xFFFF rather than wrapping.
*
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
@@ -79,6 +80,15 @@ data class EspLinkStatus(
* the phone needs from such firmware: it accepts nothing beyond the original messages.
*/
val capabilities: Int = 0,
/**
* Promiscuously-captured frames the firmware's `wifi_promisc_rx_cb` had to drop because its
* RX queue (8 deep) was still full of frames `rx_forward_task` hadn't finished forwarding —
* bytes 8-9 of the payload. 0 for firmware that predates this field (payload of 7 or 8 bytes),
* which is the honest answer: such firmware drops these frames identically, it just never
* counted them. A nonzero, growing value here — as opposed to [oversizeDrops] — points at
* bursty RX outrunning the forward task rather than any one frame being too large.
*/
val rxQueueDrops: Int = 0,
) {
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
@@ -99,6 +109,7 @@ data class EspLinkStatus(
txFailures = u16(3),
rxCrcErrors = u16(5),
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
rxQueueDrops = if (payload.size >= 10) u16(8) else 0,
)
}
}
@@ -330,13 +330,15 @@ class UsbSerialTransport @Inject constructor(
prev.txFailures != status.txFailures ||
prev.rxCrcErrors != status.rxCrcErrors ||
prev.status != status.status ||
prev.capabilities != status.capabilities
prev.capabilities != status.capabilities ||
prev.rxQueueDrops != status.rxQueueDrops
) {
Log.i(TAG, "ESP32 counters: status=${status.status} " +
"oversizeDrops=${status.oversizeDrops} " +
"txFailures=${status.txFailures} " +
"rxCrcErrors=${status.rxCrcErrors} " +
"capabilities=${status.capabilities}")
"capabilities=${status.capabilities} " +
"rxQueueDrops=${status.rxQueueDrops}")
}
_linkStatus.value = status
}
@@ -34,6 +34,8 @@ 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)
@@ -83,6 +85,7 @@ 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,6 +29,7 @@ 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
@@ -87,11 +88,15 @@ import java.util.Date
import java.util.Locale
/**
* 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).
* 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.
*/
private enum class TopicViewMode { LIST, TOPICS, MAP }
private enum class TopicViewMode { LIST, TOPICS }
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
@@ -116,6 +121,7 @@ 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()
@@ -193,6 +199,17 @@ 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(
@@ -335,49 +352,38 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
// ── List / Topics / Map toggle ────────────────────────────────────────────────────
// ── List / Topics 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). 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 }
// 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 }
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 / live map ───────────────────────────
// ── Decoded traffic / raw topics ──────────────────────────────────────
// 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.MAP) {
V2xLiveMapView(
own = ownCamPosition,
remotes = remoteCamPositions,
alerts = useCaseAlerts,
denms = denmEvents,
modifier = Modifier.fillMaxSize(),
)
} else if (shownMode == TopicViewMode.LIST) {
if (shownMode == TopicViewMode.LIST) {
ReceivedCamPane(
own = ownCamPosition,
remotes = remoteCamPositions,
@@ -1213,11 +1219,12 @@ private fun CamPingerCard(
Text(
stringResource(
R.string.mqtt_cam_pinger_fw_counters,
s.txFailures, s.oversizeDrops, s.rxCrcErrors,
s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.rxQueueDrops,
),
style = MaterialTheme.typography.labelSmall,
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0)
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0 ||
s.rxQueueDrops > 0
) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
@@ -1,12 +1,13 @@
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
@@ -30,25 +31,37 @@ 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 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).
* 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.
*
* 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].
* 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.
*/
@Composable
fun V2xLiveMapView(
@@ -56,6 +69,9 @@ 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
@@ -71,8 +87,17 @@ 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 ->
@@ -89,106 +114,236 @@ fun V2xLiveMapView(
}
}
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
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
}
},
update = { mv ->
mv.overlays.clear()
mapViewRef.value = this
}
},
update = { mv ->
val now = System.currentTimeMillis()
// 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)
}
)
// 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()
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
}
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) } ?: "")
}
}
}
// 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)
}
)
}
// 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) }
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(),
)
// 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
}
}
@@ -228,5 +383,10 @@ 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()
}
}
@@ -0,0 +1,203 @@
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)
@@ -0,0 +1,38 @@
<!--
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>
@@ -0,0 +1,38 @@
<!--
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>
@@ -0,0 +1,38 @@
<!--
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>
@@ -0,0 +1,38 @@
<!--
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>
+7 -2
View File
@@ -142,12 +142,18 @@
<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>
@@ -197,7 +203,6 @@
<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>
+8 -3
View File
@@ -143,12 +143,18 @@
<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>
@@ -198,7 +204,6 @@
<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>
@@ -259,7 +264,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</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_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,6 +166,21 @@ class CamTxPvSerialTest {
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
}
@Test
fun `firmware that predates the rx queue drop counter reports zero`() {
// 8-byte heartbeat (status + counters + capabilities, no rx queue drops tail).
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
assertEquals(0, status.rxQueueDrops)
}
@Test
fun `rx queue drops are read little-endian from the 10-byte payload`() {
// status=0, oversize=0, txFail=0, rxCrc=0, capabilities=0x01, rxQueueDrops=0x0102 (LE: 02 01)
val status = EspLinkStatus.parse("00000000000000010201".hexToBytes())!!
assertEquals(0x0102, status.rxQueueDrops)
assertTrue(status.supportsCamTxPv)
}
private val mac = "024d49435230".hexToBytes()
private fun vectorAt(tstMs: Long) = GnPositionVector(
-102
View File
@@ -1,102 +0,0 @@
# 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
```
-101
View File
@@ -1,101 +0,0 @@
#!/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()
-226
View File
@@ -1,226 +0,0 @@
#!/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()
+123 -50
View File
@@ -1,7 +1,7 @@
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
Started. See `docs/04-transmit-setup.md` in the project root for build/flash
steps and how to validate this against your own sniffer.
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.)
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4)
@@ -29,6 +29,123 @@ 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
@@ -44,56 +161,12 @@ 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 (lat/long hardcoded
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
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
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.
+2 -2
View File
@@ -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"
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c" "route.c"
INCLUDE_DIRS "."
REQUIRES esp_event esp_netif nvs_flash driver esp_phy
REQUIRES esp_event esp_timer esp_netif nvs_flash driver esp_phy
PRIV_REQUIRES esp_wifi
)
+7 -6
View File
@@ -4,6 +4,7 @@
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)
{
@@ -68,12 +69,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), 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;
// 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);
// 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
+5
View File
@@ -31,6 +31,10 @@
// 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, ...
//
@@ -38,6 +42,7 @@
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);
+82 -30
View File
@@ -1,7 +1,11 @@
#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"
@@ -14,13 +18,17 @@
#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: 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.
// 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).
// ISOLATION TEST for whether tx_custom.c is the blocker.
// 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a
@@ -55,15 +63,19 @@ 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 TX_INTERVAL_MS 1000 // CAM beacon period (1 Hz; ITS allows 1-10 Hz)
#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
// 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
// ---- 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
// 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
@@ -78,33 +90,28 @@ 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(void)
static void send_cam(const route_state_t *car, uint16_t gen_delta)
{
// 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 = BENCH_LATITUDE_TENMICRODEG,
.longitude_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG,
.speed_cm_s = 0, // stationary
.heading_ddeg = 3601, // HeadingValue unavailable (no heading source)
.latitude_tenmicrodeg = car->latitude_tenmicrodeg,
.longitude_tenmicrodeg = car->longitude_tenmicrodeg,
.speed_cm_s = car->speed_cm_s,
.heading_ddeg = car->heading_ddeg,
.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,
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
car->latitude_tenmicrodeg, car->longitude_tenmicrodeg,
car->speed_cm_s, car->heading_ddeg,
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
// qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS
@@ -127,7 +134,10 @@ static void send_cam(void)
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err);
} else {
ESP_LOGI(TAG, "CAM sent via STANDARD tx (%d bytes) @ %d MHz genDeltaT=%u", frame_len, TX_FREQ_MHZ, gen_delta);
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);
}
#else
// tx_custom path: submits to the driver's internal HMAC TX path,
@@ -151,12 +161,49 @@ static void send_cam(void)
}
}
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) {
// CAM is a continuous beacon - send every interval, unconditionally.
send_cam();
vTaskDelay(pdMS_TO_TICKS(TX_INTERVAL_MS));
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);
}
}
@@ -255,8 +302,13 @@ void app_main(void)
phy_change_channel(TX_FREQ_MHZ, 1, 0, 0);
ESP_LOGI(TAG, "phy_change_channel returned");
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, transmitting every %d ms",
TX_FREQ_MHZ, TX_INTERVAL_MS);
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])));
xTaskCreate(tx_task, "tx_task", 4096, NULL, 5, NULL);
}
+125
View File
@@ -0,0 +1,125 @@
#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;
}
+40
View File
@@ -0,0 +1,40 @@
#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
+116
View File
@@ -0,0 +1,116 @@
// 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
+170
View File
@@ -0,0 +1,170 @@
"""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:'&copy; 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()
+14
View File
@@ -0,0 +1,14 @@
<!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:'&copy; 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
+7 -2
View File
@@ -259,8 +259,13 @@ static void wifi_promisc_rx_cb(void *recv_buf, wifi_promiscuous_pkt_type_t type)
s_cb_item.rssi = packet->rx_ctrl.rssi;
// 0 timeout: never block the WiFi driver's own task waiting for queue space. xQueueSend copies
// the struct out before returning, so reusing s_cb_item on the next callback is fine.
xQueueSend(s_rx_queue, &s_cb_item, 0);
// the struct out before returning, so reusing s_cb_item on the next callback is fine. The
// return value used to go unchecked, so a full queue (rx_forward_task still draining a
// previous burst) silently ate frames with no counter anywhere - see
// serial_link_note_rx_queue_drop()'s KDoc.
if (xQueueSend(s_rx_queue, &s_cb_item, 0) != pdTRUE) {
serial_link_note_rx_queue_drop();
}
}
static void rx_forward_task(void *arg)
+13 -3
View File
@@ -21,6 +21,7 @@ static serial_link_cam_tx_pv_cb_t s_on_cam_tx_pv;
static uint16_t s_oversize_drops;
static uint16_t s_tx_failures;
static uint16_t s_rx_crc_errors;
static uint16_t s_rx_queue_drops;
// Serializes send_frame(): it writes a frame as four separate usb_serial_jtag_write_bytes() calls
// and shares one static CRC scratch buffer, and it's now called from three tasks (rx_forward for
@@ -45,6 +46,12 @@ void serial_link_note_oversize_drop(uint16_t btp_dest_port)
btp_dest_port, s_oversize_drops);
}
void serial_link_note_rx_queue_drop(void)
{
bump(&s_rx_queue_drops);
ESP_LOGW(TAG, "rx queue full, dropped a captured frame, total rx queue drops %u", s_rx_queue_drops);
}
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
@@ -165,9 +172,10 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
bool serial_link_send_status(uint8_t status)
{
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1] -
// keep in lockstep with EspLinkStatus.parse() in the app's SerialFrame.kt.
uint8_t payload[8];
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
// [rx_queue_drops:2 LE] - keep in lockstep with EspLinkStatus.parse() in the app's
// SerialFrame.kt.
uint8_t payload[10];
payload[0] = status;
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
@@ -178,6 +186,8 @@ bool serial_link_send_status(uint8_t status)
// What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which
// is what lets a new app keep working against firmware that predates that message.
payload[7] = SERIAL_CAP_CAM_TX_PV;
payload[8] = (uint8_t)(s_rx_queue_drops & 0xFF);
payload[9] = (uint8_t)((s_rx_queue_drops >> 8) & 0xFF);
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
}
+14 -2
View File
@@ -52,11 +52,14 @@
// message's own ItsPduHeader.stationID is the meaningful identifier.
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 8 bytes:
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 10 bytes:
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
// [rx_queue_drops:2 LE]
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected.
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected;
// rx_queue_drops (bytes 8-9) follows the same rule for an app that predates it. Either side
// reading a payload shorter than the field it wants should treat that field as 0, not error.
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
// in the app's SerialFrame.kt.
@@ -165,4 +168,13 @@ void serial_link_note_tx_failure(void);
// whose capture buffer is smaller than the largest frames on air.
void serial_link_note_oversize_drop(uint16_t btp_dest_port);
// Counts a promiscuously-captured frame that main.c's wifi_promisc_rx_cb() could not hand to
// rx_forward_task because s_rx_queue was full - i.e. frames arrived faster than the forward task
// (gn_unwrap + a blocking USB write, up to SERIAL_LINK_WRITE_TIMEOUT_MS x 4 per frame under
// contention) could drain them. Unlike oversize_drop this is not about one frame's size; it is
// about a burst of otherwise-forwardable frames. Previously silent - xQueueSend's return value
// was not even checked - so a run of these had no visible symptom beyond "that station's CAM
// count looked a little low."
void serial_link_note_rx_queue_drop(void);
#endif
+1 -3
View File
@@ -14,9 +14,7 @@ PYTHON_ASN1 = py -3.11
FW = ../../main
BUILD = build
EXE = $(if $(filter Windows_NT,$(OS)),.exe,)
# 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)
RECORDINGS = $(wildcard ../../../its-g5-receiver-firmware/recordings/*.pcap)
FUZZ_ITER = 2000000
FUZZ_SEED = 1