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
29 changed files with 7512 additions and 203 deletions
+161
View File
@@ -5,6 +5,94 @@ Engineering to-do list. The reviewer-facing open items live in
## Waiting on hardware ## 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) ### 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 `geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
@@ -28,6 +116,27 @@ Partial check possible with one board and no phone: flash it, `idf.py -p COMx mo
for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not
that it transmits correctly. 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) ### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11)
Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of
@@ -57,6 +166,58 @@ optional, but shows what was on air at the time.
shorter). shorter).
- [ ] The heartbeat's oversize counter still counts over-long messages (e.g. road SPATEMs). - [ ] 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 ## Set up host testing
- [x] Install MSYS2 UCRT64 gcc (done 2026-09-11: gcc 16.2.0, GNU Make 4.4.1; chosen over WSL, - [x] Install MSYS2 UCRT64 gcc (done 2026-09-11: gcc 16.2.0, GNU Make 4.4.1; chosen over WSL,
@@ -26,6 +26,7 @@ import androidx.core.view.WindowCompat
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState 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.RecordingScreen
import com.hawhamburg.micr0bu.ui.screens.SensorScreen import com.hawhamburg.micr0bu.ui.screens.SensorScreen
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen 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.MapScreen
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
@@ -104,6 +106,13 @@ class MainActivity : AppCompatActivity() {
} }
val navController = rememberNavController() 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( val locationLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
) { permissions -> ) { permissions ->
@@ -132,14 +141,16 @@ class MainActivity : AppCompatActivity() {
Scaffold( Scaffold(
topBar = { topBar = {
StatusTopBar( if (!isFullBleed) {
state = state, StatusTopBar(
mqttConnectionState = mqttConnectionState, state = state,
isEsp32 = obuHardware == ObuHardware.ESP32_C5, mqttConnectionState = mqttConnectionState,
usbSerialState = usbSerialState, isEsp32 = obuHardware == ObuHardware.ESP32_C5,
) usbSerialState = usbSerialState,
)
}
}, },
bottomBar = { BottomNavBar(navController) }, bottomBar = { if (!isFullBleed) BottomNavBar(navController) },
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
@@ -253,7 +264,19 @@ class MainActivity : AppCompatActivity() {
} }
composable(Screen.MqttViewer.route) { 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) { composable(Screen.Settings.route) {
SettingsScreen( SettingsScreen(
@@ -57,8 +57,9 @@ const val SERIAL_LINK_MAX_PAYLOAD = 512
/** /**
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE] * 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 * [rxCrcErrors:2 LE][capabilities:1][rxQueueDrops:2 LE]` (10 bytes; the last two fields are an
* saturate at 0xFFFF rather than wrapping. * 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 * 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 * 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. * the phone needs from such firmware: it accepts nothing beyond the original messages.
*/ */
val capabilities: Int = 0, 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]. */ /** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0 val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
@@ -99,6 +109,7 @@ data class EspLinkStatus(
txFailures = u16(3), txFailures = u16(3),
rxCrcErrors = u16(5), rxCrcErrors = u16(5),
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0, 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.txFailures != status.txFailures ||
prev.rxCrcErrors != status.rxCrcErrors || prev.rxCrcErrors != status.rxCrcErrors ||
prev.status != status.status || prev.status != status.status ||
prev.capabilities != status.capabilities prev.capabilities != status.capabilities ||
prev.rxQueueDrops != status.rxQueueDrops
) { ) {
Log.i(TAG, "ESP32 counters: status=${status.status} " + Log.i(TAG, "ESP32 counters: status=${status.status} " +
"oversizeDrops=${status.oversizeDrops} " + "oversizeDrops=${status.oversizeDrops} " +
"txFailures=${status.txFailures} " + "txFailures=${status.txFailures} " +
"rxCrcErrors=${status.rxCrcErrors} " + "rxCrcErrors=${status.rxCrcErrors} " +
"capabilities=${status.capabilities}") "capabilities=${status.capabilities} " +
"rxQueueDrops=${status.rxQueueDrops}")
} }
_linkStatus.value = status _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 Connection : Screen("connection", R.string.nav_connection)
data object Map : Screen("map", R.string.map_title) data object Map : Screen("map", R.string.map_title)
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x) 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 // Phase A — Trip Recording
data object TripHistory : Screen("trip_history", R.string.nav_trips) 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.Map.route ||
route == Screen.Sensors.route route == Screen.Sensors.route
Screen.Record -> route == Screen.Log.route Screen.Record -> route == Screen.Log.route
Screen.MqttViewer -> route == Screen.V2xMap.route
else -> false 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.Circle
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff 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.NotificationsActive
import androidx.compose.material.icons.filled.VerticalAlignBottom import androidx.compose.material.icons.filled.VerticalAlignBottom
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
@@ -87,11 +88,15 @@ import java.util.Date
import java.util.Locale import java.util.Locale
/** /**
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST), the raw MQTT topic list * 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), or the V2X live map * (TOPICS, CiT One only - there is no broker on the ESP32-C5 path).
* (MAP, Section 13). *
* 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) private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
@@ -116,6 +121,7 @@ private val WarningRedBg = Color(0xFF3A0A0A)
@Composable @Composable
fun MqttTopicViewerScreen( fun MqttTopicViewerScreen(
viewModel: MqttViewModel = hiltViewModel(), viewModel: MqttViewModel = hiltViewModel(),
onOpenMap: () -> Unit = {},
) { ) {
val connectionState by viewModel.connectionState.collectAsState() val connectionState by viewModel.connectionState.collectAsState()
val topicMessages by viewModel.topicMessages.collectAsState() val topicMessages by viewModel.topicMessages.collectAsState()
@@ -193,6 +199,17 @@ fun MqttTopicViewerScreen(
Spacer(Modifier.weight(1f)) 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) ConnectionChip(effectiveState)
Spacer(Modifier.width(2.dp)) Spacer(Modifier.width(2.dp))
IconButton( IconButton(
@@ -335,49 +352,38 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) 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 // 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 // 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 // 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 // to", not instead of, the topic list).
// no broker and `topics` is permanently empty. //
Row( // The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), // permanently empty, and a toggle offering a single choice is just noise.
horizontalArrangement = Arrangement.spacedBy(8.dp), if (!isEsp32) {
) { Row(
ViewModeButton( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
label = stringResource(R.string.mqtt_view_list), horizontalArrangement = Arrangement.spacedBy(8.dp),
selected = viewMode == TopicViewMode.LIST, ) {
) { viewMode = TopicViewMode.LIST } ViewModeButton(
label = stringResource(R.string.mqtt_view_list),
selected = viewMode == TopicViewMode.LIST,
) { viewMode = TopicViewMode.LIST }
if (!isEsp32) {
ViewModeButton( ViewModeButton(
label = stringResource(R.string.mqtt_view_topics), label = stringResource(R.string.mqtt_view_topics),
selected = viewMode == TopicViewMode.TOPICS, selected = viewMode == TopicViewMode.TOPICS,
) { 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 // 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 // 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. // 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 val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
if (shownMode == TopicViewMode.MAP) { if (shownMode == TopicViewMode.LIST) {
V2xLiveMapView(
own = ownCamPosition,
remotes = remoteCamPositions,
alerts = useCaseAlerts,
denms = denmEvents,
modifier = Modifier.fillMaxSize(),
)
} else if (shownMode == TopicViewMode.LIST) {
ReceivedCamPane( ReceivedCamPane(
own = ownCamPosition, own = ownCamPosition,
remotes = remoteCamPositions, remotes = remoteCamPositions,
@@ -1213,11 +1219,12 @@ private fun CamPingerCard(
Text( Text(
stringResource( stringResource(
R.string.mqtt_cam_pinger_fw_counters, 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, style = MaterialTheme.typography.labelSmall,
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0) color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0 ||
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant, s.rxQueueDrops > 0
) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
) )
} }
@@ -1,12 +1,13 @@
package com.hawhamburg.micr0bu.ui.screens package com.hawhamburg.micr0bu.ui.screens
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable
import android.view.MotionEvent
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GpsOff 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.R
import com.hawhamburg.micr0bu.domain.cam.Cam import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.denm.DenmEvent 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.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
/** /**
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus * V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the
* every currently-tracked remote road user's last-known CAM position, in addition to (not * ego bike's own position, every currently-tracked remote road user's last-known CAM position,
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as * every live hazard (DENM) and every signalised intersection heard over SPATEM.
* [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).
* *
* Remote markers are colored by that station's most severe active alert level, if any, so a * Marker vocabulary, one shape per message type so the map reads without a legend:
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same * - CAM — teardrop pin, tinted by that station's most severe active alert level
* severity coloring already used by [UseCaseAlertPanel]. * - 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 @Composable
fun V2xLiveMapView( fun V2xLiveMapView(
@@ -56,6 +69,9 @@ fun V2xLiveMapView(
remotes: Map<Long, Cam>, remotes: Map<Long, Cam>,
alerts: List<UseCaseAlert>, alerts: List<UseCaseAlert>,
denms: List<DenmEvent> = emptyList(), denms: List<DenmEvent> = emptyList(),
spats: List<SpatIntersection> = emptyList(),
followOwn: Boolean = true,
onUserPanned: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -71,8 +87,17 @@ fun V2xLiveMapView(
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel } .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 mapViewRef = remember { mutableStateOf<MapView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current 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) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
@@ -89,106 +114,236 @@ fun V2xLiveMapView(
} }
} }
Column(modifier = modifier.fillMaxSize()) { AndroidView(
Text( factory = { ctx ->
text = stringResource(R.string.v2x_map_remote_count, remotes.size), initOsmForV2xMap(ctx)
style = MaterialTheme.typography.labelMedium, MapView(ctx).apply {
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), setTileSource(TileSourceFactory.MAPNIK)
color = MaterialTheme.colorScheme.onSurfaceVariant, setMultiTouchControls(true)
) // Raster tiles are authored for ~160 dpi; without this they are upscaled by the
Spacer(Modifier.height(4.dp)) // display density and labels come out soft on a modern phone.
isTilesScaledToDpi = true
AndroidView( // The floating +/- buttons sit exactly where the rider's thumb lands and
factory = { ctx -> // duplicate pinch-zoom. Pinch and double-tap still work.
initOsmForV2xMap(ctx) zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
MapView(ctx).apply { setMinZoomLevel(4.0)
setTileSource(TileSourceFactory.MAPNIK) setMaxZoomLevel(20.0)
setMultiTouchControls(true) controller.setZoom(17.0)
controller.setZoom(17.0) controller.setCenter(ownGeoPoint)
controller.setCenter(ownGeoPoint) // Any touch means the rider is driving the map; follow-own hands over to them
mapViewRef.value = this // 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
} }
}, mapViewRef.value = this
update = { mv -> }
mv.overlays.clear() },
update = { mv ->
val now = System.currentTimeMillis()
// Own position: a centred "you are here" dot, not a pin. Own position is a fact // Intersections we can actually place: SPATEM carries signal state but no geometry
// about the viewer rather than one of the tracked objects, and when both used // (that is MAPEM's job), so the only position available is the sending RSU's own CAM.
// osmdroid's identical default pin the two were indistinguishable at a glance. val locatedSpats = spats.mapNotNull { spat ->
mv.overlays.add( remotes[spat.stationId]?.let { rsu -> spat to rsu }
Marker(mv).apply { }
position = ownGeoPoint // An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it:
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) // two markers on one point, the lower one unreachable.
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own) val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet()
title = context.getString(R.string.v2x_map_own_label)
}
)
remotes.forEach { (stationId, cam) -> val live = mutableSetOf<String>()
val level = alertByStation[stationId]
val label = when (level) { // Own position: a centred "you are here" dot, not a pin. Own position is a fact about
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId) // the viewer rather than one of the tracked objects, and when both used osmdroid's
AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId) // identical default pin the two were indistinguishable at a glance.
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId) markers.marker(mv, KEY_OWN, live).apply {
null -> context.getString(R.string.v2x_map_remote_plain, stationId) position = ownGeoPoint
} setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
// Teardrop pin anchored at its tip, tinted by severity. Now that these are icon = icons.own
// custom drawables, per-instance tinting is possible - severity no longer title = context.getString(R.string.v2x_map_own_label)
// 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. remotes.forEach { (stationId, cam) ->
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station) if (stationId in spatStationIds) return@forEach
?.mutate() val level = alertByStation[stationId]
?.apply { setTint(level.toMarkerColor()) } val label = when (level) {
mv.overlays.add( AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
Marker(mv).apply { AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId)
position = GeoPoint(cam.latitude, cam.longitude) AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) null -> context.getString(R.string.v2x_map_remote_plain, stationId)
icon = pin }
title = label // 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 // Drop markers for stations, hazards and intersections that have expired, then rebuild
// hidden behind a CAM pin defeats the point of showing it. // the overlay list in draw order from the cached Markers. Reordering moves references;
denms.forEach { denm -> // nothing here allocates a Marker or decodes a Drawable.
mv.overlays.add( markers.keys.retainAll { key ->
Marker(mv).apply { (key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() }
position = GeoPoint(denm.latitude, denm.longitude) }
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) mv.overlays.clear()
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning) markers.entries
title = denm.causeCode?.let { .sortedBy { (key, _) -> key.drawOrder() }
context.getString( .forEach { (_, marker) -> mv.overlays.add(marker) }
R.string.v2x_map_denm_labeled,
it,
denm.subCauseCode ?: 0,
denm.stationId,
)
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
}
)
}
mv.controller.animateTo(ownGeoPoint) // setCenter, not animateTo: an animation restarted on every CAM never finishes, which
mv.invalidate() // 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.
// osmdroid's onDetach() permanently tears the MapView down: afterwards its if (followOwn) {
// MapViewRepository holds a null MapView, so constructing a Marker against it throws if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint)
// NullPointerException from deep inside InfoWindow's constructor. else mv.controller.animateTo(ownGeoPoint)
// }
// This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect wasFollowing.value = followOwn
// 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 mv.invalidate()
// 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 // osmdroid's onDetach() permanently tears the MapView down: afterwards its
// to land in that window than there used to be. // MapViewRepository holds a null MapView, so constructing a Marker against it throws
// // NullPointerException from deep inside InfoWindow's constructor.
// onRelease is the callback that actually means "this View is gone": Compose //
// guarantees no further update after it. // This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect
onRelease = { it.onDetach() }, // is keyed on the lifecycle owner and disposes independently of this AndroidView, so
modifier = Modifier.fillMaxSize(), // 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 { Configuration.getInstance().apply {
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
userAgentValue = context.packageName 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="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
<string name="map_title">Standortkarte</string> <string name="map_title">Standortkarte</string>
<string name="map_location_label">Aktueller Standort</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_own_label">Eigen (Ego)</string>
<string name="v2x_map_remote_plain">Extern #%1$d</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_info">Extern #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</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_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 --> <!-- Settings -->
<string name="settings_title">Einstellungen</string> <string name="settings_title">Einstellungen</string>
@@ -197,7 +203,6 @@
<string name="mqtt_no_topics">Noch keine Nachrichten</string> <string name="mqtt_no_topics">Noch keine Nachrichten</string>
<string name="mqtt_view_list">Liste</string> <string name="mqtt_view_list">Liste</string>
<string name="mqtt_view_topics">Topics</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_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</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="gnss_no_fix">No GPS fix yet - move to an open area</string>
<string name="map_title">Location Map</string> <string name="map_title">Location Map</string>
<string name="map_location_label">Current Location</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_own_label">Own (ego)</string>
<string name="v2x_map_remote_plain">Remote #%1$d</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_info">Remote #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</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_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 --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
@@ -198,7 +204,6 @@
<string name="mqtt_no_topics">No messages yet</string> <string name="mqtt_no_topics">No messages yet</string>
<string name="mqtt_view_list">List</string> <string name="mqtt_view_list">List</string>
<string name="mqtt_view_topics">Topics</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_topics_hint">Connect to the OBU and wait for V2X traffic</string>
<string name="mqtt_no_messages">No messages on this topic yet</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_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_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_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">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_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string>
<string name="mqtt_start_pinger">Start Pinger</string> <string name="mqtt_start_pinger">Start Pinger</string>
@@ -166,6 +166,21 @@ class CamTxPvSerialTest {
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv) 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 val mac = "024d49435230".hexToBytes()
private fun vectorAt(tstMs: Long) = GnPositionVector( private fun vectorAt(tstMs: Long) = GnPositionVector(
+123 -4
View File
@@ -1,7 +1,7 @@
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon) # 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 Build and flash steps are under "Build and flash" below. (`docs/04-transmit-setup.md`,
steps and how to validate this against your own sniffer. referenced here and in the sources, is not in the repo.)
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4) ## 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 which makes `idf.py fullclean` refuse to run). If that error reappears, delete
`build/` manually rather than fighting it. `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 ## CAM encoding
`main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a `main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a
@@ -44,10 +161,12 @@ hazard-light GPIO is grounded. No location/alacarte containers.
- `main/main.c` - entry point, the `phy_11p_set`/`phy_change_channel(5900,...)` - `main/main.c` - entry point, the `phy_11p_set`/`phy_change_channel(5900,...)`
register hack, GPIO polling, TX loop register hack, GPIO polling, TX loop
- `main/denm.c` / `.h` - ASN.1 UPER encoding of a minimal DENM - `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/geonet.c` / `.h` - GeoNetworking Basic/Common/SHB headers + BTP-B
- `main/dot11p.c` / `.h` - 802.11 OCB (QoS Data, broadcast) frame + LLC/SNAP - `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 Known gaps, tracked as TODOs in the source: no real GNSS (the CAM position comes
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as 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 2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing). (no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
+2 -2
View File
@@ -1,8 +1,8 @@
# wifi_patches.c is intentionally NOT in this list anymore - superseded by # 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. # tx_custom.c (see that file for why). Left on disk, unused, for history.
idf_component_register( 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 "." 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 PRIV_REQUIRES esp_wifi
) )
+7 -6
View File
@@ -4,6 +4,7 @@
int geonet_wrap_shb(const uint8_t *its_payload, int its_len, int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
const uint8_t mac[6], uint8_t station_type, const uint8_t mac[6], uint8_t station_type,
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg, int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
uint16_t speed_cm_s, uint16_t heading_ddeg,
uint16_t btp_dest_port, uint16_t btp_dest_port,
uint8_t *out, size_t out_len) 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; 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 >> 24); *p++ = (uint8_t)(lon_u >> 16);
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u); *p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false, // PAI(1 bit) + Speed(15 bits, signed, 0.01 m/s), packed into 2 bytes. PAI stays 0: the
// speed 0 - which is actually correct semantics for a STATIONARY // position has no accuracy estimate behind it.
// vehicle beacon, not just a placeholder. uint16_t spd = speed_cm_s > 0x7FFF ? 0x7FFF : speed_cm_s;
*p++ = 0x00; *p++ = 0x00; *p++ = (uint8_t)(spd >> 8); *p++ = (uint8_t)(spd & 0xFF);
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable // Heading (16 bits, 0.1 degree units, clockwise from north)
*p++ = 0x00; *p++ = 0x00; *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 // 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 // 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 // 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 // working (same extended header shape as CAM). Fine for a single-vehicle
// beacon; revisit if you need real multi-hop forwarding later. // 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 // `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, ... // (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, int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
const uint8_t mac[6], uint8_t station_type, const uint8_t mac[6], uint8_t station_type,
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg, int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
uint16_t speed_cm_s, uint16_t heading_ddeg,
uint16_t btp_dest_port, uint16_t btp_dest_port,
uint8_t *out, size_t out_len); uint8_t *out, size_t out_len);
+82 -30
View File
@@ -1,7 +1,11 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <math.h>
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "esp_timer.h"
#include "driver/gpio.h" #include "driver/gpio.h"
#include "esp_wifi.h" #include "esp_wifi.h"
#include "esp_event.h" #include "esp_event.h"
@@ -14,13 +18,17 @@
#include "geonet.h" #include "geonet.h"
#include "dot11p.h" #include "dot11p.h"
#include "tx_custom.h" #include "tx_custom.h"
#include "route.h"
#include "route_points.h"
static const char *TAG = "obu-tx"; static const char *TAG = "obu-tx";
// CAM beacon: transmit a Cooperative Awareness Message every TX_INTERVAL_MS, // CAM beacon for a simulated car driving round a block in Hamburg (see route.c). The CAM
// unconditionally (no hazard-light gating - CAM is a continuous beacon, unlike // generation rules follow ETSI EN 302 637-2 clause 6.1.3: every CHECK_INTERVAL_MS the car's state
// the event-triggered DENM). Matches the working Rust reference // is compared with the last CAM sent, and a new CAM goes out when the heading changed by more than
// (esp32-c_its-companion, feat/tx-cam), which beacons CAM on 5900 MHz. // 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. // ISOLATION TEST for whether tx_custom.c is the blocker.
// 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a // 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_LENGTH_DM 40 // VehicleLengthValue, 10cm steps (4.0 m)
#define VEHICLE_WIDTH_DM 18 // VehicleWidth, 10cm steps (1.8 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 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 // ---- Simulated drive ----
// the unit is genuinely stationary here: 53°33'16.8"N 10°01'20.6"E, in // route_points (main/route_points.h) is the street geometry of a driving loop through six waypoints
// 1/10-microdegree units (decimal_degrees * 10,000,000). Replace with real // in St. Georg, generated by tools/make_route.py from OpenStreetMap via OSRM. To change the route,
// GNSS output once you have a fix source; until then this beats 0/0 // edit WAYPOINTS in that script and rerun it. No GNSS is wired in; replace with real fixes once
// ("Null Island"), which is an obvious placeholder-tell on any map. // there is one.
#define BENCH_LATITUDE_TENMICRODEG 535546667 #define CRUISE_MPS (50.0 / 3.6) // 50 km/h, the urban limit
#define BENCH_LONGITUDE_TENMICRODEG 100223889 #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 // 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 // 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_11p_set(int enable, int unused);
extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, 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]; uint8_t frame[300];
cam_fields_t fields = { cam_fields_t fields = {
.station_id = STATION_ID, .station_id = STATION_ID,
.station_type = STATION_TYPE, .station_type = STATION_TYPE,
.generation_delta_time = gen_delta, .generation_delta_time = gen_delta,
.latitude_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG, .latitude_tenmicrodeg = car->latitude_tenmicrodeg,
.longitude_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG, .longitude_tenmicrodeg = car->longitude_tenmicrodeg,
.speed_cm_s = 0, // stationary .speed_cm_s = car->speed_cm_s,
.heading_ddeg = 3601, // HeadingValue unavailable (no heading source) .heading_ddeg = car->heading_ddeg,
.vehicle_length_dm = VEHICLE_LENGTH_DM, .vehicle_length_dm = VEHICLE_LENGTH_DM,
.vehicle_width_dm = VEHICLE_WIDTH_DM, .vehicle_width_dm = VEHICLE_WIDTH_DM,
}; };
gen_delta += TX_INTERVAL_MS;
uint8_t cam_payload[96]; uint8_t cam_payload[96];
int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload)); int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload));
uint8_t gn_payload[160]; uint8_t gn_payload[160];
int gn_len = geonet_wrap_shb(cam_payload, cam_len, pseudonym_mac, STATION_TYPE, 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)); BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
// qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS // 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) { if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err); ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err);
} else { } 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 #else
// tx_custom path: submits to the driver's internal HMAC TX path, // 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) 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) { while (1) {
// CAM is a continuous beacon - send every interval, unconditionally. int64_t now_ms = esp_timer_get_time() / 1000;
send_cam(); if (!sent_any || cam_due(&car, &last_sent, now_ms - last_sent_ms)) {
vTaskDelay(pdMS_TO_TICKS(TX_INTERVAL_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); phy_change_channel(TX_FREQ_MHZ, 1, 0, 0);
ESP_LOGI(TAG, "phy_change_channel returned"); ESP_LOGI(TAG, "phy_change_channel returned");
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, transmitting every %d ms", if (route_init(route_points, sizeof(route_points) / sizeof(route_points[0]),
TX_FREQ_MHZ, TX_INTERVAL_MS); 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); 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; s_cb_item.rssi = packet->rx_ctrl.rssi;
// 0 timeout: never block the WiFi driver's own task waiting for queue space. xQueueSend copies // 0 timeout: never block the WiFi driver's own task waiting for queue space. xQueueSend copies
// the struct out before returning, so reusing s_cb_item on the next callback is fine. // the struct out before returning, so reusing s_cb_item on the next callback is fine. The
xQueueSend(s_rx_queue, &s_cb_item, 0); // 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) 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_oversize_drops;
static uint16_t s_tx_failures; static uint16_t s_tx_failures;
static uint16_t s_rx_crc_errors; 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 // 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 // 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); 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) ---- // ---- 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 // 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 // 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) 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] - // [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. // [rx_queue_drops:2 LE] - keep in lockstep with EspLinkStatus.parse() in the app's
uint8_t payload[8]; // SerialFrame.kt.
uint8_t payload[10];
payload[0] = status; payload[0] = status;
payload[1] = (uint8_t)(s_oversize_drops & 0xFF); payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 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 // 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. // is what lets a new app keep working against firmware that predates that message.
payload[7] = SERIAL_CAP_CAM_TX_PV; 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)); 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. // 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 // 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 // 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] // [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. // 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 // 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 // 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 // phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
// in the app's SerialFrame.kt. // 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. // whose capture buffer is smaller than the largest frames on air.
void serial_link_note_oversize_drop(uint16_t btp_dest_port); 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 #endif