Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7285fa19b7 | ||
|
|
21e01499d8 | ||
|
|
01204a2c22 |
@@ -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 = {
|
||||||
|
if (!isFullBleed) {
|
||||||
StatusTopBar(
|
StatusTopBar(
|
||||||
state = state,
|
state = state,
|
||||||
mqttConnectionState = mqttConnectionState,
|
mqttConnectionState = mqttConnectionState,
|
||||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||||
usbSerialState = usbSerialState,
|
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,12 +352,15 @@ 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.
|
//
|
||||||
|
// The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is
|
||||||
|
// permanently empty, and a toggle offering a single choice is just noise.
|
||||||
|
if (!isEsp32) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -350,34 +370,20 @@ private fun TopicListPane(
|
|||||||
selected = viewMode == TopicViewMode.LIST,
|
selected = viewMode == TopicViewMode.LIST,
|
||||||
) { 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,42 +114,58 @@ fun V2xLiveMapView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
initOsmForV2xMap(ctx)
|
initOsmForV2xMap(ctx)
|
||||||
MapView(ctx).apply {
|
MapView(ctx).apply {
|
||||||
setTileSource(TileSourceFactory.MAPNIK)
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
setMultiTouchControls(true)
|
setMultiTouchControls(true)
|
||||||
|
// Raster tiles are authored for ~160 dpi; without this they are upscaled by the
|
||||||
|
// display density and labels come out soft on a modern phone.
|
||||||
|
isTilesScaledToDpi = true
|
||||||
|
// The floating +/- buttons sit exactly where the rider's thumb lands and
|
||||||
|
// duplicate pinch-zoom. Pinch and double-tap still work.
|
||||||
|
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||||
|
setMinZoomLevel(4.0)
|
||||||
|
setMaxZoomLevel(20.0)
|
||||||
controller.setZoom(17.0)
|
controller.setZoom(17.0)
|
||||||
controller.setCenter(ownGeoPoint)
|
controller.setCenter(ownGeoPoint)
|
||||||
|
// Any touch means the rider is driving the map; follow-own hands over to them
|
||||||
|
// until they ask for it back. false: the MapView's own gesture handling still
|
||||||
|
// runs, this only observes.
|
||||||
|
setOnTouchListener { _, event ->
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_DOWN) onUserPanned()
|
||||||
|
false
|
||||||
|
}
|
||||||
mapViewRef.value = this
|
mapViewRef.value = this
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { mv ->
|
update = { mv ->
|
||||||
mv.overlays.clear()
|
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 {
|
}
|
||||||
|
// An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it:
|
||||||
|
// two markers on one point, the lower one unreachable.
|
||||||
|
val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet()
|
||||||
|
|
||||||
|
val live = mutableSetOf<String>()
|
||||||
|
|
||||||
|
// Own position: a centred "you are here" dot, not a pin. Own position is a fact about
|
||||||
|
// the viewer rather than one of the tracked objects, and when both used osmdroid's
|
||||||
|
// identical default pin the two were indistinguishable at a glance.
|
||||||
|
markers.marker(mv, KEY_OWN, live).apply {
|
||||||
position = ownGeoPoint
|
position = ownGeoPoint
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
icon = icons.own
|
||||||
title = context.getString(R.string.v2x_map_own_label)
|
title = context.getString(R.string.v2x_map_own_label)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
remotes.forEach { (stationId, cam) ->
|
remotes.forEach { (stationId, cam) ->
|
||||||
|
if (stationId in spatStationIds) return@forEach
|
||||||
val level = alertByStation[stationId]
|
val level = alertByStation[stationId]
|
||||||
val label = when (level) {
|
val label = when (level) {
|
||||||
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
||||||
@@ -132,32 +173,23 @@ fun V2xLiveMapView(
|
|||||||
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||||
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
||||||
}
|
}
|
||||||
// Teardrop pin anchored at its tip, tinted by severity. Now that these are
|
// Teardrop pin anchored at its tip, tinted by severity, so severity no longer
|
||||||
// custom drawables, per-instance tinting is possible - severity no longer
|
// depends on tapping the marker to read its label.
|
||||||
// depends on tapping the marker to read its label. mutate() is essential:
|
markers.marker(mv, "$KEY_CAM$stationId", live).apply {
|
||||||
// without it every marker shares one ConstantState and the last tint applied
|
|
||||||
// would recolour all of them.
|
|
||||||
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
|
||||||
?.mutate()
|
|
||||||
?.apply { setTint(level.toMarkerColor()) }
|
|
||||||
mv.overlays.add(
|
|
||||||
Marker(mv).apply {
|
|
||||||
position = GeoPoint(cam.latitude, cam.longitude)
|
position = GeoPoint(cam.latitude, cam.longitude)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
icon = pin
|
icon = icons.remotePin(level)
|
||||||
title = label
|
title = label
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard
|
// 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.
|
// hidden behind a CAM pin defeats the point of showing it.
|
||||||
denms.forEach { denm ->
|
denms.forEach { denm ->
|
||||||
mv.overlays.add(
|
markers.marker(mv, "$KEY_DENM${denm.dedupKey}", live).apply {
|
||||||
Marker(mv).apply {
|
|
||||||
position = GeoPoint(denm.latitude, denm.longitude)
|
position = GeoPoint(denm.latitude, denm.longitude)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
icon = icons.denm
|
||||||
title = denm.causeCode?.let {
|
title = denm.causeCode?.let {
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.v2x_map_denm_labeled,
|
R.string.v2x_map_denm_labeled,
|
||||||
@@ -167,10 +199,45 @@ fun V2xLiveMapView(
|
|||||||
)
|
)
|
||||||
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mv.controller.animateTo(ownGeoPoint)
|
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) } ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop markers for stations, hazards and intersections that have expired, then rebuild
|
||||||
|
// the overlay list in draw order from the cached Markers. Reordering moves references;
|
||||||
|
// nothing here allocates a Marker or decodes a Drawable.
|
||||||
|
markers.keys.retainAll { key ->
|
||||||
|
(key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() }
|
||||||
|
}
|
||||||
|
mv.overlays.clear()
|
||||||
|
markers.entries
|
||||||
|
.sortedBy { (key, _) -> key.drawOrder() }
|
||||||
|
.forEach { (_, marker) -> mv.overlays.add(marker) }
|
||||||
|
|
||||||
|
// setCenter, not animateTo: an animation restarted on every CAM never finishes, which
|
||||||
|
// is exactly the judder this used to show under live traffic. The one animated move is
|
||||||
|
// the rider re-enabling follow, where the travel is worth seeing.
|
||||||
|
if (followOwn) {
|
||||||
|
if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint)
|
||||||
|
else mv.controller.animateTo(ownGeoPoint)
|
||||||
|
}
|
||||||
|
wasFollowing.value = followOwn
|
||||||
|
|
||||||
mv.invalidate()
|
mv.invalidate()
|
||||||
},
|
},
|
||||||
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
||||||
@@ -186,10 +253,98 @@ fun V2xLiveMapView(
|
|||||||
//
|
//
|
||||||
// onRelease is the callback that actually means "this View is gone": Compose
|
// onRelease is the callback that actually means "this View is gone": Compose
|
||||||
// guarantees no further update after it.
|
// guarantees no further update after it.
|
||||||
onRelease = { it.onDetach() },
|
onRelease = {
|
||||||
modifier = Modifier.fillMaxSize(),
|
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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:'© 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()
|
||||||
@@ -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:'© OpenStreetMap contributors'}).addTo(m);
|
||||||
|
const line=L.polyline(pts.concat([pts[0]]),{color:'#d33',weight:4}).addTo(m);
|
||||||
|
wps.forEach((w,i)=>L.marker(w,{title:'wp'+(i+1)}).bindTooltip('wp'+(i+1),{permanent:true}).addTo(m));
|
||||||
|
L.circleMarker(pts[0],{radius:7,color:'#060'}).bindTooltip('start').addTo(m);
|
||||||
|
m.fitBounds(line.getBounds(),{padding:[20,20]});
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -259,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)
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user