Compare commits
24
Commits
312f094909
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3fc9bf66c | ||
|
|
6e4d293c3a | ||
|
|
d107534eb2 | ||
|
|
2f60623e18 | ||
|
|
a08494b56a | ||
|
|
d2fd222a62 | ||
|
|
7285fa19b7 | ||
|
|
21e01499d8 | ||
|
|
01204a2c22 | ||
|
|
0f06cdb339 | ||
|
|
75d6d3b85c | ||
|
|
1baae2c5f6 | ||
|
|
8871708a98 | ||
|
|
d7043bb04d | ||
|
|
e908f7fae1 | ||
|
|
8f397eea20 | ||
|
|
cc395771ea | ||
|
|
83153a0971 | ||
|
|
3eeccfb268 | ||
|
|
5ec3619cbe | ||
|
|
1ad123a6f8 | ||
|
|
83ccf335bb | ||
|
|
034ef22336 | ||
|
|
ebe1c9edfd |
+21
@@ -27,6 +27,9 @@ secrets.properties
|
||||
/.idea/appInsightsSettings.xml
|
||||
/.idea/studiobot.xml
|
||||
|
||||
# Python bytecode caches, e.g. from importing obu-firmware/test/host/check_replay.py.
|
||||
__pycache__/
|
||||
|
||||
# ESP-IDF rewrites sdkconfig on every build and keeps the previous one here.
|
||||
# sdkconfig.defaults is the real, intentional configuration; these two are output.
|
||||
sdkconfig.old
|
||||
@@ -38,7 +41,25 @@ sdkconfig.old
|
||||
# Third-party working copies kept beside the project, not part of it. The ASN.1
|
||||
# modules this project actually needs are vendored under asn1/ instead.
|
||||
/C-ITS-Parser/
|
||||
/vanetza/
|
||||
|
||||
# Office lock files. Word/Excel create these beside a document while it is open
|
||||
# and remove them on close, so they are transient and machine-local.
|
||||
~$*
|
||||
|
||||
# Full-flash images read back off the bench boards before reflashing them (16 MB each).
|
||||
# Restore with: esptool --chip esp32c5 -p COM<N> write-flash 0 <image>
|
||||
/firmware-backups/
|
||||
|
||||
# ESP-IDF component manager downloads (espressif/esp-boost for obu-firmware's vanetza-idf), ~125 MB.
|
||||
# dependencies.lock beside the project pins them and is committed; this is its cache.
|
||||
managed_components/
|
||||
|
||||
# The colleague's standalone ESP32-C5 VRU station (its own repository, HAW GitLab
|
||||
# urban-mobility-lab/microbu/microbu-esp32c5). Kept beside the project on the lab laptop, for
|
||||
# reference and because the V2X2MAP bridge runs from its tools/, but not part of this repository:
|
||||
# obu-firmware only needs its vanetza-idf, which is copied to obu-firmware/external/vanetza-idf.
|
||||
/microbu-esp32c5/
|
||||
|
||||
# draw.io keeps a backup beside an open diagram.
|
||||
*.drawio.bkp
|
||||
|
||||
@@ -38,9 +38,9 @@ Both paths converge at `CamUseCaseRepository`, which normalises whatever arrived
|
||||
|
||||
**DENM transmission**; CiT One path only. Triggers the stationary vehicle profile (`hln-sv`, causeCode 94) via the consider it Use Case API. This is a manual antenna and range test tool. It is never triggered by a detected event or a use case alert, and the control is hidden entirely on the ESP32-C5 path.
|
||||
|
||||
**Trip recording**; foreground service records all sensor streams and detects cycling events (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected.
|
||||
**Trip recording**; foreground service records all sensor streams and detects cycling manoeuvres (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected. The detected manoeuvres are neither shown nor stored - their only effect is to raise the CAM transmit rate through the manoeuvre on the ESP32-C5 path.
|
||||
|
||||
**Trip review**; past trips displayed on an OpenStreetMap layer with detected events overlaid as coloured pins. Tap any pin for event details.
|
||||
**Trip review**; past trips displayed as a route on an OpenStreetMap layer, with duration and distance.
|
||||
|
||||
**CSV export**; every sensor sample written to a timestamped CSV in real time. Trip exports additionally include the V2X messages received and their RSSI. Shareable via the standard Android share sheet.
|
||||
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# TODO
|
||||
|
||||
Engineering to-do list. The reviewer-facing open items live in
|
||||
`docs/01-requirements-traceability.md` ("Open items"); this file is the working list behind them.
|
||||
|
||||
## Waiting on hardware
|
||||
|
||||
### Signed-TX firmware (vanetza-idf port), VAM and BLE: first on-air checks (added 2026-09-23)
|
||||
|
||||
obu-firmware is now a port of the colleague's `microbu-esp32c5` station (vanetza-idf, TS 103 097
|
||||
signing, station-link protocol, BLE GATT), built with **ESP-IDF 6.0.2**. See `obu-firmware/NOTES.md`.
|
||||
The previous firmware is backed up in `firmware-backups/` (restore command in its README.txt).
|
||||
The app speaks the new protocol over USB or BLE and still falls back to the old frames against the
|
||||
old firmware.
|
||||
|
||||
Done without hardware: IDF 6.0.2 build clean (39 % app partition free); host suite (`make` in
|
||||
`obu-firmware/test/host`) passes unchanged; app unit tests 103/103, including the VAM encoder
|
||||
against asn1tools, the station-link codec against the colleague's Python `messages.py`, and the VAM
|
||||
generation rules. Flashed to **COM3** 2026-09-23 (hash verified); boot log: IDF v6.0.2,
|
||||
`BLE advertising started as 'micrOBU-4AFA'`, station task ready. The radio stays off until the app
|
||||
configures the station. New app build installed on the Pixel 9 Pro (adb, `install -r`).
|
||||
|
||||
First phone session (user, 2026-09-23): BLE works and CAMs go out. Three faults, fixed and
|
||||
reflashed/reinstalled the same day:
|
||||
1. No RX until the CAM pinger ran, with ~177 RX-queue drops: the colleague's `Station::tick()`
|
||||
returned before draining the radio until the first PoTi had set the clock. Now drained always.
|
||||
2. "refused a request: time_regression": the loops re-send the latest fix every tick; a stale fix
|
||||
timestamp read as the clock going back > 1 s, and each time the board rebuilt its stack.
|
||||
`Esp32Link` now sends a PoTi only for a newer fix (or a >= 60 s real clock correction).
|
||||
3. BLE reconnect loop: GATT operations with a 5 s timeout ran during Android's pairing, cut it off
|
||||
and restarted it on every attempt. Encryption/pairing is now settled first (60 s), retries back
|
||||
off to 30 s, and every failure reason is logged and shown; the board logs encryption changes.
|
||||
|
||||
Second session (user, 2026-09-23): USB, RX and signing work; BLE still prompted every time and
|
||||
never connected; time_regression every ~8 s. Found and fixed, reflashed (full flash, NVS erased):
|
||||
4. The board never stored a bond: NVS (24 KB, the colleague's 4 MB-board layout) was full, mostly
|
||||
Wi-Fi settings the previous firmware left behind, and NimBLE's bond write failed. NVS is now
|
||||
80 KB (app moved to 0x20000) and was erased; boot logs `N bonded phone(s) in NVS`.
|
||||
5. The station loop waited `pdMS_TO_TICKS(5)` = 0 ticks at 100 Hz, so it spun on the single core
|
||||
(task watchdog: IDLE starved). Now waits at least one tick.
|
||||
6. The phone clock is ~14 min fast; GnssTimeSource fell back to it whenever GNSS time blinked out
|
||||
indoors, so every transmitted timestamp (CAM generationDeltaTime too) jumped 14 min back and
|
||||
forth. It now keeps the last measured error.
|
||||
|
||||
Watch COM3 (`idf.py -p COM3 monitor`, or `readlog.py`-style with DTR/RTS low) during these; it only
|
||||
resets the board, the phone is on the other port.
|
||||
|
||||
- [x] **USB session.** Settings > Connection > ESP32-C5: link USB-C, transmit CAM, signing on.
|
||||
Phone on the native port, Connect. Expected: the card shows "Provisioning the demo credentials"
|
||||
once, then Connected and `Signing on · tickets 1 · signed N` with N rising while recording.
|
||||
COM3: `radio on channel 180, transmit and receive`, `tx power: … dBm`,
|
||||
`credentials provisioned: 1 roots, 1 authorities, 1 tickets`, and no `radio refused a frame`.
|
||||
Confirmed by the user 2026-09-23: connects, signing works.
|
||||
- [x] **Reception intact.** Same session, sim car (COM8) beaconing: its CAMs (station 195936478) on
|
||||
the V2X map at ~3 Hz as before. Then put a DENM and a SPATEM on air: both show up (they come
|
||||
through the raw V2X_RX path; the vanetza stack drops them because they are not demo-signed).
|
||||
Confirmed 2026-09-23: sim car and the RSU's CAM/SPATEM/MAPEM arrive; DENM not yet re-tested.
|
||||
- [x] **Signed CAM on air** (2026-09-23, CAM pinger over BLE, signing on). Recorded 25 s through
|
||||
the V2X2MAP bridge's `/api/record` (`micrOBU_workspace/v2x-obu-esp32c5/signed-cam-check.pcap`)
|
||||
and checked with the new `obu-firmware/test/verify_signed_pcap.py` (asn1tools + OpenSSL, no
|
||||
vanetza code): 12/12 secured CAMs, station 999999, psid 36, signer = full certificate of the
|
||||
demo AT `B80B49387A4C12EB`, **all signatures valid**, COER canonical, and the bundle's chain
|
||||
(AT <- AA <- root) verifies. The CiT One (192.168.40.201) also receives them (~1 Hz on
|
||||
`v2x/rx/cam`), i.e. a third-party stack unwraps our 1609.2 envelope; its MQTT API exposes no
|
||||
security fields, and it forwards unsigned and unknown-root messages alike, so it cannot say
|
||||
whether it verified them. The same capture showed generationTime wobbling by seconds, with
|
||||
`time_regression` still firing: fixed in Esp32Link (the PoTi never moves the micrOBU's clock
|
||||
back except for a >= 60 s correction). Re-check: no "restarted its stack" lines in logcat.
|
||||
- [x] **Unsigned toggle.** Signing off: the same capture shows next header 1 (common header), as
|
||||
the previous firmware sent. The card's `signed` count stops rising.
|
||||
Confirmed 2026-09-23: unsigned pinger CAMs show on V2X2MAP as unsigned.
|
||||
- [x] **Signed VAM on V2X2MAP.** Since 2026-09-23 17:16 the COM10 bridge is the colleague's
|
||||
v2x2map-0.3.0 from source with a new `verify.py` and `--trust demo-chain.vcr` (launcher:
|
||||
`micrOBU_workspace/v2x-obu-esp32c5/start-v2x2map-signed.bat`, replacing its-g5-bridge.exe).
|
||||
Signed CAMs from the pinger already show "signature verified" live. Switch to VAM with signing
|
||||
on: the VAM must be decoded (cyclist, position) and show "signature verified" too.
|
||||
Confirmed by the user 2026-09-23: signed VAMs decode and verify.
|
||||
- [x] **VAM.** Transmit VAM: BTP port 2018, psid 638; with `tools/wireshark/psid-vru.lua` from the colleague's microbu-esp32c5 repository
|
||||
Wireshark decodes the VAM (stationType cyclist, bicyclist profile in every ~2 s VAM). Rate:
|
||||
≥1 per 5 s standing still, about one per GNSS fix while riding.
|
||||
Covered by the V2X2MAP check above (decoded VAM, psid 638); Wireshark not needed.
|
||||
- [x] **RX without recording.** Connect only (no recording, no pinger): sim-car CAMs appear and
|
||||
the RX-queue drop counter stays at 0 or near it.
|
||||
Confirmed 2026-09-23: messages come in on connect alone.
|
||||
- [ ] **No time_regression.** Record for a few minutes standing still indoors: no "refused" line on
|
||||
the card, and COM3 never logs `ITS time moved back`.
|
||||
- [x] **BLE after the NVS fix.** First forget micrOBU-4AFA in Android's Bluetooth settings (the
|
||||
phone still holds the bond the board lost). Then Connect, passkey 123456 once; a second
|
||||
Connect after an app restart must not prompt again, and COM3's next boot must say
|
||||
`1 bonded phone(s) in NVS`.
|
||||
Confirmed 2026-09-23: pairs once, reconnects after an app restart.
|
||||
- [x] **BLE.** Link Bluetooth, unplug USB, Connect. Android asks to pair with micrOBU-4AFA:
|
||||
passkey 123456. Expected: Connected, CAMs keep going, sim-car CAMs keep arriving.
|
||||
While USB is plugged in and in use, the phone's BLE scan must not see micrOBU-4AFA
|
||||
(COM3: `USB link in use: BLE advertising paused`). If it loops again, the card now says why;
|
||||
"refused this phone's stored pairing" means forget micrOBU-4AFA in Android and pair again.
|
||||
COM3 shows `encryption change status=...` for the board's side.
|
||||
Confirmed 2026-09-23: CAMs and VAMs out, reception in, over BLE.
|
||||
- [ ] **BLE/ITS-G5 coexistence (the unmeasured risk from the hardware review).** With BLE
|
||||
connected, count the sim car's CAMs received per minute and ours at the sniffer; compare
|
||||
with the same over USB. A clear drop, or reception stopping altogether, means the coex
|
||||
arbiter takes the radio off 5900 MHz (our channel is set behind the driver's back with
|
||||
`phy_change_channel`). Then BLE cannot be used while receiving, or needs a longer connection
|
||||
interval.
|
||||
- [ ] **Board reset recovery over BLE.** Press RST mid-session: the app reconnects by itself and
|
||||
reconfigures on the first STATUS saying `not configured`, with no manual Connect. (Over USB a
|
||||
reset re-enumerates the port and needs a manual Connect, as before.)
|
||||
|
||||
### Confirm the RX queue drop counter explains the bench-session frame drops / map flicker (added 2026-09-22)
|
||||
|
||||
Investigated the user's report of "OBU mode keeps dropping a few frames" and "v2x screen comes
|
||||
and goes" while bench-testing against `obu-cam-transmistter`. Found a real, previously invisible
|
||||
drop path: `obu-firmware/main/main.c`'s `wifi_promisc_rx_cb()` calls `xQueueSend(s_rx_queue, ...,
|
||||
0)` (queue depth 8) without checking the return value, so a burst of promiscuously-captured
|
||||
frames arriving faster than `rx_forward_task` can drain them (each drain can legitimately block up
|
||||
to ~400ms under USB/UART contention) silently vanishes. None of the existing `EspLinkStatus`
|
||||
counters (`oversizeDrops`/`txFailures`/`rxCrcErrors`) caught this class of drop.
|
||||
|
||||
This plausibly also explains the map symptom: `UseCaseDetectionEngine.pruneStale()` drops a remote
|
||||
station's marker after `staleRemoteMs` (3 s) with no CAM update. Measured 2026-09-22 via
|
||||
`tools/cit_one_rx_watch.py --host 192.168.40.201` against `obu-cam-transmistter`'s bench beacon
|
||||
(stationID 195936478 / 0x0BADC0DE): **75 CAMs in 25 s, ~3 Hz**, not the 1 Hz this note assumed
|
||||
earlier — faster than assumed means more promiscuous captures per second and a shorter fuse on
|
||||
`staleRemoteMs`, both of which make the queue-overflow theory more likely, not less.
|
||||
|
||||
Fixed to be **visible**, not yet fixed to **not drop**: added a `rxQueueDrops` counter, checked
|
||||
`xQueueSend`'s return value (`main.c`), wired it through the STATUS heartbeat as a new trailing
|
||||
`uint16` field (`serial_link.c/.h`, `SerialFrame.kt`'s `EspLinkStatus`), and surfaced it on the
|
||||
CAM Pinger card (`MqttTopicViewerScreen.kt`, string `mqtt_cam_pinger_fw_counters`). Host build
|
||||
untouched (serial_link.c/main.c aren't in the host test's standard-headers-only set); IDF build
|
||||
verification is the remaining pre-flash check. Deliberately did NOT bump `s_rx_queue`'s depth from
|
||||
8 — no real burst-size data yet, and guessing a bigger number against an unmeasured memory budget
|
||||
is exactly the kind of assumption [[microbu-hw-review]] flags as needing verification first, not
|
||||
capacity that's cheap to reason your way into.
|
||||
|
||||
Needs: a phone attached to the production OBU's native USB port, watching the CAM Pinger card,
|
||||
while `obu-cam-transmistter` (or real traffic) beacons.
|
||||
|
||||
- [x] `idf.py build` succeeds (obu-firmware, IDF 6.1) — clean, both changed files compiled with no
|
||||
warnings, 17% flash free.
|
||||
- [x] Reflashed the production OBU on **COM3** 2026-09-22 (hash verified). Boot log confirms the
|
||||
new build (`21e0149-dirty`, compiled Sep 22 2026 14:14:09), clean boot, OCB @ 5900 MHz
|
||||
TX/RX armed, `serial_link up ... 1 Hz heartbeat`, no panic. Incidentally answers part of the
|
||||
"measure the OBU's actual transmit power" item below: this boot logged
|
||||
`tx power: 72 quarter-dBm = 18.00 dBm (20.00 requested)` — the driver **is** clamping below
|
||||
the requested 20 dBm at 5900 MHz, as that item suspected but had not measured.
|
||||
- [x] 25 s of steady-state console (no phone attached, `obu-cam-transmistter` beaconing nearby):
|
||||
silent — no crash, no `oversize`/`rx queue full`/`crc` warnings. Inconclusive on its own
|
||||
(successful forwards aren't logged, and nothing was attached to trigger the ~400 ms UART
|
||||
stalls the theory needs), but at least rules out a crash-on-boot regression.
|
||||
- [x] Confirmed the wider bench RF path independently via the CiT One OBU broker
|
||||
(`py -3.11 tools/cit_one_rx_watch.py --host 192.168.40.201`): heard `obu-cam-transmistter`'s
|
||||
beacon cleanly, 75/25 s, GN source `14:00:02:00:00:00:00:01`, position in the expected
|
||||
St. Georg route area. This is a *different* receiver from the production OBU though — it
|
||||
shows the beacon is genuinely on air, not that COM3 forwards every one of it without drops.
|
||||
- [x] **Confirmed on real hardware, 2026-09-22.** Installed the updated debug APK (previous build
|
||||
on the phone was from 2026-09-15, predating this fix entirely) on the Pixel 9 Pro (adb over
|
||||
Wi-Fi), relaunched against the freshly-reflashed COM3, and read `rx queue drop` via `adb
|
||||
logcat -s UsbSerialTransport`. The counter mechanism works end-to-end and **the bug is
|
||||
real**: `rxQueueDrops` was 0 at the last flash (14:22), read as 89 at first reconnect
|
||||
(14:48, ~26 min later), and 90 at a second reconnect (14:52). No `oversizeDrops`,
|
||||
`txFailures`, or `rxCrcErrors` moved at all, and zero `decode FAILED` lines — this queue is
|
||||
the only place frames are going missing.
|
||||
Nuance: over a clean ~4.5 min window in between (14:48→14:52) with `obu-cam-transmistter`
|
||||
actively beaconing at a measured **~3.33 Hz** (matches the CiT One's 75/25 s independently)
|
||||
and 490+ CAMs decoding cleanly with steady cadence and no gaps, the counter did **not**
|
||||
move — it only ticked at connect/reconnect moments. So this is a low-rate, bursty drop (matches
|
||||
the user's own "a few frames" framing), not a continuous overflow under steady single-station
|
||||
traffic; it may be specific to WiFi/PHY activity around association or reconnect rather than
|
||||
raw beacon rate. Worth a longer, quieter-boot capture before sizing a `s_rx_queue` bump.
|
||||
Did **not** independently confirm the map-flicker connection this session — that needs eyes
|
||||
on the app's V2X screen while watching this same counter live, not just logcat.
|
||||
|
||||
### On-device check of the full-screen V2X live map (added 2026-09-15)
|
||||
|
||||
The live map moved out of the V2X Monitor's view-mode row into its own full-screen destination
|
||||
(`V2xMapScreen`, route `v2x_map`), reached from the map button in that screen's header. Markers are
|
||||
now cached and reused across updates instead of being rebuilt on every incoming message, and
|
||||
SPATEM intersections are drawn as traffic lights at the position of the RSU's own CAM. All of that
|
||||
compiles and the unit tests pass, but none of it has been seen with live traffic.
|
||||
|
||||
Needs: the phone with the app, plus a CAM/DENM/SPATEM source - either the CiT One, or the OBU
|
||||
ESP32-C5 with a second board or a real RSU transmitting.
|
||||
|
||||
- [ ] Both hardware modes: tap the map button, confirm the map fills the screen (no status bar, no
|
||||
bottom nav) and the back button returns to the V2X Monitor.
|
||||
- [ ] Panning stays smooth while CAMs are arriving - this is what the marker reuse is for. Compare
|
||||
against the old behaviour if it still judders.
|
||||
- [ ] Touching the map stops it recentring; the location FAB resumes follow and lights up.
|
||||
- [ ] A DENM shows the warning triangle, and a SPATEM intersection shows a traffic light with the
|
||||
lamp matching the Dashboard's SignalCard for the same intersection.
|
||||
- [ ] Near a real RSU: confirm the RSU is drawn once, as a traffic light, not as a CAM pin with a
|
||||
light on top of it. If the RSU sends SPATEM but no CAM, the "signals not shown" note should
|
||||
appear instead - worth knowing which of the two the HAW RSUs actually do.
|
||||
|
||||
|
||||
### Over-the-air check of the GN lifetime fix (added 2026-09-11)
|
||||
|
||||
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
|
||||
in both `obu-firmware` and `obu-cam-transmistter`. Both still build (IDF 6.1 / 5.5.4), and the
|
||||
compiled `geonet_wrap_shb` stores the new byte, but it has not been seen on air yet. Nothing else
|
||||
reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not
|
||||
need updating alongside the firmware.
|
||||
|
||||
Needs: the phone with the app, the OBU ESP32-C5, and a **second** ESP32-C5 running
|
||||
`its-g5-receiver-firmware` to capture with.
|
||||
|
||||
- [ ] Flash `obu-firmware` (see `obu-firmware/FLASHING.md`).
|
||||
- [ ] Connect the phone, let it send CAMs, and confirm the CAM Pinger's `tx fail` counter stays 0.
|
||||
- [ ] Capture with the receiver into `its-g5-receiver-firmware/recordings/`.
|
||||
- [ ] Run `python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/<capture>.pcap`.
|
||||
The rows for the phone's pseudonym MACs must show SHB, port 2001, lifetime `0x05`, exactly
|
||||
like every other station's CAMs.
|
||||
- [ ] While the phone is connected: real-station CAMs/DENMs still reach the app (RX path unchanged).
|
||||
|
||||
Partial check possible with one board and no phone: flash it, `idf.py -p COMx monitor`, and look
|
||||
for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not
|
||||
that it transmits correctly.
|
||||
|
||||
### Measure the OBU's actual transmit power (added 2026-09-14)
|
||||
|
||||
Nothing in this project has ever measured it. `main.c` asks for 20 dBm
|
||||
(`esp_wifi_set_max_tx_power(80)`, 0.25 dBm units) and the build's ceiling is the same
|
||||
(`CONFIG_ESP_PHY_MAX_TX_POWER=20`), but a request is a ceiling, not a guarantee: the driver clamps
|
||||
it to its own calibrated table, and 5900 MHz is above the range this chip is rated for, so the
|
||||
table actually in use is channel 177's. The firmware now reads the value back and logs it at boot,
|
||||
which records what the driver admits to, not what leaves the antenna.
|
||||
|
||||
- [ ] Flash and `idf.py -p COM3 monitor`, then note the `tx power:` line. A value below 80 means
|
||||
the driver clamped the request, which the code alone cannot tell you.
|
||||
- [ ] Relative check with the second ESP32-C5 on `its-g5-receiver-firmware`: capture at a measured
|
||||
distance in a straight line, read the RSSI the receive path already reports, and record
|
||||
distance and RSSI together. This gives a comparable number between builds and antennas,
|
||||
which is what matters for range work, without any lab equipment.
|
||||
- [ ] Only a spectrum analyser or a calibrated reference receiver gives real radiated power. Worth
|
||||
it only if the range result looks wrong, or if the thesis needs an absolute figure.
|
||||
|
||||
For context: ETSI allows up to 33 dBm EIRP on the ITS band, and production OBUs sit around
|
||||
20 to 23 dBm, so the requested figure is in the right region if the PA really keys it there.
|
||||
|
||||
### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11)
|
||||
|
||||
Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of
|
||||
4 bits / unavailable(8), the bug the app fixed on 2026-08-20. Fixed in it and in obu-firmware's
|
||||
reference copy; asn1tools now decodes the CAM and re-encodes it byte-identically, and it builds on
|
||||
IDF 5.5.4. No board runs this firmware right now (the production OBU runs obu-firmware), so this
|
||||
only matters if it is flashed again:
|
||||
|
||||
- [ ] After flashing it: capture, run `pcap_gn_tally.py`, and decode the CAM payload with
|
||||
asn1tools (`py -3.11`, modules in `asn1/`).
|
||||
|
||||
### Signed-message reception and exact payloads (added 2026-09-11)
|
||||
|
||||
obu-firmware's `gn_unwrap.c` now unwraps TS 103 097 signed packets (signature not verified,
|
||||
reported as V2X_RX flags bit1) and cuts every message to the length its header declares, dropping
|
||||
the 8 bytes the chip's RX appends to each frame, which were forwarded to the phone until now.
|
||||
Verified on the host (`obu-firmware/test/host`: chain, replay of all recordings against asn1tools,
|
||||
50M-iteration fuzz) and built on IDF 6.1, but not flashed: the production OBU still runs the
|
||||
2026-09-10 build. Needs the OBU with this build, the phone, and signed traffic - real vehicles or
|
||||
RSUs, since the bench CiT One sends unsigned. A second ESP32 running the receiver firmware is
|
||||
optional, but shows what was on air at the time.
|
||||
|
||||
- [ ] Flash obu-firmware (this also carries the GN lifetime fix above).
|
||||
- [ ] Near signed traffic: signed CAMs/DENMs appear in the app, and a simultaneous capture shows
|
||||
them on air (`pcap_gn_tally.py` lists them as `secured`).
|
||||
- [ ] Unsigned bench traffic still decodes in the app as before (messages now arrive 8 bytes
|
||||
shorter).
|
||||
- [ ] The heartbeat's oversize counter still counts over-long messages (e.g. road SPATEMs).
|
||||
|
||||
### CiT One custom CAM injection over `v2x/tx/v2/cam` (added 2026-09-14)
|
||||
|
||||
The haw-002 unit now runs the special firmware: Cohda's own CAM transmission disabled, and a
|
||||
V2X-Gateway build that accepts a `SendV2XMessage` (schemas.consider-innovation.de/its-s/
|
||||
v2x_interface.proto) carrying a UPER CAM on `v2x/tx/v2/cam`. `tools/cit_one_cam_tx.py` builds
|
||||
and publishes those from a PC; its `--self-test` passes offline, proving only that the bytes
|
||||
match `CamEncodeGoldenTest.kt` and that the protobuf wrapper round-trips. Nothing about what
|
||||
the OBU does with them is established.
|
||||
|
||||
Reach the broker over Wi-Fi or Ethernet for now - the USB-peripheral-mode link needs the phone
|
||||
to be USB host on a `172.25.1.0/24` interface with no DHCP server, which Android cannot
|
||||
configure from inside an app.
|
||||
|
||||
Needs: the CiT One haw-002 on the same network as a PC, and a second ESP32-C5 running
|
||||
`its-g5-receiver-firmware` sniffing G5CC (`-c 5900`) to capture with.
|
||||
|
||||
Bench run 2026-09-14, PC -> haw-002 (192.168.3.201), captured on the RSU (192.168.3.202,
|
||||
**not** .2.202 - that address does not route). `tools/cit_one_rx_watch.py` decodes what a unit
|
||||
hears. Result: the injection path works end to end, with one blocker found.
|
||||
|
||||
- [x] Publishes without the broker refusing the topic. 1.00 Hz, confirmed by subscribing to
|
||||
`v2x/tx/v2/cam` on the OBU itself.
|
||||
- [x] The RSU hears our CAMs on air, 1.00 Hz, matching what we publish.
|
||||
- [x] `ItsPduHeader` **is** expected in the payload - we send it included and it decodes.
|
||||
- [x] BTP destination port 2001. GN source address `08:00:26:93:92:01:91:dc`, the OBU's.
|
||||
- [x] **Our CAM content goes out intact**: position, speed (417), heading (639), width (7) and
|
||||
length (18) arrive byte-exact. The gateway does not touch the content.
|
||||
- [x] **The gateway overwrites `stationID`** with the OBU's own (999999 -> 4033890855, which
|
||||
matches `own_info.stationID` on `v2x/rx/obu_gnss`). This is what the "OBU owns identity"
|
||||
decision wants, so `--follow-obu-identity` is not needed on this unit.
|
||||
- [x] ~~BLOCKER: Cohda's own CAM is still transmitting.~~ Fixed 2026-09-14 by disabling CAM in
|
||||
a second conf file: the RSU now hears only our stream, 0 CAMs with the stack's
|
||||
unavailable dimensions over 30 s. Note the stack restart gave the unit a new identity
|
||||
(stationID 4033890855 -> 2553426533, GN source `08:00:26:...` -> `08:00:a2:...`), which is
|
||||
expected under `ItsGnLocalAddrConfMethod = 2` (anonymous, random at boot).
|
||||
- [x] Re-checked: 41 published / 41 heard over 40 s, 1.02 Hz both ends, inter-arrival a steady
|
||||
1.0 s. 100% delivery, no gateway rate limiting. An earlier 0.40 Hz sample was the stack
|
||||
still settling after the restart and did not persist.
|
||||
Two topics the v6 API does not document, found by subscribing to `#` on haw-002:
|
||||
|
||||
- `v2x/loopback/cam` - a `RecvV2XMessage` (btpHeader.type=2) carrying each CAM the unit
|
||||
transmits, 1:1 with what we publish and **after** the gateway's stationID rewrite. This is the
|
||||
TX confirmation we were going to ask consider it for: it makes "did my CAM go out, and under
|
||||
which identity" answerable on the transmitting unit alone, without an RSU or a second ESP32.
|
||||
- `v2x/rx/obuinfo` at 10 Hz - the protobuf `OwnStationInfo` (binary twin of `obu_gnss`;
|
||||
field 2 decodes to the same stationID, field 10 to the same heading). Output only, so it is
|
||||
not the content-feed input we speculated about.
|
||||
|
||||
- [ ] Wire `v2x/loopback/cam` into `cit_one_rx_watch.py` as a local TX check.
|
||||
- [ ] Sanity-check the rate: `--rate 4` should produce 4 CAMs/s on air, since `ItsDCCEnabled = 0`
|
||||
on this unit.
|
||||
|
||||
## 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,
|
||||
vanetza is not going to be built). Setup and the PATH gotcha: `obu-firmware/test/host/README.md`.
|
||||
- [x] Host round-trip test `obu-firmware/test/host/test_chain.c` (`geonet_wrap_shb` ->
|
||||
`dot11p_build_frame` -> `gn_unwrap_its`, byte-checked against the standard). Done
|
||||
2026-09-11: 491 checks, 0 failed. Run `make` in that folder before flashing any firmware fix.
|
||||
- [x] Replay of the recorded captures (`test_replay.c` + `check_replay.py`, independent asn1tools
|
||||
check). Done 2026-09-11: C and Python agree on all 15 145 records.
|
||||
- [x] Mutation fuzzer `fuzz_gn_unwrap.c`, inputs against a no-access guard page. Done 2026-09-11:
|
||||
50 000 000 iterations, no crash. `make` runs a 2 000 000-iteration pass every time.
|
||||
|
||||
## Firmware ideas from the vanetza review (2026-09-11, not started)
|
||||
|
||||
Suggested order after the host tests exist:
|
||||
|
||||
- [x] **Read secured packets (GN NextHeader=2) without verifying them.** Done 2026-09-11 in
|
||||
`gn_unwrap.c`, host-verified; flagged to the phone as V2X_RX flags bit1. On-air check under
|
||||
"Waiting on hardware".
|
||||
- [ ] **Forward the full GeoBroadcast area**: shape (circle/rectangle/ellipse), DistanceB, angle,
|
||||
appended to the V2X_RX prefix behind a capability bit. Port vanetza's `geonet/areas.cpp`
|
||||
`inside_or_at_border` to the app, which currently treats every area as a circle.
|
||||
- [ ] **RX filtering before the serial link**: duplicate detection for GBC (last 8 sequence numbers
|
||||
per source, as vanetza does), drop our own frames, reject GN version != 1.
|
||||
- [ ] **Read the DCC-MCO field** (the 4 "reserved" bytes of an SHB header): neighbours' channel
|
||||
busy ratio for free.
|
||||
- [ ] **Minimum TX gap in firmware** as a DCC safety net (vanetza reactive table: 60 ms relaxed ...
|
||||
460 ms restrictive), with a CBR estimate in the heartbeat.
|
||||
- [ ] **Generic V2X_TX message** (BTP port, SHB/GBC, traffic class, lifetime, area) so the phone can
|
||||
send DENM and VAM without reflashing. Consider QoS Data frames: vanetza's Cohda receive path
|
||||
drops non-QoS ones.
|
||||
|
||||
Dropped: building vanetza as a GN/BTP oracle. Real captures (`pcap_gn_tally.py`), the host
|
||||
round-trip test and `asn1tools` for UPER cover what it would have checked.
|
||||
|
||||
## Follow-ups found 2026-09-11
|
||||
|
||||
- [ ] **App: show the signed flag.** `V2xRxFrame.parse` in `SerialFrame.kt` only reads bit0 of
|
||||
the flags byte; read bit1 (signed, not verified) and show it where messages are listed.
|
||||
- [ ] **Messages that do not decode with asn1tools.** In the recordings, 56 from the CiT One
|
||||
(`aa:f8:76:7d:bd:ad`: 54 CAMs of 245 bytes, 2 DENMs of 402 bytes) and one 218-byte CAM from
|
||||
`6e:94:03:1b:05:26` fail against `cam_1_4_1`/`denm_1_3_1` + `cdd_1_3_1_1`, with or without the
|
||||
old trailing bytes. A newer module version on the sender, or a sender bug; check what the
|
||||
app's decoders make of them (`check_replay.py` lists the records).
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Networking (MQTT / tile downloads) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -10,6 +11,15 @@
|
||||
|
||||
<!-- Location -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<!-- BLE link to the ESP32-C5 (BleLinkTransport). Android 12+ asks for scan/connect at runtime;
|
||||
older versions use the legacy pair plus location, which is requested anyway. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Phase A: foreground service (trip recording) -->
|
||||
|
||||
Binary file not shown.
@@ -26,10 +26,12 @@ import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.ui.components.StatusTopBar
|
||||
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
|
||||
@@ -40,6 +42,7 @@ import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.SensorScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.V2xMapScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.MapScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
|
||||
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
|
||||
@@ -86,7 +89,16 @@ class MainActivity : AppCompatActivity() {
|
||||
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
|
||||
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
||||
val obuHardware by mqttViewModel.obuHardware.collectAsState()
|
||||
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
|
||||
val esp32LinkState by mqttViewModel.esp32LinkState.collectAsState()
|
||||
val esp32Transport by mqttViewModel.esp32Transport.collectAsState()
|
||||
val outgoingMessage by mqttViewModel.outgoingMessage.collectAsState()
|
||||
val signOutgoing by mqttViewModel.signOutgoing.collectAsState()
|
||||
// Received hazards and live signal state, for the Dashboard's V2X summary cards.
|
||||
// Both flows already expire their own entries on a clock, so nothing here has to
|
||||
// decide when a hazard or a traffic light has gone stale.
|
||||
val denmEvents by mqttViewModel.denmEvents.collectAsState()
|
||||
val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
|
||||
val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState()
|
||||
|
||||
MicrOBUTheme(darkTheme = state.darkTheme) {
|
||||
val view = LocalView.current
|
||||
@@ -98,6 +110,13 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
val navController = rememberNavController()
|
||||
|
||||
// The V2X live map is a full-bleed destination: the app's own chrome would eat a
|
||||
// third of the display on the one screen whose entire job is showing where things
|
||||
// are relative to each other. It carries its own floating back button, and system
|
||||
// back still works, so nothing becomes unreachable.
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val isFullBleed = currentBackStackEntry?.destination?.route == Screen.V2xMap.route
|
||||
|
||||
val locationLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
@@ -113,11 +132,17 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.startImuStreams()
|
||||
// Bluetooth scan/connect ride along with location (Android 12+): the ESP32-C5
|
||||
// can be reached over BLE, and a denial only matters if that is selected, where
|
||||
// BleLinkTransport then says so instead of silently finding nothing.
|
||||
val bluetooth = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else emptyArray()
|
||||
locationLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
)
|
||||
) + bluetooth
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -126,14 +151,17 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
StatusTopBar(
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||
usbSerialState = usbSerialState,
|
||||
)
|
||||
if (!isFullBleed) {
|
||||
StatusTopBar(
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||
esp32LinkState = esp32LinkState,
|
||||
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = { BottomNavBar(navController) },
|
||||
bottomBar = { if (!isFullBleed) BottomNavBar(navController) },
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
@@ -146,24 +174,34 @@ class MainActivity : AppCompatActivity() {
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
activeTransport = activeTransport,
|
||||
obuHardware = obuHardware,
|
||||
usbSerialState = usbSerialState,
|
||||
esp32LinkState = esp32LinkState,
|
||||
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
|
||||
usbCableConnected = usbConnected,
|
||||
obuStationTypeWarning = obuStationTypeWarning,
|
||||
obuStationType = obuStationType,
|
||||
hazards = denmEvents,
|
||||
signals = spatIntersections,
|
||||
ownPosition = ownCamPosition,
|
||||
onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
|
||||
onNavigateToSensors = {
|
||||
navController.navigate(Screen.Sensors.route) {
|
||||
popUpTo(Screen.Dashboard.route) { saveState = true }
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToMap = { navController.navigate(Screen.Map.route) },
|
||||
onNavigateToRecord = {
|
||||
navController.navigate(Screen.Record.route) {
|
||||
popUpTo(Screen.Dashboard.route) { saveState = true }
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
// Same options the bottom bar uses, so arriving at V2X from a
|
||||
// Dashboard card leaves the same back stack as tapping the tab.
|
||||
onNavigateToV2x = {
|
||||
navController.navigate(Screen.MqttViewer.route) {
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -176,7 +214,7 @@ class MainActivity : AppCompatActivity() {
|
||||
state = state,
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
|
||||
usbSerialState == UsbSerialState.CONNECTED
|
||||
esp32LinkState == Esp32LinkState.CONNECTED
|
||||
else
|
||||
mqttConnectionState == MqttConnectionState.CONNECTED,
|
||||
tripServiceState = tripServiceState,
|
||||
@@ -233,15 +271,24 @@ class MainActivity : AppCompatActivity() {
|
||||
val trip = trips.firstOrNull { it.id == tripId }
|
||||
|
||||
if (trip != null) {
|
||||
TripReviewScreen(
|
||||
trip = trip,
|
||||
viewModel = tripViewModel,
|
||||
)
|
||||
TripReviewScreen(trip = trip)
|
||||
}
|
||||
}
|
||||
|
||||
composable(Screen.MqttViewer.route) {
|
||||
MqttTopicViewerScreen(viewModel = mqttViewModel)
|
||||
MqttTopicViewerScreen(
|
||||
viewModel = mqttViewModel,
|
||||
onOpenMap = { navController.navigate(Screen.V2xMap.route) },
|
||||
)
|
||||
}
|
||||
composable(Screen.V2xMap.route) {
|
||||
// Activity-scoped instance, like Connection below: a hiltViewModel()
|
||||
// here would be scoped to this NavBackStackEntry and torn down on the
|
||||
// way back out, taking the shared transport with it.
|
||||
V2xMapScreen(
|
||||
viewModel = mqttViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
@@ -269,6 +316,12 @@ class MainActivity : AppCompatActivity() {
|
||||
onMqttPrefsChange = mqttViewModel::updatePrefs,
|
||||
obuHardware = obuHardware,
|
||||
onObuHardwareChange = mqttViewModel::setObuHardware,
|
||||
esp32Transport = esp32Transport,
|
||||
onEsp32TransportChange = mqttViewModel::setEsp32Transport,
|
||||
outgoingMessage = outgoingMessage,
|
||||
onOutgoingMessageChange = mqttViewModel::setOutgoingMessage,
|
||||
signOutgoing = signOutgoing,
|
||||
onSignOutgoingChange = mqttViewModel::setSignOutgoing,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.hawhamburg.micr0bu.data
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import java.time.DateTimeException
|
||||
|
||||
/**
|
||||
* Puts the timestamps this phone transmits on GNSS time instead of its own wall clock.
|
||||
*
|
||||
* ## Why
|
||||
* Every CAM carries a generationDeltaTime and every GeoNetworking header a TST, and receivers use
|
||||
* them to judge how fresh a message is and in what order messages came. Both used to come straight
|
||||
* from `System.currentTimeMillis()`, so they were only as good as the phone's clock setting. On
|
||||
* 2026-09-10 the bench phone was 24 minutes fast: automatic time had no source (no SIM, and the
|
||||
* lab Wi-Fi has no internet time), so it had not set the clock once in 69 hours, and every CAM
|
||||
* went out stamped 24 minutes in the future. A bike-mounted phone on the road is in exactly that
|
||||
* position. GNSS time depends on none of it.
|
||||
*
|
||||
* ## How
|
||||
* [SystemClock.currentGnssTimeClock] (API 29, this app's minSdk) is a UTC clock the platform keeps
|
||||
* synchronised from GNSS fixes. One reading of it taken alongside the wall clock gives the wall
|
||||
* clock's error, which is then applied to the fix's own timestamp. When GNSS time is unavailable,
|
||||
* the last error measured is kept, because the wall clock's error changes slowly while GNSS time
|
||||
* comes and goes indoors. Only before any GNSS time since the app started is the wall clock used
|
||||
* unchanged.
|
||||
*
|
||||
* Keeping it matters: with the bench phone 14 minutes fast (2026-09-23), dropping back to the raw
|
||||
* wall clock whenever GNSS time blinked out made every transmitted timestamp jump 14 minutes back
|
||||
* and forth, and the micrOBU restarted its stack at each jump back (time_regression).
|
||||
*
|
||||
* Which clock is in use is logged whenever it changes, with the measured error, so a capture shows
|
||||
* where a given run's timestamps came from.
|
||||
*
|
||||
* Only the transmit path uses this. Everything else in the app stays on the wall clock, because
|
||||
* received messages, sensor samples and trip records are all stamped with it and must stay
|
||||
* comparable with one another.
|
||||
*/
|
||||
object GnssTimeSource {
|
||||
private const val TAG = "GnssTimeSource"
|
||||
|
||||
/** Whether the last correction used GNSS time; null before the first. For change-only logging. */
|
||||
@Volatile private var lastUsedGnss: Boolean? = null
|
||||
|
||||
/** GNSS time minus wall clock at the last reading of both; null until GNSS time was first seen. */
|
||||
@Volatile private var lastErrorMs: Long? = null
|
||||
|
||||
/** [systemMs], a wall-clock reading, moved onto GNSS time where GNSS time is available. */
|
||||
fun correct(systemMs: Long): Long {
|
||||
val systemNow = System.currentTimeMillis()
|
||||
val gnssNow = try {
|
||||
SystemClock.currentGnssTimeClock().millis()
|
||||
} catch (e: DateTimeException) {
|
||||
null
|
||||
}
|
||||
if (gnssNow != null) lastErrorMs = gnssNow - systemNow
|
||||
noteSource(gnssNow, systemNow)
|
||||
return ItsTime.onGnssTime(systemMs, lastErrorMs?.let { systemNow + it }, systemNow)
|
||||
}
|
||||
|
||||
private fun noteSource(gnssNow: Long?, systemNow: Long) {
|
||||
val usingGnss = gnssNow != null
|
||||
if (lastUsedGnss == usingGnss) return
|
||||
lastUsedGnss = usingGnss
|
||||
if (gnssNow != null) {
|
||||
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
|
||||
"${"%+.1f".format((systemNow - gnssNow) / 1000.0)} s off")
|
||||
} else if (lastErrorMs != null) {
|
||||
Log.i(TAG, "GNSS time unavailable, keeping the last measured phone clock error of " +
|
||||
"${"%+.1f".format(-lastErrorMs!! / 1000.0)} s")
|
||||
} else {
|
||||
Log.w(TAG, "GNSS time unavailable, transmit timestamps fall back to the phone clock, " +
|
||||
"which has no automatic time source without a SIM or internet")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.hawhamburg.micr0bu.data
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -23,12 +22,15 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
||||
|
||||
/**
|
||||
* Builds a single combined CSV for one trip: the raw sensor samples recorded alongside it, the
|
||||
* events the detector fired, the GPS track, and every V2X message seen during the ride — all in
|
||||
* one file, ordered by time.
|
||||
* GPS track, and every V2X message seen during the ride — all in one file, ordered by time.
|
||||
*
|
||||
* Detected manoeuvres are deliberately absent. The detector exists to raise the CAM transmit
|
||||
* rate (see EventDetector's KDoc); its output is not retained, so there is nothing to export
|
||||
* beyond the per-trip count in the header.
|
||||
*
|
||||
* **Why one file rather than a zip of tables.** The point of the export is correlation: what was
|
||||
* the bike doing when that CAM arrived, what did the detector make of it. Splitting those into
|
||||
* separate files pushes the join onto whoever opens it. A leading `type` column keeps the rows
|
||||
* the bike doing when that CAM arrived. Splitting those into separate files pushes the join
|
||||
* onto whoever opens it. A leading `type` column keeps the rows
|
||||
* distinguishable, which is the same shape the existing session CSV already uses, so the two
|
||||
* remain readable by the same tooling.
|
||||
*
|
||||
@@ -44,7 +46,6 @@ fun tripFileName(trip: RecordedTripEntity): String =
|
||||
suspend fun buildTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
events: List<DetectedEventEntity>,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
): String = withContext(Dispatchers.IO) {
|
||||
buildString {
|
||||
@@ -59,7 +60,6 @@ suspend fun buildTripCsv(
|
||||
appendLine()
|
||||
appendLine(
|
||||
"type,timestamp_ms,timestamp_iso,lat,lon,speed_ms,heading_deg," +
|
||||
"event_type,confidence,peak_accel,peak_gyro,duration_ms," +
|
||||
"station_id,station_type,is_own,yaw_rate_dps,rssi_dbm"
|
||||
)
|
||||
|
||||
@@ -68,16 +68,6 @@ suspend fun buildTripCsv(
|
||||
appendLine(
|
||||
"gps,${point.timestamp},${isoUtc.format(Date(point.timestamp))}," +
|
||||
"${point.lat},${point.lon},,," +
|
||||
",,,,," +
|
||||
",,,"
|
||||
)
|
||||
}
|
||||
|
||||
for (e in events) {
|
||||
appendLine(
|
||||
"event,${e.timestamp},${isoUtc.format(Date(e.timestamp))}," +
|
||||
"${e.latitude},${e.longitude},${e.speedMps},," +
|
||||
"${e.type},${e.confidence},${e.peakAccelMagnitude},${e.peakGyroMagnitude},${e.durationMs}," +
|
||||
",,,,"
|
||||
)
|
||||
}
|
||||
@@ -86,14 +76,13 @@ suspend fun buildTripCsv(
|
||||
appendLine(
|
||||
"v2x,${m.timestamp},${isoUtc.format(Date(m.timestamp))}," +
|
||||
"${m.latitude},${m.longitude},${m.speedMps},${m.headingDeg}," +
|
||||
",,,,," +
|
||||
"${m.stationId},${m.stationType},${m.isOwn},${m.yawRateDps ?: ""},${m.rssiDbm ?: ""}"
|
||||
)
|
||||
}
|
||||
|
||||
// Raw sensor samples, copied verbatim from the session CSV. Appended last rather than
|
||||
// merge-sorted in: a long ride is hundreds of thousands of rows, and sorting them against
|
||||
// the (comparatively tiny) event/V2X sets in memory would defeat the streaming that
|
||||
// the (comparatively tiny) V2X set in memory would defeat the streaming that
|
||||
// CsvExporter deliberately does. Each row carries its own timestamp, so sort on load.
|
||||
val sessionCsv = trip.sessionId?.let { File(File(context.filesDir, "sessions"), "$it.csv") }
|
||||
if (sessionCsv != null && sessionCsv.exists()) {
|
||||
@@ -110,12 +99,11 @@ suspend fun buildTripCsv(
|
||||
suspend fun shareTripCsv(
|
||||
context: Context,
|
||||
trip: RecordedTripEntity,
|
||||
events: List<DetectedEventEntity>,
|
||||
v2xMessages: List<V2xMessageEntity>,
|
||||
) {
|
||||
val fileName = tripFileName(trip)
|
||||
val cacheFile = File(context.cacheDir, fileName)
|
||||
val csv = buildTripCsv(context, trip, events, v2xMessages)
|
||||
val csv = buildTripCsv(context, trip, v2xMessages)
|
||||
|
||||
withContext(Dispatchers.IO) { cacheFile.writeText(csv) }
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@ package com.hawhamburg.micr0bu.data
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
@@ -15,7 +13,7 @@ import java.io.File
|
||||
private const val TAG = "TripRepository"
|
||||
|
||||
/**
|
||||
* Repository that abstracts Room access for trips and detected events.
|
||||
* Repository that abstracts Room access for trips and V2X messages.
|
||||
*
|
||||
* All suspend functions are safe to call from a coroutine running on any
|
||||
* dispatcher; Room executes the actual SQL on its own I/O thread pool.
|
||||
@@ -81,14 +79,11 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
||||
* One-shot snapshots for export. The Flow-returning variants above stay observable for the UI;
|
||||
* an export wants a value it can write out, not a stream it has to unsubscribe from.
|
||||
*/
|
||||
suspend fun getEventsForTripOnce(tripId: Long): List<DetectedEventEntity> =
|
||||
dao.getEventsForTrip(tripId).first()
|
||||
|
||||
suspend fun getV2xMessagesForTripOnce(tripId: Long): List<V2xMessageEntity> =
|
||||
dao.getV2xMessagesForTrip(tripId).first()
|
||||
|
||||
/**
|
||||
* Deletes a trip and everything belonging to it: detected events and V2X messages go via the
|
||||
* Deletes a trip and everything belonging to it: V2X messages go via the
|
||||
* schema's CASCADE foreign keys, and the CSV recorded alongside it is removed here.
|
||||
*
|
||||
* The CSV is a plain file outside the database, so nothing deletes it implicitly - before
|
||||
@@ -109,32 +104,6 @@ class TripRepository(db: AppDatabase, private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persists a domain [DetectedEvent] for the given [tripId].
|
||||
* Converts the domain model to the Room entity.
|
||||
*/
|
||||
suspend fun insertEvent(tripId: Long, event: DetectedEvent) =
|
||||
dao.insertEvent(
|
||||
DetectedEventEntity(
|
||||
tripId = tripId,
|
||||
timestamp = event.timestamp,
|
||||
type = event.type.name,
|
||||
confidence = event.confidence.name,
|
||||
latitude = event.latitude,
|
||||
longitude = event.longitude,
|
||||
speedMps = event.speedMps.toFloat(),
|
||||
peakAccelMagnitude = event.peakAccelMagnitude.toFloat(),
|
||||
peakGyroMagnitude = event.peakGyroMagnitude.toFloat(),
|
||||
durationMs = event.durationMs,
|
||||
)
|
||||
)
|
||||
|
||||
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
|
||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
|
||||
dao.getEventsForTrip(tripId)
|
||||
|
||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||
// Retention policy: only ever called while a trip is actively recording — see
|
||||
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
|
||||
|
||||
@@ -7,25 +7,32 @@ import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_CAM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_DENM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RAW_SPATEM_TOPIC
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
|
||||
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.BtpPort
|
||||
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
|
||||
import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
||||
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamParser
|
||||
import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatEvent
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.service.CamPinger
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -40,6 +47,7 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -55,6 +63,16 @@ private const val PRUNE_INTERVAL_MS = 1_000L
|
||||
// missed updates, not just normal jitter between samples.
|
||||
private const val OBU_GNSS_STALE_MS = 2_500L
|
||||
|
||||
/**
|
||||
* How long a raw `v2x/rx/cam` message keeps the Use Case app's CAM topic suppressed.
|
||||
*
|
||||
* The two topics carry the same traffic, but `v2x-uca/output/json/cam` is rate-limited and drops
|
||||
* messages, so while the raw topic is arriving there is nothing the processed one can add. A few
|
||||
* seconds is many missed repetitions at CAM rates, so this only lapses if the raw topic really
|
||||
* has stopped, which is what makes the fallback automatic on an OBU that does not publish it.
|
||||
*/
|
||||
private const val RAW_PREFERRED_WINDOW_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Bridges the raw MQTT CAM stream (plus the ego's own obu_gnss/phone GNSS state) to
|
||||
* [UseCaseDetectionEngine] and exposes the resulting CAM-based Use Case Alerts to the UI
|
||||
@@ -72,16 +90,23 @@ private const val OBU_GNSS_STALE_MS = 2_500L
|
||||
* A singleton so detection keeps running (and alert state survives) even while no screen is
|
||||
* collecting it — same rationale as [MqttRepository]'s per-topic message log.
|
||||
*
|
||||
* DENM is decoded from the ESP32-C5 serial path (see [airDenm]) but deliberately kept out of
|
||||
* **Two decode sources, one funnel.** UPER arrives either from the ESP32-C5 serial link or, on
|
||||
* the CiT One path, from the raw `v2x/rx` protobuf topics ([RecvV2xMessage]). Both end up in
|
||||
* the same handlers, so everything downstream is transport-agnostic. The CiT One's processed
|
||||
* `v2x-uca/output/json` topics remain a fallback for an OBU that does not publish the raw ones.
|
||||
*
|
||||
* DENM is decoded from both (see [decodedDenm]) but deliberately kept out of
|
||||
* [UseCaseDetectionEngine] — that engine reasons about moving road users from CAM kinematics.
|
||||
*/
|
||||
@Singleton
|
||||
class CamUseCaseRepository @Inject constructor(
|
||||
private val mqttRepository: MqttRepository,
|
||||
private val prefs: UseCaseAlertPreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val esp32Link: Esp32Link,
|
||||
private val camCodec: RealAsn1UperCodec,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
private val camPinger: CamPinger,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -98,6 +123,9 @@ class CamUseCaseRepository @Inject constructor(
|
||||
@Volatile private var lastOwnStationType: Int = StationType.CYCLIST
|
||||
@Volatile private var lastObuGnssTimestamp: Long = 0L
|
||||
|
||||
/** When a raw `v2x/rx/cam` message last arrived, for [rawCamPreferred]. */
|
||||
@Volatile private var lastRawCamMs: Long = 0L
|
||||
|
||||
/** Per-use-case enable/disable toggles (Settings > Use Case Alerts). */
|
||||
val enabledMap: StateFlow<Map<UseCaseType, Boolean>> = prefs.enabledMapFlow.stateIn(
|
||||
scope, SharingStarted.Eagerly, UseCaseType.entries.associateWith { true },
|
||||
@@ -140,21 +168,42 @@ class CamUseCaseRepository @Inject constructor(
|
||||
*/
|
||||
val rsuStations: StateFlow<Map<Long, Cam>> = _rsuStations.asStateFlow()
|
||||
|
||||
private val _airSpat = MutableSharedFlow<SpatEvent>(replay = 16, extraBufferCapacity = 32)
|
||||
private val _ownTxLoopback = MutableStateFlow<OwnTxLoopback?>(null)
|
||||
/**
|
||||
* SPATEMs decoded from over-the-air traffic on the ESP32-C5 path. Replayed so a screen opened
|
||||
* mid-stream sees the current signal state immediately rather than waiting up to half a second
|
||||
* for the next repetition.
|
||||
* Our own transmissions heard back off the air, or null until one is.
|
||||
*
|
||||
* These frames are dropped from the detection engine, correctly, since the phone is not a
|
||||
* road user to itself. But dropping them silently threw away the one thing that proves the
|
||||
* whole radio loop works: the frame went out over serial, the ESP32 transmitted it, and the
|
||||
* ESP32 received it again. That is precisely what the bench pinger exists to demonstrate, so
|
||||
* it is counted here and reported rather than discarded.
|
||||
*
|
||||
* ESP32-C5 path in practice. The CiT One does not normally hear its own transmissions.
|
||||
*/
|
||||
val airSpat: SharedFlow<SpatEvent> = _airSpat.asSharedFlow()
|
||||
val ownTxLoopback: StateFlow<OwnTxLoopback?> = _ownTxLoopback.asStateFlow()
|
||||
|
||||
private val _airDenm = MutableSharedFlow<DenmEvent>(replay = 32, extraBufferCapacity = 32)
|
||||
/** Clears the loopback tally. Called when a fresh pinger run starts, so the count is per run. */
|
||||
fun resetOwnTxLoopback() { _ownTxLoopback.value = null }
|
||||
|
||||
private val _decodedSpat = MutableSharedFlow<SpatEvent>(replay = 16, extraBufferCapacity = 32)
|
||||
/**
|
||||
* DENMs decoded from over-the-air traffic on the ESP32-C5 path. `replay` so a screen opened
|
||||
* after a hazard was first heard still sees it - DENMs repeat at ~1 Hz but a subscriber that
|
||||
* missed the last repetition shouldn't have to wait for the next.
|
||||
* SPATEMs decoded from UPER, from either hardware path: the ESP32-C5 serial link or the CiT
|
||||
* One's `v2x/rx/spatem` topic. Replayed so a screen opened mid-stream sees the current signal
|
||||
* state immediately rather than waiting up to half a second for the next repetition.
|
||||
*
|
||||
* The CiT One's own `v2x-uca/output/json/spat` topic is not a source here. It was never
|
||||
* parsed, so before the raw topic was wired up this path produced no signal state at all.
|
||||
*/
|
||||
val airDenm: SharedFlow<DenmEvent> = _airDenm.asSharedFlow()
|
||||
val decodedSpat: SharedFlow<SpatEvent> = _decodedSpat.asSharedFlow()
|
||||
|
||||
private val _decodedDenm = MutableSharedFlow<DenmEvent>(replay = 32, extraBufferCapacity = 32)
|
||||
/**
|
||||
* DENMs decoded from UPER, from either hardware path: the ESP32-C5 serial link or the CiT
|
||||
* One's `v2x/rx/denm` topic. `replay` so a screen opened after a hazard was first heard still
|
||||
* sees it - DENMs repeat at ~1 Hz but a subscriber that missed the last repetition shouldn't
|
||||
* have to wait for the next.
|
||||
*/
|
||||
val decodedDenm: SharedFlow<DenmEvent> = _decodedDenm.asSharedFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
@@ -166,6 +215,37 @@ class CamUseCaseRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// CiT One raw path: every message the OBU's radio heard, as protobuf, decoded here with
|
||||
// the same codecs the serial path uses. This is what makes the CiT One see traffic the
|
||||
// Use Case app filtered out, the ESP32-C5's CAM pinger among it, and it is the only
|
||||
// source of SPATEM on this hardware.
|
||||
scope.launch {
|
||||
mqttRepository.rawV2x.collect { raw ->
|
||||
val envelope = RecvV2xMessage.parse(raw.bytes)
|
||||
if (envelope == null) {
|
||||
Log.w(TAG, "rawV2x: unparseable RecvV2XMessage on ${raw.topic}, " +
|
||||
"${raw.bytes.size} bytes - first bytes: ${raw.bytes.toHexPreview()}")
|
||||
return@collect
|
||||
}
|
||||
when (raw.topic) {
|
||||
RAW_CAM_TOPIC -> {
|
||||
lastRawCamMs = raw.timestamp
|
||||
handleCamUper(envelope.payload, rssiDbm = null, source = "mqtt")
|
||||
}
|
||||
// The GeoBroadcast radius comes off the GeoNetworking header the same way it
|
||||
// does on the serial path, so a hazard's relevance area survives here too.
|
||||
RAW_DENM_TOPIC -> handleDenmUper(
|
||||
uper = envelope.payload,
|
||||
rssiDbm = null,
|
||||
relevanceRadiusM = envelope.destAreaRadiusM,
|
||||
source = "mqtt",
|
||||
)
|
||||
RAW_SPATEM_TOPIC -> handleSpatUper(envelope.payload, rssiDbm = null, source = "mqtt")
|
||||
else -> Log.w(TAG, "rawV2x: unexpected topic ${raw.topic}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phone GNSS fallback — only applied when obu_gnss has gone stale (see class KDoc).
|
||||
// Retries in a loop: this singleton can be created before the user grants location
|
||||
// permission (requested at app startup), so a single subscription attempt isn't
|
||||
@@ -205,9 +285,9 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// this only ever sees CAM UPER bytes. No-op stream on the CiT One path (the transport
|
||||
// just never emits CAM_RX frames if nothing's plugged in over serial).
|
||||
scope.launch {
|
||||
usbSerialTransport.incomingFrames.collect { frame ->
|
||||
esp32Link.incomingFrames.collect { frame ->
|
||||
if (frame.type != SerialFrameType.V2X_RX) return@collect
|
||||
if (usbSerialTransport.state.value != UsbSerialState.CONNECTED) return@collect
|
||||
if (esp32Link.state.value != Esp32LinkState.CONNECTED) return@collect
|
||||
val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect
|
||||
when (v2x.btpPort) {
|
||||
BtpPort.CAM -> handleCamFromSerial(v2x)
|
||||
@@ -224,10 +304,10 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// last-seen positions and their alerts linger on the map and in the use-case panel after
|
||||
// an unplug, which reads as live traffic - the worst kind of stale on a safety display.
|
||||
scope.launch {
|
||||
usbSerialTransport.state.collect { state ->
|
||||
esp32Link.state.collect { state ->
|
||||
// ESP32-only: on the CiT One path this transport is permanently DISCONNECTED and
|
||||
// resetting here would wipe perfectly good MQTT-derived state.
|
||||
if (currentHardware == ObuHardware.ESP32_C5 && state != UsbSerialState.CONNECTED) {
|
||||
if (currentHardware == ObuHardware.ESP32_C5 && state != Esp32LinkState.CONNECTED) {
|
||||
engine.reset()
|
||||
_rsuStations.value = emptyMap()
|
||||
}
|
||||
@@ -235,8 +315,8 @@ class CamUseCaseRepository @Inject constructor(
|
||||
}
|
||||
|
||||
// Our own station ID. On the CiT One path it's learned from v2x/rx/obu_gnss; the ESP32-C5
|
||||
// path has no such topic, so it comes from the same persisted value CamTransmitLoop puts
|
||||
// in outgoing CAMs.
|
||||
// path has no such topic, so it follows the current transmit pseudonym, the same one
|
||||
// CamTransmitLoop puts in outgoing CAMs, across every rotation.
|
||||
//
|
||||
// Without this the ID stayed null on the ESP32 path and the self-heard-TX filter in
|
||||
// [handleCamFromSerial] never fired - so the phone's own CAMs, which the ESP32 hears back
|
||||
@@ -244,10 +324,13 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// sitting exactly on top of the ego position, fed into the detection engine as a
|
||||
// collision partner for itself.
|
||||
scope.launch {
|
||||
obuHardwarePrefs.obuHardwareFlow.collect { hardware ->
|
||||
combine(obuHardwarePrefs.obuHardwareFlow, pseudonymManager.currentFlow) { hardware, pseudonym ->
|
||||
hardware to pseudonym
|
||||
}.collect { (hardware, pseudonym) ->
|
||||
currentHardware = hardware
|
||||
if (hardware == ObuHardware.ESP32_C5) {
|
||||
_ownStationId.value = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
// currentFlow re-emits on every rotation, so this tracks the live identity.
|
||||
_ownStationId.value = (pseudonym ?: pseudonymManager.current()).stationId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,8 +340,22 @@ class CamUseCaseRepository @Inject constructor(
|
||||
scope.launch { prefs.setEnabled(type, enabled) }
|
||||
}
|
||||
|
||||
/** True if [stationId] matches the ego OBU's own station ID (for OWN/REMOTE UI badges). */
|
||||
fun isOwnStationId(stationId: Long): Boolean = stationId != 0L && stationId == _ownStationId.value
|
||||
/**
|
||||
* True if [stationId] is one this phone transmits under, so a frame heard back off the air is
|
||||
* recognised as our own rather than tracked as another road user. Also drives the OWN/REMOTE
|
||||
* badges in the raw message list.
|
||||
*
|
||||
* The rule lives in [OwnStationIds], which explains which ids count and what goes wrong when
|
||||
* one is missed. The set passed in holds the current transmit pseudonym and the ones it most
|
||||
* recently replaced, plus, on the CiT One path, the OBU's own id from obu_gnss. The bench
|
||||
* ping id counts only while this phone's own pinger is running.
|
||||
*/
|
||||
fun isOwnStationId(stationId: Long): Boolean =
|
||||
OwnStationIds.isOwn(
|
||||
stationId,
|
||||
ownIds = pseudonymManager.ownStationIds() + setOfNotNull(_ownStationId.value),
|
||||
benchPingIsOurs = camPinger.benchPingIsOurs(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Primary ego state source: `v2x/rx/obu_gnss`, ~4 Hz, carries position/speed/heading/yaw
|
||||
@@ -301,16 +398,32 @@ class CamUseCaseRepository @Inject constructor(
|
||||
_processedCam.tryEmit(ego)
|
||||
}
|
||||
|
||||
/** True while `v2x/rx/cam` is arriving, in which case the processed CAM topic adds nothing. */
|
||||
private fun rawCamPreferred(now: Long): Boolean =
|
||||
lastRawCamMs != 0L && now - lastRawCamMs <= RAW_PREFERRED_WINDOW_MS
|
||||
|
||||
private fun handleCam(payload: String, timestamp: Long) {
|
||||
val cam = CamParser.parse(payload, _ownStationId.value, timestamp) ?: return
|
||||
// This phone's own bench ping, relayed back by the CiT One's radio: not a road user, and not
|
||||
// ego state either, since it is built from the same phone GNSS the engine already has.
|
||||
// Only while this phone is the one pinging, though. Another phone's pings carry the same
|
||||
// fixed id and are genuine remote traffic to this one.
|
||||
if (cam.stationId == OwnStationIds.BENCH_PING && camPinger.benchPingIsOurs()) return
|
||||
if (cam.isOwn) {
|
||||
// Third fallback — the CAM topic's own low-rate entry. onOwnCam() keeps whichever
|
||||
// Third fallback - the CAM topic's own low-rate entry. onOwnCam() keeps whichever
|
||||
// update is freshest, so this only actually wins when both obu_gnss and phone GNSS
|
||||
// are unavailable/stale.
|
||||
// are unavailable/stale. Deliberately still processed while the raw topic is live:
|
||||
// v2x/rx/cam is a receive topic and never carries the ego station's own CAM, so
|
||||
// suppressing this would remove the fallback without anything replacing it.
|
||||
engine.onOwnCam(cam)
|
||||
} else {
|
||||
engine.onRemoteCam(cam)
|
||||
_processedCam.tryEmit(cam)
|
||||
return
|
||||
}
|
||||
// A remote CAM the raw topic has already delivered, in fuller form and without the Use
|
||||
// Case app's rate limiting. Dropping it here rather than letting both reach the engine
|
||||
// keeps one station from being fed by two sources at two different rates.
|
||||
if (rawCamPreferred(timestamp)) return
|
||||
engine.onRemoteCam(cam)
|
||||
_processedCam.tryEmit(cam)
|
||||
}
|
||||
|
||||
@@ -325,7 +438,12 @@ class CamUseCaseRepository @Inject constructor(
|
||||
* its own just-transmitted frame (promiscuous capture of a local TX). Guarded the same way
|
||||
* the MQTT path guards against reprocessing "own" CAM: compare against [_ownStationId].
|
||||
*/
|
||||
private fun handleCamFromSerial(v2x: V2xRxFrame) {
|
||||
private fun handleCamFromSerial(v2x: V2xRxFrame) =
|
||||
handleCamUper(v2x.uper, v2x.rssiDbm, source = "serial")
|
||||
|
||||
/** Shared by both transports: [rssiDbm] is null on the MQTT path, which does not report it. */
|
||||
private fun handleCamUper(uper: ByteArray, rssiDbm: Int?, source: String) {
|
||||
val v2x = UperSource(uper, rssiDbm, source)
|
||||
val cam = camCodec.decodeCam(v2x.uper, System.currentTimeMillis())?.copy(rssiDbm = v2x.rssiDbm)
|
||||
if (cam == null) {
|
||||
// Logged, not silently dropped: "the app shows nothing" has two completely different
|
||||
@@ -333,14 +451,27 @@ class CamUseCaseRepository @Inject constructor(
|
||||
// without this line they're indistinguishable from the outside.
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleCamFromSerial: decode FAILED for ${v2x.uper.size}-byte CAM " +
|
||||
"handleCamUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte CAM " +
|
||||
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "handleCamFromSerial: decoded station=${cam.stationId} " +
|
||||
Log.d(TAG, "handleCamUper[${v2x.source}]: decoded station=${cam.stationId} " +
|
||||
"lat=${cam.latitude} lon=${cam.longitude} speed=${cam.speedMps} rssi=${v2x.rssiDbm} dBm")
|
||||
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX
|
||||
if (isOwnStationId(cam.stationId)) {
|
||||
// Ours, on either station id. Kept out of the engine, but counted: this is the
|
||||
// round trip completing, and it is the only direct evidence the radio path works.
|
||||
_ownTxLoopback.update { prev ->
|
||||
OwnTxLoopback(
|
||||
frames = (prev?.frames ?: 0) + 1,
|
||||
// Hold the last known reading rather than overwriting it with null on a
|
||||
// transport that does not report RSSI, so the figure does not blink away.
|
||||
lastRssiDbm = v2x.rssiDbm ?: prev?.lastRssiDbm,
|
||||
lastHeardMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Roadside units are infrastructure, not road users. Their CAM carries no kinematics (see
|
||||
// CamUperCodec's rsuContainerHighFrequency branch), so it reaches here as a permanently
|
||||
@@ -367,25 +498,38 @@ class CamUseCaseRepository @Inject constructor(
|
||||
* reasons about moving road users from CAM kinematics, and a static hazard is a different kind
|
||||
* of thing. DENMs go to the map and the message list only.
|
||||
*/
|
||||
private fun handleDenmFromSerial(v2x: V2xRxFrame) {
|
||||
private fun handleDenmFromSerial(v2x: V2xRxFrame) = handleDenmUper(
|
||||
uper = v2x.uper,
|
||||
rssiDbm = v2x.rssiDbm,
|
||||
relevanceRadiusM = v2x.geoArea?.radiusMeters,
|
||||
source = "serial",
|
||||
)
|
||||
|
||||
private fun handleDenmUper(
|
||||
uper: ByteArray,
|
||||
rssiDbm: Int?,
|
||||
relevanceRadiusM: Int?,
|
||||
source: String,
|
||||
) {
|
||||
val v2x = UperSource(uper, rssiDbm, source)
|
||||
val denm = DenmUperCodec.decode(
|
||||
bytes = v2x.uper,
|
||||
receivedAtEpochMs = System.currentTimeMillis(),
|
||||
rssiDbm = v2x.rssiDbm,
|
||||
relevanceRadiusM = v2x.geoArea?.radiusMeters,
|
||||
relevanceRadiusM = relevanceRadiusM,
|
||||
)
|
||||
if (denm == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleDenmFromSerial: decode FAILED for ${v2x.uper.size}-byte DENM " +
|
||||
"handleDenmUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte DENM " +
|
||||
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "handleDenmFromSerial: decoded station=${denm.stationId}/${denm.sequenceNumber} " +
|
||||
Log.d(TAG, "handleDenmUper[${v2x.source}]: decoded station=${denm.stationId}/${denm.sequenceNumber} " +
|
||||
"cause=${denm.causeCode}/${denm.subCauseCode} lat=${denm.latitude} lon=${denm.longitude} " +
|
||||
"radius=${denm.relevanceRadiusM}m termination=${denm.isTermination} rssi=${v2x.rssiDbm} dBm")
|
||||
_airDenm.tryEmit(denm)
|
||||
_decodedDenm.tryEmit(denm)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,7 +542,11 @@ class CamUseCaseRepository @Inject constructor(
|
||||
* counts it as an oversize drop, so on real road RSUs (median 555 bytes) most will not arrive
|
||||
* until that cap is raised. The bench trigger's ~58-byte messages are unaffected.
|
||||
*/
|
||||
private fun handleSpatFromSerial(v2x: V2xRxFrame) {
|
||||
private fun handleSpatFromSerial(v2x: V2xRxFrame) =
|
||||
handleSpatUper(v2x.uper, v2x.rssiDbm, source = "serial")
|
||||
|
||||
private fun handleSpatUper(uper: ByteArray, rssiDbm: Int?, source: String) {
|
||||
val v2x = UperSource(uper, rssiDbm, source)
|
||||
val spat = SpatemUperCodec.decode(
|
||||
bytes = v2x.uper,
|
||||
receivedAtEpochMs = System.currentTimeMillis(),
|
||||
@@ -407,17 +555,24 @@ class CamUseCaseRepository @Inject constructor(
|
||||
if (spat == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleSpatFromSerial: decode FAILED for ${v2x.uper.size}-byte SPATEM " +
|
||||
"handleSpatUper[${v2x.source}]: decode FAILED for ${v2x.uper.size}-byte SPATEM " +
|
||||
"(rssi=${v2x.rssiDbm} dBm) - first bytes: ${v2x.uper.toHexPreview()}",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "handleSpatFromSerial: decoded station=${spat.stationId} " +
|
||||
Log.d(TAG, "handleSpatUper[${v2x.source}]: decoded station=${spat.stationId} " +
|
||||
"intersections=${spat.intersections.joinToString { it.key }} " +
|
||||
"movements=${spat.intersections.sumOf { it.movements.size }} rssi=${v2x.rssiDbm} dBm")
|
||||
_airSpat.tryEmit(spat)
|
||||
_decodedSpat.tryEmit(spat)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bits of a received frame the decoders and their log lines need, independent of whether
|
||||
* it came off the serial link or an MQTT topic. [rssiDbm] is null on the MQTT path: the
|
||||
* RecvV2XMessage envelope does not carry signal strength.
|
||||
*/
|
||||
private data class UperSource(val uper: ByteArray, val rssiDbm: Int?, val source: String)
|
||||
|
||||
private fun ByteArray.toHexPreview(limit: Int = 16): String =
|
||||
take(limit).joinToString(" ") { "%02x".format(it) } + if (size > limit) " ..." else ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.hawhamburg.micr0bu.data.cam
|
||||
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Owns the phone's transmit identity on the ESP32-C5 path and rotates it every
|
||||
* [Pseudonym.ROTATION_INTERVAL_MS].
|
||||
*
|
||||
* A singleton because there must be exactly one of these. [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
||||
* runs inside the foreground recording service and [CamUseCaseRepository] filters received frames;
|
||||
* if each held its own identity, the phone could transmit under one pseudonym while its receive
|
||||
* path recognised another, which brings back the ghost road user sitting on the ego position.
|
||||
* The bench pinger deliberately does not use this: it keeps a fixed identity so pings stay
|
||||
* recognisable in a capture.
|
||||
*/
|
||||
@Singleton
|
||||
class PseudonymManager @Inject constructor(
|
||||
private val prefs: ObuHardwarePreferences,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val _current = MutableStateFlow<Pseudonym?>(null)
|
||||
|
||||
/** The identity in use, or null before the first call to [current] has loaded one. */
|
||||
val currentFlow: StateFlow<Pseudonym?> = _current.asStateFlow()
|
||||
|
||||
/** Station IDs replaced most recently, newest first. See [ownStationIds]. */
|
||||
@Volatile private var recentlyRetired: List<Long> = emptyList()
|
||||
|
||||
/**
|
||||
* Every station ID one of our own frames could still be carrying: the current pseudonym's and
|
||||
* the ones it replaced most recently.
|
||||
*
|
||||
* The previous IDs matter because the ESP32 hears our own transmissions back. A frame sent just
|
||||
* before a rotation can come back just after it, and if its ID no longer counted as ours it
|
||||
* would be tracked as another road user sitting exactly on the ego position.
|
||||
*/
|
||||
fun ownStationIds(): Set<Long> = buildSet {
|
||||
_current.value?.let { add(it.stationId) }
|
||||
addAll(recentlyRetired)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pseudonym to transmit under right now, rotating first if the current one has expired.
|
||||
*
|
||||
* Rotation happens here, at the moment an identity is about to be used, rather than on a
|
||||
* timer. Each frame therefore carries one complete identity chosen in a single step, so a
|
||||
* rotation can never land between the CAM being built and its position vector being attached.
|
||||
*
|
||||
* Persisted, so an app restart inside the interval keeps the same identity. Only elapsed time
|
||||
* rotates it, never a crash or a relaunch.
|
||||
*/
|
||||
suspend fun current(nowMs: Long = System.currentTimeMillis()): Pseudonym = mutex.withLock {
|
||||
val existing = _current.value ?: prefs.loadPseudonym()
|
||||
if (existing != null && !existing.isExpired(nowMs)) {
|
||||
_current.value = existing
|
||||
existing
|
||||
} else {
|
||||
val next = Pseudonym.generate(nowMs)
|
||||
prefs.savePseudonym(next)
|
||||
if (existing != null) {
|
||||
recentlyRetired = (listOf(existing.stationId) + recentlyRetired).take(RETIRED_TO_KEEP)
|
||||
}
|
||||
_current.update { next }
|
||||
Log.i(TAG, "pseudonym rotated: station ${existing?.stationId} -> ${next.stationId}")
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "PseudonymManager"
|
||||
|
||||
/**
|
||||
* A loopback arrives within milliseconds, so one previous ID would already be ample. Two
|
||||
* costs nothing and covers a rotation that fires twice in quick succession after a clock
|
||||
* correction.
|
||||
*/
|
||||
const val RETIRED_TO_KEEP = 2
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
entities = [
|
||||
SessionEntity::class,
|
||||
RecordedTripEntity::class,
|
||||
DetectedEventEntity::class,
|
||||
V2xMessageEntity::class,
|
||||
],
|
||||
version = 4,
|
||||
version = 5,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@@ -34,13 +33,29 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"micr0bu.db",
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
|
||||
// ── Migrations ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drops `detected_events`. The cyclist event detector still runs, but its output is now
|
||||
* consumed only by the CAM transmit-rate policy (see EventDetector's KDoc) and is no
|
||||
* longer persisted, displayed, or exported, so the table had no reader left.
|
||||
*
|
||||
* `trips.eventCount` is deliberately kept. Dropping a column means recreating `trips`
|
||||
* and copying every recorded ride across, which is real risk for one unused integer;
|
||||
* the service still writes an accurate count into it and the CSV header still reports it.
|
||||
*/
|
||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("DROP INDEX IF EXISTS `index_detected_events_tripId`")
|
||||
database.execSQL("DROP TABLE IF EXISTS `detected_events`")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two additions:
|
||||
* - `trips.sessionId` links a trip to the CSV recording session captured alongside it, so
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.hawhamburg.micr0bu.data.db
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One detected cyclist event (braking / turning / stopping) linked to a
|
||||
* [RecordedTripEntity] via the [tripId] foreign key.
|
||||
*
|
||||
* [type] and [confidence] are stored as the enum name strings so that the
|
||||
* database remains human-readable.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "detected_events",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = RecordedTripEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["tripId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
)
|
||||
data class DetectedEventEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
|
||||
@ColumnInfo(index = true)
|
||||
val tripId: Long,
|
||||
|
||||
/** Wall-clock epoch ms of the first qualifying sensor frame. */
|
||||
val timestamp: Long,
|
||||
|
||||
/** EventType.name — one of BRAKING, TURNING, STOPPING. */
|
||||
val type: String,
|
||||
|
||||
/** Confidence.name — one of HIGH, MEDIUM, LOW. */
|
||||
val confidence: String,
|
||||
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val speedMps: Float,
|
||||
val peakAccelMagnitude: Float,
|
||||
val peakGyroMagnitude: Float,
|
||||
|
||||
/** Duration from first qualifying frame to emission (ms). */
|
||||
val durationMs: Long,
|
||||
)
|
||||
@@ -27,17 +27,6 @@ interface TripDao {
|
||||
@Query("DELETE FROM trips WHERE id = :id")
|
||||
suspend fun deleteTripById(id: Long)
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertEvent(event: DetectedEventEntity)
|
||||
|
||||
@Query("SELECT * FROM detected_events WHERE tripId = :tripId ORDER BY timestamp ASC")
|
||||
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
|
||||
suspend fun getEventCountForTrip(tripId: Long): Int
|
||||
|
||||
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
|
||||
@@ -43,6 +43,13 @@ private val SUBSCRIBED_TOPICS = listOf(
|
||||
"sys/state/heartbeat",
|
||||
"sys/state/cellular",
|
||||
"v2x/rx/obu_gnss",
|
||||
// Everything the radio heard, as RecvV2XMessage protobuf (API section 2.4). Preferred over
|
||||
// the v2x-uca topics below, which are a rate-limited and lossy view of the same traffic.
|
||||
RAW_CAM_TOPIC,
|
||||
RAW_DENM_TOPIC,
|
||||
RAW_SPATEM_TOPIC,
|
||||
// Kept subscribed as a fallback for an OBU whose product configuration does not publish the
|
||||
// raw topics, and because the Use Case app is still the only source of its own alert output.
|
||||
"v2x-uca/output/json/cam",
|
||||
"v2x-uca/output/json/denm",
|
||||
"v2x-uca/output/json/spat",
|
||||
@@ -50,6 +57,31 @@ private val SUBSCRIBED_TOPICS = listOf(
|
||||
"v2x-uca/output/json/cpm",
|
||||
)
|
||||
|
||||
/** Raw received-V2X topics, carrying protobuf rather than JSON. See [RecvV2xMessage]. */
|
||||
const val RAW_CAM_TOPIC = "v2x/rx/cam"
|
||||
const val RAW_DENM_TOPIC = "v2x/rx/denm"
|
||||
const val RAW_SPATEM_TOPIC = "v2x/rx/spatem"
|
||||
|
||||
private val RAW_V2X_TOPICS = setOf(RAW_CAM_TOPIC, RAW_DENM_TOPIC, RAW_SPATEM_TOPIC)
|
||||
|
||||
/**
|
||||
* A message straight off a `v2x/rx` topic, before the protobuf envelope is opened.
|
||||
*
|
||||
* Carried as bytes, not [MqttMessage]: that type holds a String, and putting protobuf through
|
||||
* a UTF-8 round trip replaces every byte that is not valid UTF-8 with U+FFFD. The payload
|
||||
* survives looking plausible in a log and decodes to nothing.
|
||||
*/
|
||||
data class RawV2xMqttMessage(val topic: String, val bytes: ByteArray, val timestamp: Long) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is RawV2xMqttMessage) return false
|
||||
return topic == other.topic && timestamp == other.timestamp && bytes.contentEquals(other.bytes)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int =
|
||||
31 * (31 * topic.hashCode() + timestamp.hashCode()) + bytes.contentHashCode()
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class MqttRepository @Inject constructor(
|
||||
private val prefs: MqttPreferences,
|
||||
@@ -69,6 +101,21 @@ class MqttRepository @Inject constructor(
|
||||
)
|
||||
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
|
||||
|
||||
// Same buffering rationale as [_messages], with more headroom: this stream carries every CAM
|
||||
// the radio hears rather than the Use Case app's thinned-out selection, which at a busy
|
||||
// intersection is a considerably higher rate.
|
||||
private val _rawV2x = MutableSharedFlow<RawV2xMqttMessage>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 512,
|
||||
)
|
||||
|
||||
/**
|
||||
* Undecoded `v2x/rx` protobuf messages. Consumed by
|
||||
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which opens the envelope and runs
|
||||
* the UPER decoders over the payload, exactly as it does for the ESP32-C5 serial path.
|
||||
*/
|
||||
val rawV2x: SharedFlow<RawV2xMqttMessage> = _rawV2x.asSharedFlow()
|
||||
|
||||
// Per-topic message log, kept here (singleton) so it survives even when no screen is
|
||||
// collecting — e.g. DENM TX messages emitted by TripRecordingService while the V2X
|
||||
// Monitor screen isn't open.
|
||||
@@ -194,6 +241,12 @@ class MqttRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/** A one-line, printable stand-in for a binary payload, for the raw topic log. */
|
||||
private fun describeBinary(bytes: ByteArray, limit: Int = 24): String {
|
||||
val hex = bytes.take(limit).joinToString(" ") { "%02x".format(it) }
|
||||
return "${bytes.size} bytes protobuf: $hex" + if (bytes.size > limit) " ..." else ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a message into both the live [messages] stream (for screens currently open)
|
||||
* and the persistent [topicMessages] log (survives even when no screen is collecting).
|
||||
@@ -301,11 +354,26 @@ class MqttRepository @Inject constructor(
|
||||
override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) }
|
||||
|
||||
override fun messageArrived(topic: String, message: PahoMqttMessage) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (topic in RAW_V2X_TOPICS) {
|
||||
// Binary. The bytes go to the decoders untouched; the topic log gets a hex
|
||||
// preview instead, because decoding these to a String would show the operator
|
||||
// a screenful of replacement characters and imply the data was corrupt.
|
||||
_rawV2x.tryEmit(RawV2xMqttMessage(topic, message.payload, now))
|
||||
recordMessage(
|
||||
MqttMessage(
|
||||
topic = topic,
|
||||
payload = describeBinary(message.payload),
|
||||
timestamp = now,
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
recordMessage(
|
||||
MqttMessage(
|
||||
topic = topic,
|
||||
payload = message.payload.toString(Charsets.UTF_8),
|
||||
timestamp = System.currentTimeMillis(),
|
||||
timestamp = now,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package com.hawhamburg.micr0bu.data.mqtt
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.random.Random
|
||||
|
||||
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
|
||||
|
||||
@@ -26,7 +30,15 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
) {
|
||||
private object Keys {
|
||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||
// The current transmit pseudonym. Three keys, but only ever read or written together;
|
||||
// see loadPseudonym.
|
||||
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
||||
val OWN_MAC = stringPreferencesKey("own_mac")
|
||||
val OWN_PSEUDONYM_CREATED_MS = longPreferencesKey("own_pseudonym_created_ms")
|
||||
// ESP32-C5 only.
|
||||
val ESP32_TRANSPORT = stringPreferencesKey("esp32_transport")
|
||||
val OUTGOING_MESSAGE = stringPreferencesKey("outgoing_message")
|
||||
val SIGN_OUTGOING = booleanPreferencesKey("sign_outgoing")
|
||||
}
|
||||
|
||||
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
@@ -37,31 +49,67 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
||||
}
|
||||
|
||||
/** This device's own CAM StationID, or null if one hasn't been assigned yet. */
|
||||
val ownStationIdFlow: Flow<Long?> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
prefs[Keys.OWN_STATION_ID]
|
||||
/** How the phone reaches the ESP32-C5: its native USB-C port (default) or BLE. */
|
||||
val esp32TransportFlow: Flow<Esp32Transport> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
Esp32Transport.entries.firstOrNull { it.id == prefs[Keys.ESP32_TRANSPORT] } ?: Esp32Transport.USB
|
||||
}
|
||||
|
||||
suspend fun setEsp32Transport(transport: Esp32Transport) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP32_TRANSPORT] = transport.id }
|
||||
}
|
||||
|
||||
/** What the ESP32-C5 path transmits while recording: CAM (default) or VAM. */
|
||||
val outgoingMessageFlow: Flow<OutgoingMessage> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
OutgoingMessage.entries.firstOrNull { it.id == prefs[Keys.OUTGOING_MESSAGE] } ?: OutgoingMessage.CAM
|
||||
}
|
||||
|
||||
suspend fun setOutgoingMessage(message: OutgoingMessage) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OUTGOING_MESSAGE] = message.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this device's own CAM StationID, generating and persisting a random one on first
|
||||
* call.
|
||||
*
|
||||
* Replaces the previous hardcoded 0: receivers key on StationID to track a station across
|
||||
* successive CAMs, so every MicrOBU broadcasting 0 makes two units in the same area
|
||||
* indistinguishable to any receiver — including this app's own detection engine, which
|
||||
* dedupes remote stations by ID. Random rather than derived from a hardware identifier both
|
||||
* because ETSI expects station IDs to be pseudonymous and because Android hardware IDs aren't
|
||||
* readable without privileged permissions on modern versions.
|
||||
*
|
||||
* Range is 1..2^32-2: StationID is INTEGER(0..4294967295), and 0 is avoided so leftover
|
||||
* placeholder traffic stays distinguishable from a real assignment.
|
||||
* Whether outgoing messages are signed (TS 103 097, demo PKI). Default on. Off sends them
|
||||
* unsigned exactly as the previous firmware did, which verifying receivers may prefer to a
|
||||
* signature they cannot chain to the EU trust list.
|
||||
*/
|
||||
suspend fun getOrCreateOwnStationId(): Long {
|
||||
val prefs = context.obuHardwareDataStore.edit { p ->
|
||||
if (p[Keys.OWN_STATION_ID] == null) {
|
||||
p[Keys.OWN_STATION_ID] = Random.nextLong(1L, 0xFFFF_FFFEL)
|
||||
}
|
||||
}
|
||||
return prefs[Keys.OWN_STATION_ID]!!
|
||||
val signOutgoingFlow: Flow<Boolean> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
prefs[Keys.SIGN_OUTGOING] ?: true
|
||||
}
|
||||
|
||||
suspend fun setSignOutgoing(sign: Boolean) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.SIGN_OUTGOING] = sign }
|
||||
}
|
||||
|
||||
/**
|
||||
* The transmit pseudonym last saved by [savePseudonym], or null if there is none.
|
||||
*
|
||||
* All three parts must be present. An install from before pseudonym rotation has a station ID
|
||||
* but no MAC or creation time, and loads as null so that a complete new pseudonym is
|
||||
* generated. Keeping the old ID alongside a fresh MAC would be exactly the partial rotation
|
||||
* [Pseudonym] exists to rule out.
|
||||
*
|
||||
* Only [com.hawhamburg.micr0bu.data.cam.PseudonymManager] should call this: it is the one
|
||||
* owner of the phone's transmit identity.
|
||||
*/
|
||||
suspend fun loadPseudonym(): Pseudonym? {
|
||||
val prefs = context.obuHardwareDataStore.data.first()
|
||||
val stationId = prefs[Keys.OWN_STATION_ID] ?: return null
|
||||
val mac = prefs[Keys.OWN_MAC]?.let(::macFromHex) ?: return null
|
||||
val createdAtMs = prefs[Keys.OWN_PSEUDONYM_CREATED_MS] ?: return null
|
||||
return Pseudonym(stationId, mac, createdAtMs)
|
||||
}
|
||||
|
||||
/** Persists [pseudonym] in a single edit, so a crash can never leave half an identity stored. */
|
||||
suspend fun savePseudonym(pseudonym: Pseudonym) {
|
||||
context.obuHardwareDataStore.edit { p ->
|
||||
p[Keys.OWN_STATION_ID] = pseudonym.stationId
|
||||
p[Keys.OWN_MAC] = pseudonym.mac.joinToString("") { "%02x".format(it) }
|
||||
p[Keys.OWN_PSEUDONYM_CREATED_MS] = pseudonym.createdAtMs
|
||||
}
|
||||
}
|
||||
|
||||
private fun macFromHex(hex: String): ByteArray? =
|
||||
if (hex.length != 12) null
|
||||
else runCatching { ByteArray(6) { i -> hex.substring(2 * i, 2 * i + 2).toInt(16).toByte() } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.hawhamburg.micr0bu.data.mqtt
|
||||
|
||||
/**
|
||||
* The CiT One's raw received-V2X envelope, as published on the `v2x/rx` MQTT topics.
|
||||
*
|
||||
* These topics carry a `RecvV2XMessage` protobuf (CI-CiT MQTT API section 2.4), not JSON: the
|
||||
* ITS-G5 PDU sits in one bytes field, and the GeoNetworking and BTP headers the stack stripped
|
||||
* off travel alongside it. That is the CiT One's counterpart to the ESP32-C5 path's
|
||||
* [com.hawhamburg.micr0bu.data.transport.V2xRxFrame], and it exists for the same reason: the
|
||||
* app decodes the UPER itself instead of accepting somebody else's summary.
|
||||
*
|
||||
* **Why this rather than the Use Case app's JSON.** `v2x-uca/output/json` is a processed,
|
||||
* rate-limited view. It drops messages, and what it does publish has already been reduced to
|
||||
* the fields the Use Case app cared about. `v2x/rx` is everything the radio actually heard.
|
||||
*
|
||||
* **Why a hand-written reader.** Only three of this envelope's fields are used, protobuf's wire
|
||||
* format is trivial to walk, and the alternative is adding protoc and the protobuf Gradle plugin
|
||||
* to an Android build plus vendoring a third-party `.proto` into this repository. The same
|
||||
* argument the ASN.1 codecs in `domain/asn1/` are built on applies here.
|
||||
*
|
||||
* Field numbers below come from consider it's `v2x_interface.proto`, V2X RX protocol v2.4.2.
|
||||
* They are wire-format constants: changing them silently mis-parses every message, so they are
|
||||
* pinned by `RecvV2xMessageTest` against a byte fixture rather than left to inspection.
|
||||
*/
|
||||
data class RecvV2xMessage(
|
||||
/**
|
||||
* `btpHeader.type`, the stack's own idea of which PDU this is: DENM 1, CAM 2, SPATEM 4,
|
||||
* MAPEM 5. Null when the sender omitted the header. Advisory only, since every decoder
|
||||
* re-checks the messageID in the ItsPduHeader itself.
|
||||
*/
|
||||
val pduType: Int?,
|
||||
|
||||
/** `btpHeader.destinationPort`: 2001 CAM, 2002 DENM, 2003 MAPEM, 2004 SPATEM. */
|
||||
val destinationPort: Int?,
|
||||
|
||||
/**
|
||||
* `gnHeader.dest.area.distA`, metres: the radius of the GeoBroadcast destination area, so
|
||||
* how far the sender meant its message to apply. Only DENM normally carries one. This is the
|
||||
* MQTT path's equivalent of the serial prefix's
|
||||
* [com.hawhamburg.micr0bu.data.transport.V2xRxFrame.GeoArea.radiusMeters].
|
||||
*/
|
||||
val destAreaRadiusM: Int?,
|
||||
|
||||
/** The ITS-G5 PDU as UPER, ItsPduHeader included. Empty when the field was absent. */
|
||||
val payload: ByteArray,
|
||||
) {
|
||||
// Generated equals/hashCode would compare the payload array by identity, which makes two
|
||||
// decodes of the same bytes unequal and quietly breaks any test or set that holds these.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is RecvV2xMessage) return false
|
||||
return pduType == other.pduType &&
|
||||
destinationPort == other.destinationPort &&
|
||||
destAreaRadiusM == other.destAreaRadiusM &&
|
||||
payload.contentEquals(other.payload)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = pduType ?: 0
|
||||
result = 31 * result + (destinationPort ?: 0)
|
||||
result = 31 * result + (destAreaRadiusM ?: 0)
|
||||
result = 31 * result + payload.contentHashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
// RecvV2XMessage
|
||||
private const val F_BTP_HEADER = 1
|
||||
private const val F_GN_HEADER = 2
|
||||
private const val F_PAYLOAD = 3
|
||||
|
||||
// BasicTransportProtocolHeader
|
||||
private const val F_BTP_TYPE = 1
|
||||
private const val F_BTP_DEST_PORT = 2
|
||||
|
||||
// GeoNetworkingHeader
|
||||
private const val F_GN_DEST = 8
|
||||
|
||||
// GNDestination
|
||||
private const val F_DEST_AREA = 1
|
||||
|
||||
// GeoNetworkingArea
|
||||
private const val F_AREA_DIST_A = 3
|
||||
|
||||
/**
|
||||
* Parses an MQTT payload from a `v2x/rx` topic, or null if it is not a readable
|
||||
* `RecvV2XMessage` or carries no PDU.
|
||||
*
|
||||
* Unknown fields are skipped rather than treated as errors, which is what protobuf
|
||||
* requires and what keeps this working if consider it adds fields in a later revision.
|
||||
*/
|
||||
fun parse(bytes: ByteArray): RecvV2xMessage? {
|
||||
var pduType: Int? = null
|
||||
var destPort: Int? = null
|
||||
var radius: Int? = null
|
||||
var payload: ByteArray? = null
|
||||
|
||||
val reader = ProtoReader(bytes)
|
||||
while (reader.hasNext()) {
|
||||
val tag = reader.readTag() ?: return null
|
||||
when {
|
||||
tag.field == F_PAYLOAD && tag.wireType == WIRE_LENGTH_DELIMITED ->
|
||||
payload = reader.readBytes() ?: return null
|
||||
|
||||
tag.field == F_BTP_HEADER && tag.wireType == WIRE_LENGTH_DELIMITED -> {
|
||||
val sub = reader.readBytes() ?: return null
|
||||
val btp = ProtoReader(sub)
|
||||
while (btp.hasNext()) {
|
||||
val t = btp.readTag() ?: return null
|
||||
when {
|
||||
t.field == F_BTP_TYPE && t.wireType == WIRE_VARINT ->
|
||||
pduType = btp.readVarint()?.toInt() ?: return null
|
||||
t.field == F_BTP_DEST_PORT && t.wireType == WIRE_VARINT ->
|
||||
destPort = btp.readVarint()?.toInt() ?: return null
|
||||
else -> if (!btp.skip(t.wireType)) return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tag.field == F_GN_HEADER && tag.wireType == WIRE_LENGTH_DELIMITED -> {
|
||||
val sub = reader.readBytes() ?: return null
|
||||
radius = readDestAreaRadius(sub)
|
||||
}
|
||||
|
||||
else -> if (!reader.skip(tag.wireType)) return null
|
||||
}
|
||||
}
|
||||
|
||||
// A message with no payload has nothing to decode. Returning it anyway would push an
|
||||
// empty byte array into the ASN.1 decoders for them to reject one layer later.
|
||||
val pdu = payload ?: return null
|
||||
if (pdu.isEmpty()) return null
|
||||
|
||||
return RecvV2xMessage(
|
||||
pduType = pduType,
|
||||
destinationPort = destPort,
|
||||
destAreaRadiusM = radius,
|
||||
payload = pdu,
|
||||
)
|
||||
}
|
||||
|
||||
/** GeoNetworkingHeader.dest.area.distA, walking two levels down. Null at any break. */
|
||||
private fun readDestAreaRadius(gnHeader: ByteArray): Int? {
|
||||
val dest = nestedField(gnHeader, F_GN_DEST) ?: return null
|
||||
val area = nestedField(dest, F_DEST_AREA) ?: return null
|
||||
|
||||
val reader = ProtoReader(area)
|
||||
while (reader.hasNext()) {
|
||||
val tag = reader.readTag() ?: return null
|
||||
if (tag.field == F_AREA_DIST_A && tag.wireType == WIRE_VARINT) {
|
||||
return reader.readVarint()?.toInt()
|
||||
}
|
||||
if (!reader.skip(tag.wireType)) return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The bytes of the first length-delimited field numbered [field], or null. */
|
||||
private fun nestedField(bytes: ByteArray, field: Int): ByteArray? {
|
||||
val reader = ProtoReader(bytes)
|
||||
while (reader.hasNext()) {
|
||||
val tag = reader.readTag() ?: return null
|
||||
if (tag.field == field && tag.wireType == WIRE_LENGTH_DELIMITED) {
|
||||
return reader.readBytes()
|
||||
}
|
||||
if (!reader.skip(tag.wireType)) return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val WIRE_VARINT = 0
|
||||
private const val WIRE_FIXED64 = 1
|
||||
private const val WIRE_LENGTH_DELIMITED = 2
|
||||
private const val WIRE_FIXED32 = 5
|
||||
|
||||
private data class ProtoTag(val field: Int, val wireType: Int)
|
||||
|
||||
/**
|
||||
* A minimal protobuf wire-format reader: enough to walk a message, read varints and
|
||||
* length-delimited fields, and skip everything else.
|
||||
*
|
||||
* Every read returns null instead of throwing on a malformed or truncated buffer. These bytes
|
||||
* arrive off a network topic and a decoder that throws on bad input is a decoder that takes the
|
||||
* MQTT callback thread down with it.
|
||||
*/
|
||||
private class ProtoReader(private val buf: ByteArray) {
|
||||
private var pos = 0
|
||||
|
||||
fun hasNext(): Boolean = pos < buf.size
|
||||
|
||||
fun readTag(): ProtoTag? {
|
||||
val raw = readVarint() ?: return null
|
||||
val field = (raw ushr 3).toInt()
|
||||
val wireType = (raw and 0x7L).toInt()
|
||||
if (field <= 0) return null
|
||||
return ProtoTag(field, wireType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a base-128 varint. Capped at ten bytes: that is the longest a 64-bit value can be,
|
||||
* and without the cap a run of 0x80 bytes would walk the reader off the end of the buffer.
|
||||
*/
|
||||
fun readVarint(): Long? {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
while (shift < 64) {
|
||||
if (pos >= buf.size) return null
|
||||
val b = buf[pos++].toInt()
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
if (b and 0x80 == 0) return result
|
||||
shift += 7
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun readBytes(): ByteArray? {
|
||||
val len = readVarint()?.toInt() ?: return null
|
||||
if (len < 0 || pos + len > buf.size) return null
|
||||
val out = buf.copyOfRange(pos, pos + len)
|
||||
pos += len
|
||||
return out
|
||||
}
|
||||
|
||||
/** Advances past a field of [wireType]. False if the type is unknown or the buffer is short. */
|
||||
fun skip(wireType: Int): Boolean = when (wireType) {
|
||||
WIRE_VARINT -> readVarint() != null
|
||||
WIRE_FIXED64 -> advance(8)
|
||||
WIRE_LENGTH_DELIMITED -> readBytes() != null
|
||||
WIRE_FIXED32 -> advance(4)
|
||||
else -> false // groups (3, 4) are not used by this schema
|
||||
}
|
||||
|
||||
private fun advance(n: Int): Boolean {
|
||||
if (pos + n > buf.size) return false
|
||||
pos += n
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val TAG = "BleLinkTransport"
|
||||
|
||||
/**
|
||||
* BLE GATT central for the micrOBU's station link: the Android counterpart of the firmware's
|
||||
* `obu-firmware/main/simple_ble.cpp` (from the colleague's microbu-esp32c5) and of their Python
|
||||
* `microbu_link/ble_transport.py`, which this follows step for step.
|
||||
*
|
||||
* ## The GATT layout (what the firmware actually implements)
|
||||
* The station-link README describes a Nordic-UART-shaped service with fragmentation. The firmware
|
||||
* does something else, and the firmware is what counts here: a custom service
|
||||
* `0000C175-BA5E-4C17-8000-00805F9B34FB` with one characteristic per primitive, and each GATT value
|
||||
* is one complete link message, never fragmented. Requests are written with response to the
|
||||
* characteristic of their opcode ([writeTarget]); replies, STATUS and V2X_RX arrive as
|
||||
* notifications. A message may be up to 512 octets, so the ATT MTU must be raised to 517 first:
|
||||
* the firmware refuses to notify a message that does not fit rather than send it cut short.
|
||||
*
|
||||
* ## Pairing
|
||||
* Every characteristic needs an encrypted, authenticated link. The firmware uses LE Secure
|
||||
* Connections with a fixed passkey, [PASSKEY] (DisplayOnly). Android shows its own pairing dialog
|
||||
* the first time; the user types the passkey there, and the bond is kept on both sides. The
|
||||
* passkey is public, so this gives encryption but no protection against an active attacker
|
||||
* during that first pairing; acceptable for the demo PKI this carries.
|
||||
*
|
||||
* ## RF
|
||||
* BLE shares the C5's single RF front end with 5.9 GHz ITS-G5. The firmware stops advertising
|
||||
* while the USB link is in use; whether an active BLE connection disturbs ITS-G5 has not been
|
||||
* measured yet (TODO.md, "Waiting on hardware").
|
||||
*
|
||||
* Like [UsbSerialTransport], an app-scoped singleton: only an explicit disconnect or the process
|
||||
* dying closes it, never a screen or ViewModel going away.
|
||||
*/
|
||||
@Singleton
|
||||
class BleLinkTransport @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
companion object {
|
||||
const val PASSKEY = "123456"
|
||||
const val NAME_PREFIX = "micrOBU"
|
||||
private val SERVICE: UUID = UUID.fromString("0000c175-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val BTP_REQUEST: UUID = UUID.fromString("0000c176-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val BTP_INDICATION: UUID = UUID.fromString("0000c177-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val POTI: UUID = UUID.fromString("0000c178-ba5e-4c17-8000-00805f9b34fb")
|
||||
/** Read-encrypted, returns nothing useful: only used to find out whether the link is secure. */
|
||||
private val STATUS_CHAR: UUID = UUID.fromString("0000c179-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val ID_EVENT: UUID = UUID.fromString("0000c17a-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val CONFIG: UUID = UUID.fromString("0000c17b-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val RESULT: UUID = UUID.fromString("0000c17c-ba5e-4c17-8000-00805f9b34fb")
|
||||
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private const val REQUESTED_MTU = 517
|
||||
private const val SCAN_TIMEOUT_MS = 15_000L
|
||||
/** Long enough for the user to find and type the passkey in the system dialog. */
|
||||
private const val BOND_TIMEOUT_MS = 60_000L
|
||||
private const val GATT_OP_TIMEOUT_MS = 5_000L
|
||||
/** After a working link dropped: try again soon. */
|
||||
private const val RECONNECT_DELAY_MS = 1_000L
|
||||
/** After failed attempts: 2, 4, 8, 16, then every 30 s, so a broken pairing does not spin. */
|
||||
private const val RETRY_BASE_MS = 2_000L
|
||||
private const val RETRY_MAX_MS = 30_000L
|
||||
/** ATT status codes Android reports when the link lacks the encryption a characteristic needs. */
|
||||
private val AUTH_FAILURES = setOf(5, 8, 15, 137)
|
||||
|
||||
/** Which characteristic a phone -> micrOBU message is written to, by opcode (as ble_transport.py). */
|
||||
fun writeTarget(opcode: Int): UUID = when (opcode) {
|
||||
LinkOpcode.BTP_DATA_REQUEST -> BTP_REQUEST
|
||||
LinkOpcode.POTI_UPDATE -> POTI
|
||||
0x08 /* SF_IDCHANGE_EVENT_RESPONSE */ -> ID_EVENT
|
||||
else -> CONFIG
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
|
||||
|
||||
private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
|
||||
val state: StateFlow<Esp32LinkState> = _state.asStateFlow()
|
||||
|
||||
/** Why the last attempt failed, or what the user has to do (e.g. type the passkey); null when fine. */
|
||||
private val _detail = MutableStateFlow<String?>(null)
|
||||
val detail: StateFlow<String?> = _detail.asStateFlow()
|
||||
|
||||
private val _incoming = MutableSharedFlow<ByteArray>(extraBufferCapacity = 256)
|
||||
/** Every link message the micrOBU notifies, one GATT value each. */
|
||||
val incoming: SharedFlow<ByteArray> = _incoming.asSharedFlow()
|
||||
|
||||
/** Name of the connected micrOBU, e.g. "micrOBU-4AF8". */
|
||||
@Volatile var deviceName: String? = null
|
||||
private set
|
||||
|
||||
@Volatile private var gatt: BluetoothGatt? = null
|
||||
/** Whether the current GATT connection is up, as the last connection-state callback said. */
|
||||
@Volatile private var linkUp = false
|
||||
@Volatile private var wanted = false
|
||||
private var sessionJob: Job? = null
|
||||
|
||||
/** One GATT operation at a time: Android drops a second one issued before the first completes. */
|
||||
private val gattMutex = Mutex()
|
||||
@Volatile private var pendingOp: CompletableDeferred<Int>? = null
|
||||
@Volatile private var connected: CompletableDeferred<Boolean>? = null
|
||||
@Volatile private var mtuDone: CompletableDeferred<Int>? = null
|
||||
@Volatile private var servicesDone: CompletableDeferred<Boolean>? = null
|
||||
|
||||
fun hasPermissions(): Boolean {
|
||||
val needed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
return needed.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||
}
|
||||
|
||||
/** Scans for (or reuses the bond with) a micrOBU, pairs if needed, and opens the link. No-op if already under way. */
|
||||
fun connect() {
|
||||
if (sessionJob?.isActive == true) return
|
||||
wanted = true
|
||||
sessionJob = scope.launch {
|
||||
var failures = 0
|
||||
while (wanted) {
|
||||
val ok = try {
|
||||
session()
|
||||
} catch (e: CancellationException) {
|
||||
throw e // disconnect(): not a failure to report
|
||||
} catch (e: Exception) {
|
||||
fail("BLE session failed: ${e.message}")
|
||||
}
|
||||
closeGatt()
|
||||
if (!wanted) break
|
||||
failures = if (ok) 0 else failures + 1
|
||||
_state.value = if (ok) Esp32LinkState.DEVICE_ATTACHED else Esp32LinkState.ERROR
|
||||
delay(if (ok) RECONNECT_DELAY_MS
|
||||
else minOf(RETRY_MAX_MS, RETRY_BASE_MS shl (failures - 1).coerceAtMost(4)))
|
||||
}
|
||||
_state.value = Esp32LinkState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
wanted = false
|
||||
sessionJob?.cancel()
|
||||
sessionJob = null
|
||||
closeGatt()
|
||||
_state.value = Esp32LinkState.DISCONNECTED
|
||||
_detail.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one complete link message to the characteristic of its opcode, with response.
|
||||
* Suspends until the micrOBU acknowledged the write; false when not connected or it failed.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun send(message: ByteArray): Boolean {
|
||||
val g = gatt ?: return false
|
||||
if (_state.value != Esp32LinkState.CONNECTED || message.isEmpty()) return false
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(writeTarget(message[0].toInt() and 0xFF))
|
||||
?: return false
|
||||
return gattOp {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeCharacteristic(characteristic, message, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
|
||||
BluetoothGatt.GATT_SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value = message
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeCharacteristic(characteristic)
|
||||
}
|
||||
} == BluetoothGatt.GATT_SUCCESS
|
||||
}
|
||||
|
||||
/** One connection lifetime. Returns true if it reached CONNECTED before ending. */
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun session(): Boolean {
|
||||
if (!hasPermissions()) {
|
||||
wanted = false
|
||||
return fail("Bluetooth permission not granted (Android Settings > Apps > MicrOBU > Permissions)")
|
||||
}
|
||||
val adapter = bluetoothManager?.adapter
|
||||
if (adapter == null || !adapter.isEnabled) return fail("Bluetooth is off")
|
||||
_state.value = Esp32LinkState.DEVICE_ATTACHED
|
||||
// A bonded micrOBU is reused without scanning: its address is stable (public address), and
|
||||
// this is what makes a reconnect after a dropout fast.
|
||||
val device = adapter.bondedDevices.firstOrNull { it.name?.startsWith(NAME_PREFIX) == true }
|
||||
?: scan() ?: return fail("No micrOBU advertising nearby (is the phone on its USB port?)")
|
||||
deviceName = device.name
|
||||
_detail.value = null
|
||||
Log.i(TAG, "connecting to ${device.name} ${device.address} (bond state ${device.bondState})")
|
||||
|
||||
connected = CompletableDeferred()
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
|
||||
if (withTimeoutOrNull(GATT_OP_TIMEOUT_MS * 2) { connected!!.await() } != true) {
|
||||
return fail("Could not connect to ${device.name}")
|
||||
}
|
||||
|
||||
val g = gatt ?: return false
|
||||
// Services first: discovery needs no encryption, and the encryption probe below needs the
|
||||
// STATUS characteristic.
|
||||
servicesDone = CompletableDeferred()
|
||||
g.discoverServices()
|
||||
if (withTimeoutOrNull(GATT_OP_TIMEOUT_MS) { servicesDone!!.await() } != true) {
|
||||
return fail("Service discovery on ${device.name} timed out")
|
||||
}
|
||||
if (g.getService(SERVICE) == null) {
|
||||
return fail("${device.name} does not offer the station-link service (old firmware?)")
|
||||
}
|
||||
|
||||
// Encryption before anything else. Every characteristic needs an encrypted, authenticated
|
||||
// link; the board asks for security as soon as a phone connects. A phone it has a bond with
|
||||
// encrypts with the stored key. Otherwise Android pairs, with its passkey dialog, which takes
|
||||
// as long as the user takes. A GATT operation with a short timeout during that cuts the
|
||||
// pairing off, the link drops, and the next attempt starts pairing again: a loop.
|
||||
val wasBonded = device.bondState == BluetoothDevice.BOND_BONDED
|
||||
if (!awaitEncryption(g, device)) {
|
||||
if (!linkUp) return fail("${device.name} dropped the link while pairing")
|
||||
return fail(
|
||||
if (wasBonded) "${device.name} refused this phone's stored pairing. In Android's Bluetooth " +
|
||||
"settings, forget ${device.name}, then Connect again (passkey $PASSKEY)."
|
||||
else "Pairing with ${device.name} failed or timed out (passkey $PASSKEY)"
|
||||
)
|
||||
}
|
||||
_state.value = Esp32LinkState.DEVICE_ATTACHED
|
||||
_detail.value = null
|
||||
|
||||
mtuDone = CompletableDeferred()
|
||||
g.requestMtu(REQUESTED_MTU)
|
||||
val mtu = withTimeoutOrNull(GATT_OP_TIMEOUT_MS) { mtuDone!!.await() } ?: 23
|
||||
Log.i(TAG, "ATT MTU $mtu")
|
||||
if (mtu < LINK_MAX_MESSAGE + 3) {
|
||||
// The board drops a notification that does not fit rather than truncate it (simple_ble.cpp).
|
||||
Log.w(TAG, "MTU $mtu is below ${LINK_MAX_MESSAGE + 3}: large V2X_RX messages will not arrive")
|
||||
}
|
||||
|
||||
for (uuid in listOf(RESULT, BTP_INDICATION, ID_EVENT)) {
|
||||
if (!enableNotifications(g, uuid)) return fail("Could not subscribe to ${device.name} notifications")
|
||||
}
|
||||
_state.value = Esp32LinkState.CONNECTED
|
||||
Log.i(TAG, "BLE station link ready: ${device.name}")
|
||||
// Wait until the link drops (callback completes `connected` anew with false).
|
||||
val dropped = CompletableDeferred<Boolean>()
|
||||
connected = dropped
|
||||
dropped.await()
|
||||
Log.w(TAG, "BLE link to ${device.name} lost")
|
||||
return true
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun scan(): BluetoothDevice? {
|
||||
val scanner = bluetoothManager?.adapter?.bluetoothLeScanner ?: return null
|
||||
val found = CompletableDeferred<BluetoothDevice>()
|
||||
val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val name = result.scanRecord?.deviceName ?: result.device.name
|
||||
val offersService = result.scanRecord?.serviceUuids?.any { it.uuid == SERVICE } == true
|
||||
if (offersService || name?.startsWith(NAME_PREFIX) == true) found.complete(result.device)
|
||||
}
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.w(TAG, "BLE scan failed: $errorCode")
|
||||
}
|
||||
}
|
||||
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
|
||||
scanner.startScan(null, settings, scanCallback)
|
||||
return try {
|
||||
withTimeoutOrNull(SCAN_TIMEOUT_MS) { found.await() }
|
||||
} finally {
|
||||
runCatching { scanner.stopScan(scanCallback) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns once the link is encrypted, pairing first if needed; false if that fails or the user
|
||||
* does not finish within [BOND_TIMEOUT_MS].
|
||||
*
|
||||
* The probe is a read of the STATUS characteristic, which needs an encrypted and authenticated
|
||||
* link, as the colleague's Python transport does. Android answers a read the link is not
|
||||
* secure enough for by encrypting, or by pairing and showing the passkey dialog, and then
|
||||
* retries the read itself. While it pairs, the card says which passkey to type.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun awaitEncryption(g: BluetoothGatt, device: BluetoothDevice): Boolean {
|
||||
val status = g.getService(SERVICE)?.getCharacteristic(STATUS_CHAR) ?: return false
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val changed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
|
||||
}
|
||||
if (changed?.address != device.address) return
|
||||
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR)
|
||||
Log.i(TAG, "bond state of ${device.name}: $state")
|
||||
if (state == BluetoothDevice.BOND_BONDING) {
|
||||
_state.value = Esp32LinkState.PERMISSION_REQUESTED
|
||||
_detail.value = "Pair with ${device.name}: enter passkey $PASSKEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
register(receiver)
|
||||
try {
|
||||
if (device.bondState == BluetoothDevice.BOND_BONDING) {
|
||||
_state.value = Esp32LinkState.PERMISSION_REQUESTED
|
||||
_detail.value = "Pair with ${device.name}: enter passkey $PASSKEY"
|
||||
}
|
||||
// Up to two reads: the first can come back with an authentication error at the moment
|
||||
// pairing completes, before Android's own retry.
|
||||
repeat(2) { attempt ->
|
||||
val result = gattOp(BOND_TIMEOUT_MS) { g.readCharacteristic(status) }
|
||||
Log.i(TAG, "encryption probe ${attempt + 1}: status $result, bond state ${device.bondState}")
|
||||
if (result == BluetoothGatt.GATT_SUCCESS) return true
|
||||
if (result !in AUTH_FAILURES) return false
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
runCatching { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun register(receiver: BroadcastReceiver) {
|
||||
val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
/** Logs [reason], shows it on the connection card, and ends the attempt. */
|
||||
private fun fail(reason: String): Boolean {
|
||||
Log.w(TAG, reason)
|
||||
_detail.value = reason
|
||||
return false
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun enableNotifications(g: BluetoothGatt, uuid: UUID): Boolean {
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid) ?: return false
|
||||
if (!g.setCharacteristicNotification(characteristic, true)) return false
|
||||
val cccd = characteristic.getDescriptor(CCCD) ?: return false
|
||||
val value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
return gattOp {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
g.writeDescriptor(cccd, value) == BluetoothGatt.GATT_SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION") cccd.value = value
|
||||
@Suppress("DEPRECATION") g.writeDescriptor(cccd)
|
||||
}
|
||||
} == BluetoothGatt.GATT_SUCCESS
|
||||
}
|
||||
|
||||
/** Starts one GATT operation and waits for its callback's status; -1 if it could not start or timed out. */
|
||||
private suspend fun gattOp(timeoutMs: Long = GATT_OP_TIMEOUT_MS, start: () -> Boolean): Int = gattMutex.withLock {
|
||||
val op = CompletableDeferred<Int>()
|
||||
pendingOp = op
|
||||
if (!start()) {
|
||||
pendingOp = null
|
||||
return@withLock -1
|
||||
}
|
||||
withTimeoutOrNull(timeoutMs) { op.await() } ?: -1
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun closeGatt() {
|
||||
gatt?.let { runCatching { it.disconnect(); it.close() } }
|
||||
gatt = null
|
||||
pendingOp?.complete(-1)
|
||||
connected?.complete(false)
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
Log.i(TAG, "connection state $newState (status $status)")
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
linkUp = true
|
||||
connected?.complete(true)
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
linkUp = false
|
||||
connected?.complete(false)
|
||||
pendingOp?.complete(-1)
|
||||
if (_state.value == Esp32LinkState.CONNECTED) _state.value = Esp32LinkState.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
mtuDone?.complete(mtu)
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
servicesDone?.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
pendingOp?.complete(status)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
pendingOp?.complete(status)
|
||||
}
|
||||
|
||||
// API 33+ calls this overload; older versions the deprecated one below.
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray, status: Int) {
|
||||
pendingOp?.complete(status)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in API 33")
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
pendingOp?.complete(status)
|
||||
}
|
||||
|
||||
// API 33+ delivers the value here and no longer calls the deprecated overload below.
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
_incoming.tryEmit(value.copyOf())
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in API 33")
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value?.let { _incoming.tryEmit(it.copyOf()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Connection lifecycle of the ESP32-C5 link, over either transport. */
|
||||
enum class Esp32LinkState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
|
||||
|
||||
/** Which physical link the phone uses to reach the ESP32-C5 (Settings). */
|
||||
enum class Esp32Transport(val id: String) { USB("usb"), BLE("ble") }
|
||||
|
||||
/** What the phone transmits while a trip records (Settings). */
|
||||
enum class OutgoingMessage(val id: String) { CAM("cam"), VAM("vam") }
|
||||
|
||||
/**
|
||||
* What the board on the other end speaks. The phone cannot ask, so it listens: the previous
|
||||
* obu-firmware sends a [SerialFrameType.STATUS] heartbeat, the current one a station-link STATUS.
|
||||
*/
|
||||
enum class Esp32Protocol { UNKNOWN, LEGACY_SERIAL, STATION_LINK }
|
||||
|
||||
/** One ITS message for the air, with what the micrOBU needs to know about the sender. */
|
||||
class OutgoingIts(
|
||||
val kind: OutgoingMessage,
|
||||
val uper: ByteArray,
|
||||
/** Pseudonym MAC, station type and position; its [GnPositionVector.tstMs] is the fix time. */
|
||||
val positionVector: GnPositionVector,
|
||||
/** Android horizontal accuracy, metres (68 %); null when unknown. */
|
||||
val accuracyM: Float?,
|
||||
val signed: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* The one entry point the app uses to talk to the ESP32-C5: picks USB ([UsbSerialTransport]) or
|
||||
* BLE ([BleLinkTransport]) from the setting, works out which firmware protocol is on the other end,
|
||||
* and runs the station-link session the current firmware needs.
|
||||
*
|
||||
* ## Station-link session (obu-firmware since 2026-09-23)
|
||||
* The firmware keeps no state the phone depends on, except what it stores itself (credentials in
|
||||
* NVS), so the phone sets it up each time it sees it unconfigured:
|
||||
* 1. STATION_CONFIGURE, with the current pseudonym MAC as the GN address and 802.11 source. This
|
||||
* also starts the radio, which until then neither transmits nor receives.
|
||||
* 2. If the answer reports no authorization ticket, CREDENTIALS_PROVISION of the demo bundle in
|
||||
* `assets/demo-chain.vcr` (a disposable chain, not EU-registered: receivers that verify against
|
||||
* the EU trust list will drop what it signs). The firmware keeps it in NVS from then on.
|
||||
* 3. Per message: POTI_UPDATE (the fix, which also sets the micrOBU's ITS clock for the signature
|
||||
* time), then BTP_DATA_REQUEST, secured or unsecured per the "Sign outgoing messages" setting.
|
||||
* A pseudonym change reconfigures with the new MAC before the next message goes out. A STATUS
|
||||
* saying "not configured" (the board reset) starts again at 1.
|
||||
*
|
||||
* ## Legacy firmware
|
||||
* A board still on the previous obu-firmware (0xAA55 frames 0x01-0x05, no signing, USB only)
|
||||
* keeps working for CAM exactly as before. VAM needs the current firmware.
|
||||
*
|
||||
* Everything received is surfaced the old way, as [DecodedFrame]s of [SerialFrameType.V2X_RX], so
|
||||
* the receive side of the app did not change.
|
||||
*/
|
||||
@Singleton
|
||||
class Esp32Link @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val usb: UsbSerialTransport,
|
||||
private val ble: BleLinkTransport,
|
||||
private val prefs: ObuHardwarePreferences,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "Esp32Link"
|
||||
private const val REPLY_TIMEOUT_MS = 3_000L
|
||||
/** Applying a bundle verifies the chain and rebuilds the stack on the C5: seconds, not ms. */
|
||||
private const val PROVISION_TIMEOUT_MS = 15_000L
|
||||
private const val SEGMENT_SIZE = 240
|
||||
private const val DEMO_BUNDLE_ASSET = "demo-chain.vcr"
|
||||
/**
|
||||
* A PoTi this far behind the last one sent is a real correction of the phone's clock (e.g.
|
||||
* GNSS time taking over from a wrong system clock), not a repeated fix; it goes through
|
||||
* and the micrOBU restarts its stack at the new time once.
|
||||
*/
|
||||
private const val CLOCK_STEP_BACK_MS = 60_000L
|
||||
/** How long a refusal stays on the connection card. */
|
||||
private const val DETAIL_HOLD_MS = 10_000L
|
||||
|
||||
const val BTP_PORT_CAM = 2001
|
||||
const val BTP_PORT_VAM = 2018
|
||||
const val ITS_AID_CAM = 36L
|
||||
const val ITS_AID_VAM = 638L
|
||||
/** CAM SSP version 1, no special-vehicle permissions: what the demo ticket grants for ITS-AID 36. */
|
||||
val SSP_CAM = byteArrayOf(0x01, 0x00, 0x00)
|
||||
/** VRU SSP as the demo ticket grants for ITS-AID 638 (same as the colleague's VBS). */
|
||||
val SSP_VAM = byteArrayOf(0x01)
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val transport: StateFlow<Esp32Transport> =
|
||||
prefs.esp32TransportFlow.stateIn(scope, SharingStarted.Eagerly, Esp32Transport.USB)
|
||||
|
||||
val state: StateFlow<Esp32LinkState> = combine(transport, usb.state, ble.state) { t, u, b ->
|
||||
if (t == Esp32Transport.USB) u else b
|
||||
}.stateIn(scope, SharingStarted.Eagerly, Esp32LinkState.DISCONNECTED)
|
||||
|
||||
private val _protocol = MutableStateFlow(Esp32Protocol.UNKNOWN)
|
||||
val protocol: StateFlow<Esp32Protocol> = _protocol.asStateFlow()
|
||||
|
||||
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
|
||||
/** Received ITS messages ([SerialFrameType.V2X_RX]) and, from legacy firmware, its heartbeats. */
|
||||
val incomingFrames: SharedFlow<DecodedFrame> = _incomingFrames.asSharedFlow()
|
||||
|
||||
private val _linkStatus = MutableStateFlow<EspLinkStatus?>(null)
|
||||
/** Heartbeat counters in the old shape, from either firmware; null before the first one. */
|
||||
val linkStatus: StateFlow<EspLinkStatus?> = _linkStatus.asStateFlow()
|
||||
|
||||
private val _stationStatus = MutableStateFlow<StationStatus?>(null)
|
||||
/** Full station-link STATUS (signing counters, tickets); null with legacy firmware. */
|
||||
val stationStatus: StateFlow<StationStatus?> = _stationStatus.asStateFlow()
|
||||
|
||||
private val _detail = MutableStateFlow<String?>(null)
|
||||
/** One line for the UI about the session: pairing, provisioning, or why it is stuck. */
|
||||
val detail: StateFlow<String?> = _detail.asStateFlow()
|
||||
|
||||
private val _consecutiveWriteFailures = MutableStateFlow(0)
|
||||
val consecutiveWriteFailures: StateFlow<Int> = _consecutiveWriteFailures.asStateFlow()
|
||||
|
||||
private val _refusedRequests = MutableStateFlow(0)
|
||||
/** BTP_DATA_REQUESTs the micrOBU answered with anything but accepted (e.g. no ticket). */
|
||||
val refusedRequests: StateFlow<Int> = _refusedRequests.asStateFlow()
|
||||
|
||||
private val sequence = AtomicInteger(0)
|
||||
private val pending = ConcurrentHashMap<Int, CompletableDeferred<LinkResult>>()
|
||||
private val sessionMutex = Mutex()
|
||||
/** The STATION_CONFIGURE the micrOBU is known to run, or null when it has to be sent again. */
|
||||
@Volatile private var configured: StationConfigure? = null
|
||||
@Volatile private var lastRefusalLogMs = 0L
|
||||
/**
|
||||
* Timestamp of the last POTI_UPDATE sent in this session, or null when the micrOBU's ITS clock
|
||||
* has to be set again (new session, board reset).
|
||||
*/
|
||||
@Volatile private var lastPotiMs: Long? = null
|
||||
/** [SystemClock.elapsedRealtime] when [lastPotiMs] was sent: with it, where the micrOBU's clock stands now. */
|
||||
@Volatile private var lastPotiElapsedMs = 0L
|
||||
/** The fix time (before any clamping) of the last PoTi sent, to send each fix only once. */
|
||||
@Volatile private var lastPotiFixMs: Long? = null
|
||||
|
||||
init {
|
||||
scope.launch { usb.incomingFrames.collect { onUsbFrame(it) } }
|
||||
scope.launch { ble.incoming.collect { onLinkMessage(it) } }
|
||||
scope.launch { usb.linkStatus.collect { if (it != null) _linkStatus.value = it } }
|
||||
// A new connection, on either transport, starts a new session.
|
||||
scope.launch {
|
||||
state.collect { s ->
|
||||
if (s != Esp32LinkState.CONNECTED) {
|
||||
configured = null; lastPotiMs = null; lastPotiFixMs = null
|
||||
_protocol.value = Esp32Protocol.UNKNOWN
|
||||
_stationStatus.value = null
|
||||
_linkStatus.value = null
|
||||
pending.values.forEach { it.cancel() }
|
||||
pending.clear()
|
||||
} else if (transport.value == Esp32Transport.BLE) {
|
||||
_protocol.value = Esp32Protocol.STATION_LINK // BLE exists only on the current firmware
|
||||
scope.launch { ensureConfigured(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch { ble.detail.collect { if (transport.value == Esp32Transport.BLE) _detail.value = it } }
|
||||
// Switching transport in Settings closes the other one; connecting stays a user action.
|
||||
scope.launch {
|
||||
transport.collect { t ->
|
||||
if (t == Esp32Transport.USB) ble.disconnect() else usb.disconnect()
|
||||
_detail.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
if (transport.value == Esp32Transport.USB) usb.connect() else ble.connect()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
usb.disconnect()
|
||||
ble.disconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands one message to the micrOBU for transmission. False when it could not be handed over
|
||||
* (no link, legacy firmware asked for a VAM, session setup failed); the next message retries.
|
||||
* Acceptance by the micrOBU is not awaited: a refusal shows up in [refusedRequests].
|
||||
*/
|
||||
suspend fun send(its: OutgoingIts): Boolean = withContext(Dispatchers.IO) {
|
||||
val ok = when (_protocol.value) {
|
||||
Esp32Protocol.LEGACY_SERIAL -> {
|
||||
if (its.kind == OutgoingMessage.CAM) {
|
||||
usb.sendCamTx(its.uper, its.positionVector)
|
||||
} else {
|
||||
noteRefusal("VAM needs the current obu-firmware; this board runs the previous one")
|
||||
false
|
||||
}
|
||||
}
|
||||
Esp32Protocol.STATION_LINK -> sendStationLink(its)
|
||||
Esp32Protocol.UNKNOWN -> false // no heartbeat yet: nothing to address
|
||||
}
|
||||
if (ok) _consecutiveWriteFailures.value = 0 else _consecutiveWriteFailures.value++
|
||||
ok
|
||||
}
|
||||
|
||||
private suspend fun sendStationLink(its: OutgoingIts): Boolean {
|
||||
val pv = its.positionVector
|
||||
if (!ensureConfigured(StationConfigure(stationType = pv.stationType, mid = pv.mac))) return false
|
||||
// The micrOBU's ITS clock must never be sent backwards: past 1 s it answers
|
||||
// time_regression and rebuilds its whole stack. Two things tried to. The transmit loops
|
||||
// re-send the latest GNSS fix every tick while fused location pauses, so an old fix time
|
||||
// arrived again and again while the micrOBU's clock ran on. And the GNSS-corrected fix
|
||||
// times themselves wobble by seconds indoors (measured 2026-09-23: -2.1 s, +4.9 s between
|
||||
// consecutive CAMs). So a fix goes over once, and its timestamp is never below where the
|
||||
// micrOBU's clock stands now, except for a real correction of the phone clock.
|
||||
val poti = potiFor(its)
|
||||
if (poti.timestampMs != lastPotiFixMs) {
|
||||
val last = lastPotiMs
|
||||
val microbuNow = last?.let { it + (SystemClock.elapsedRealtime() - lastPotiElapsedMs) }
|
||||
val timestamp = when {
|
||||
microbuNow == null -> poti.timestampMs
|
||||
poti.timestampMs < microbuNow - CLOCK_STEP_BACK_MS -> poti.timestampMs
|
||||
else -> maxOf(poti.timestampMs, microbuNow)
|
||||
}
|
||||
if (!write(LinkOpcode.POTI_UPDATE, poti.copy(timestampMs = timestamp).encode())) return false
|
||||
lastPotiFixMs = poti.timestampMs
|
||||
lastPotiMs = timestamp
|
||||
lastPotiElapsedMs = SystemClock.elapsedRealtime()
|
||||
}
|
||||
val request = BtpDataRequest(
|
||||
destinationPort = if (its.kind == OutgoingMessage.CAM) BTP_PORT_CAM else BTP_PORT_VAM,
|
||||
itsAid = if (its.kind == OutgoingMessage.CAM) ITS_AID_CAM else ITS_AID_VAM,
|
||||
securityProfile = if (its.signed) LinkSecurityProfile.SECURED else LinkSecurityProfile.UNSECURED,
|
||||
permissions = if (its.kind == OutgoingMessage.CAM) SSP_CAM else SSP_VAM,
|
||||
flSdu = its.uper,
|
||||
)
|
||||
return write(LinkOpcode.BTP_DATA_REQUEST, request.encode())
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the micrOBU runs [wanted] (or, when null, any configuration: used right after a
|
||||
* BLE connect or a board reset, to start its receiver before the first message goes out).
|
||||
*/
|
||||
private suspend fun ensureConfigured(wanted: StationConfigure?): Boolean = sessionMutex.withLock {
|
||||
val current = configured
|
||||
if (current != null && (wanted == null || current == wanted)) return@withLock true
|
||||
val config = wanted ?: StationConfigure(stationType = StationType.CYCLIST, mid = pseudonymManager.current().mac)
|
||||
_detail.value = "Configuring the micrOBU"
|
||||
val result = request(LinkOpcode.STATION_CONFIGURE, config.encode(), REPLY_TIMEOUT_MS)
|
||||
if (result == null || !result.accepted) {
|
||||
_detail.value = "micrOBU did not accept the configuration (${result?.let { LinkResultCode.name(it.code) } ?: "no reply"})"
|
||||
return@withLock false
|
||||
}
|
||||
val info = StationInfo.decode(result.detail)
|
||||
Log.i(TAG, "station configured: $info")
|
||||
if (info == null || !info.credentialsLoaded || info.tickets == 0) {
|
||||
if (!provisionDemoCredentials()) return@withLock false
|
||||
}
|
||||
configured = config
|
||||
_detail.value = null
|
||||
true
|
||||
}
|
||||
|
||||
private suspend fun provisionDemoCredentials(): Boolean {
|
||||
_detail.value = "Provisioning the demo credentials"
|
||||
val bundle = runCatching { context.assets.open(DEMO_BUNDLE_ASSET).use { it.readBytes() } }.getOrElse {
|
||||
_detail.value = "Demo credential bundle missing from the app"
|
||||
return false
|
||||
}
|
||||
var offset = 0
|
||||
while (offset < bundle.size) {
|
||||
val segment = bundle.copyOfRange(offset, minOf(bundle.size, offset + SEGMENT_SIZE))
|
||||
val last = offset + segment.size == bundle.size
|
||||
val result = request(LinkOpcode.CREDENTIALS_PROVISION, credentialsSegment(bundle.size, offset, segment),
|
||||
if (last) PROVISION_TIMEOUT_MS else REPLY_TIMEOUT_MS)
|
||||
if (result == null || !result.accepted) {
|
||||
_detail.value = "micrOBU refused the demo credentials (${result?.let { LinkResultCode.name(it.code) } ?: "no reply"})"
|
||||
return false
|
||||
}
|
||||
if (last) {
|
||||
val d = result.detail
|
||||
Log.i(TAG, "demo credentials provisioned: " +
|
||||
if (d.size == 3) "${d[0]} root(s), ${d[1]} authorit(ies), ${d[2]} ticket(s)" else "no report")
|
||||
}
|
||||
offset += segment.size
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun potiFor(its: OutgoingIts): PotiUpdate {
|
||||
val pv = its.positionVector
|
||||
// Semi-axes of the 95 % ellipse from Android's 68 % radius (circular error), as GnPositionVector's PAI bound.
|
||||
val semiCm = its.accuracyM?.takeIf { it > 0f && it.isFinite() }
|
||||
?.let { (it * 1.62f * 100).roundToInt().coerceAtMost(65_535) } ?: 0
|
||||
return PotiUpdate(
|
||||
timestampMs = fullTimestampIts(pv.tstMs),
|
||||
latTenMicroDeg = pv.latTenMicroDeg,
|
||||
lonTenMicroDeg = pv.lonTenMicroDeg,
|
||||
semiMajorCm = semiCm,
|
||||
semiMinorCm = semiCm,
|
||||
speedCms = pv.speedCms.coerceAtLeast(0),
|
||||
headingDeciDeg = pv.headingDeciDeg,
|
||||
pai = pv.pai,
|
||||
)
|
||||
}
|
||||
|
||||
/** [GnPositionVector.tstMs] is already the full TimestampIts; guard against a reduced one anyway. */
|
||||
private fun fullTimestampIts(tstMs: Long): Long {
|
||||
if (tstMs > 0xFFFF_FFFFL) return tstMs
|
||||
val now = ItsTime.timestampIts(System.currentTimeMillis())
|
||||
return now - ((now - tstMs) and 0xFFFF_FFFFL)
|
||||
}
|
||||
|
||||
private suspend fun request(opcode: Int, body: ByteArray, timeoutMs: Long): LinkResult? {
|
||||
val seq = nextSequence()
|
||||
val reply = CompletableDeferred<LinkResult>()
|
||||
pending[seq] = reply
|
||||
return try {
|
||||
if (!writeMessage(LinkMessage(opcode, seq, body).encode())) null
|
||||
else withTimeoutOrNull(timeoutMs) { reply.await() }
|
||||
} finally {
|
||||
pending.remove(seq)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun write(opcode: Int, body: ByteArray): Boolean =
|
||||
writeMessage(LinkMessage(opcode, nextSequence(), body).encode())
|
||||
|
||||
private suspend fun writeMessage(message: ByteArray): Boolean =
|
||||
if (transport.value == Esp32Transport.USB) usb.sendFrame(SERIAL_FRAME_LINK, message)
|
||||
else ble.send(message)
|
||||
|
||||
private fun nextSequence(): Int = sequence.incrementAndGet() and 0xFFFF
|
||||
|
||||
private fun onUsbFrame(frame: DecodedFrame) {
|
||||
when (frame.type) {
|
||||
SerialFrameType.STATUS -> _protocol.value = Esp32Protocol.LEGACY_SERIAL
|
||||
SerialFrameType.V2X_RX -> _incomingFrames.tryEmit(frame)
|
||||
SERIAL_FRAME_LINK -> onLinkMessage(frame.payload)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLinkMessage(octets: ByteArray) {
|
||||
val message = LinkMessage.decode(octets) ?: return
|
||||
when (message.opcode) {
|
||||
LinkOpcode.V2X_RX -> _incomingFrames.tryEmit(DecodedFrame(SerialFrameType.V2X_RX, message.body))
|
||||
LinkOpcode.RESULT -> {
|
||||
val result = LinkResult.decode(message.body) ?: return
|
||||
val waiting = pending.remove(message.sequence)
|
||||
if (waiting != null) {
|
||||
waiting.complete(result)
|
||||
} else if (result.code == LinkResultCode.TIME_REGRESSION) {
|
||||
// Only sent for a deliberate clock correction (see CLOCK_STEP_BACK_MS): the
|
||||
// micrOBU accepted the new time and restarted its stack. Not a refusal.
|
||||
Log.i(TAG, "micrOBU followed a step back of the phone's clock and restarted its stack")
|
||||
} else if (!result.accepted) {
|
||||
// A POTI_UPDATE or BTP_DATA_REQUEST the micrOBU refused (they are not awaited).
|
||||
_refusedRequests.value++
|
||||
if (result.code == LinkResultCode.NOT_CONFIGURED) { configured = null; lastPotiMs = null; lastPotiFixMs = null }
|
||||
noteRefusal("micrOBU refused a request: ${LinkResultCode.name(result.code)}")
|
||||
}
|
||||
}
|
||||
LinkOpcode.STATUS -> {
|
||||
val status = StationStatus.decode(message.body) ?: return
|
||||
val first = _protocol.value != Esp32Protocol.STATION_LINK
|
||||
_protocol.value = Esp32Protocol.STATION_LINK
|
||||
_stationStatus.value = status
|
||||
_linkStatus.value = EspLinkStatus(
|
||||
status = 0,
|
||||
oversizeDrops = 0,
|
||||
txFailures = status.radioFailed.coerceAtMost(0xFFFF).toInt(),
|
||||
rxCrcErrors = status.linkCrcErrors.coerceAtMost(0xFFFF).toInt(),
|
||||
rxQueueDrops = status.radioDropped.coerceAtMost(0xFFFF).toInt(),
|
||||
)
|
||||
// Board reset (or first contact): configure now, so its receiver runs even before
|
||||
// the first message is sent.
|
||||
if (!status.configured) { configured = null; lastPotiMs = null; lastPotiFixMs = null }
|
||||
if (first || !status.configured) scope.launch { ensureConfigured(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun noteRefusal(line: String) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastRefusalLogMs < 5_000) return
|
||||
lastRefusalLogMs = now
|
||||
Log.w(TAG, line)
|
||||
_detail.value = line
|
||||
// A refusal is news, not a state: it leaves the card again unless something replaced it.
|
||||
scope.launch {
|
||||
delay(DETAIL_HOLD_MS)
|
||||
_detail.compareAndSet(line, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Binary framing for the phone <-> ESP32-C5 link (Phase 03). Kotlin counterpart of the
|
||||
* firmware's `obu-firmware/main/serial_link.c`/`.h` — frame shape and CRC algorithm MUST stay
|
||||
@@ -23,6 +28,15 @@ object SerialFrameType {
|
||||
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
|
||||
* Payload layout is [EspLinkStatus] — see its KDoc. */
|
||||
const val STATUS: Int = 0x03
|
||||
|
||||
/**
|
||||
* Phone -> ESP32: a CAM together with the GeoNetworking Source Position Vector to transmit it
|
||||
* under. Payload is the [GnPositionVector.PREFIX_SIZE]-byte [GnPositionVector] prefix, then
|
||||
* the CAM UPER. Sent only to firmware whose heartbeat advertises
|
||||
* [EspLinkStatus.supportsCamTxPv]; `serial_link.h` explains why this is a new type rather
|
||||
* than a changed [CAM_TX].
|
||||
*/
|
||||
const val CAM_TX_PV: Int = 0x05
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,8 +57,9 @@ const val SERIAL_LINK_MAX_PAYLOAD = 512
|
||||
|
||||
/**
|
||||
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
|
||||
* [rxCrcErrors:2 LE]` (7 bytes). Counters are free-running totals since firmware boot and
|
||||
* saturate at 0xFFFF rather than wrapping.
|
||||
* [rxCrcErrors:2 LE][capabilities:1][rxQueueDrops:2 LE]` (10 bytes; the last two fields are an
|
||||
* optional tail — see [capabilities] and [rxQueueDrops]). Counters are free-running totals since
|
||||
* firmware boot and saturate at 0xFFFF rather than wrapping.
|
||||
*
|
||||
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
|
||||
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
|
||||
@@ -59,10 +74,31 @@ data class EspLinkStatus(
|
||||
val txFailures: Int,
|
||||
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
||||
val rxCrcErrors: Int,
|
||||
/**
|
||||
* What the firmware accepts, as `SERIAL_CAP_*` bits from `serial_link.h`. Byte 7 of the
|
||||
* payload; 0 for firmware that predates it and sends only 7 bytes, which is exactly the answer
|
||||
* the phone needs from such firmware: it accepts nothing beyond the original messages.
|
||||
*/
|
||||
val capabilities: Int = 0,
|
||||
/**
|
||||
* Promiscuously-captured frames the firmware's `wifi_promisc_rx_cb` had to drop because its
|
||||
* RX queue (8 deep) was still full of frames `rx_forward_task` hadn't finished forwarding —
|
||||
* bytes 8-9 of the payload. 0 for firmware that predates this field (payload of 7 or 8 bytes),
|
||||
* which is the honest answer: such firmware drops these frames identically, it just never
|
||||
* counted them. A nonzero, growing value here — as opposed to [oversizeDrops] — points at
|
||||
* bursty RX outrunning the forward task rather than any one frame being too large.
|
||||
*/
|
||||
val rxQueueDrops: Int = 0,
|
||||
) {
|
||||
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
|
||||
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_SIZE = 7
|
||||
|
||||
/** Mirrors `SERIAL_CAP_CAM_TX_PV` in `serial_link.h`. */
|
||||
const val CAP_CAM_TX_PV = 0x01
|
||||
|
||||
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
|
||||
fun parse(payload: ByteArray): EspLinkStatus? {
|
||||
if (payload.size < PAYLOAD_SIZE) return null
|
||||
@@ -72,6 +108,8 @@ data class EspLinkStatus(
|
||||
oversizeDrops = u16(1),
|
||||
txFailures = u16(3),
|
||||
rxCrcErrors = u16(5),
|
||||
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
|
||||
rxQueueDrops = if (payload.size >= 10) u16(8) else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +199,116 @@ data class V2xRxFrame(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The GeoNetworking Source Position Vector content sent with each CAM: the 24-byte little-endian
|
||||
* prefix of a [SerialFrameType.CAM_TX_PV] payload. Must stay in lockstep with the layout at
|
||||
* `SERIAL_MSG_CAM_TX_PV` in `serial_link.h`, which the firmware decodes into `gn_lpv_t`.
|
||||
*
|
||||
* Every field is something the ESP32-C5 cannot know by itself, since it has no GNSS and no clock
|
||||
* on the OCB channel. That is why its GN header used to carry fixed bench placeholders instead,
|
||||
* describing a stationary car at the bench while the CAM inside described the moving rider.
|
||||
*/
|
||||
data class GnPositionVector(
|
||||
/** Pseudonym, 6 bytes: both the 802.11 source address and the GN_ADDR MID. */
|
||||
val mac: ByteArray,
|
||||
/** TS 102 894-2 StationType. */
|
||||
val stationType: Int,
|
||||
/** Position Accuracy Indicator. */
|
||||
val pai: Boolean,
|
||||
/** TimestampIts at which the position was acquired; reduced modulo 2^32 on the wire. */
|
||||
val tstMs: Long,
|
||||
/** 1/10 microdegree. */
|
||||
val latTenMicroDeg: Int,
|
||||
/** 1/10 microdegree. */
|
||||
val lonTenMicroDeg: Int,
|
||||
/** 0.01 m/s, within the GN field's 15-bit signed range. */
|
||||
val speedCms: Int,
|
||||
/** 0.1 degree from north, clockwise, 0..3599. */
|
||||
val headingDeciDeg: Int,
|
||||
) {
|
||||
init {
|
||||
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||
}
|
||||
|
||||
/** The 24-byte prefix, little-endian like the rest of this framing. */
|
||||
fun toSerialPrefix(): ByteArray {
|
||||
val out = ByteArray(PREFIX_SIZE)
|
||||
mac.copyInto(out, destinationOffset = 0)
|
||||
out[6] = stationType.toByte()
|
||||
out[7] = (if (pai) 0x01 else 0x00).toByte()
|
||||
putLe(out, 8, tstMs, 4)
|
||||
putLe(out, 12, latTenMicroDeg.toLong(), 4)
|
||||
putLe(out, 16, lonTenMicroDeg.toLong(), 4)
|
||||
putLe(out, 20, speedCms.toLong(), 2)
|
||||
putLe(out, 22, headingDeciDeg.toLong(), 2)
|
||||
return out
|
||||
}
|
||||
|
||||
// Generated equals/hashCode would compare the MAC array by identity.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is GnPositionVector) return false
|
||||
return mac.contentEquals(other.mac) && stationType == other.stationType &&
|
||||
pai == other.pai && tstMs == other.tstMs && latTenMicroDeg == other.latTenMicroDeg &&
|
||||
lonTenMicroDeg == other.lonTenMicroDeg && speedCms == other.speedCms &&
|
||||
headingDeciDeg == other.headingDeciDeg
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var h = mac.contentHashCode()
|
||||
for (v in listOf(stationType, pai.hashCode(), tstMs.hashCode(), latTenMicroDeg,
|
||||
lonTenMicroDeg, speedCms, headingDeciDeg)) h = 31 * h + v
|
||||
return h
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_SIZE = 24
|
||||
|
||||
/** The GN speed field is 15-bit signed, in 0.01 m/s. */
|
||||
const val SPEED_MIN_CMS = -16384
|
||||
const val SPEED_MAX_CMS = 16383
|
||||
|
||||
/**
|
||||
* Largest Android horizontal accuracy, in metres, that still sets the Position Accuracy
|
||||
* Indicator.
|
||||
*
|
||||
* EN 302 636-4-1 sets PAI when the 95% semi-major confidence is below itsGnPaiInterval / 2,
|
||||
* and itsGnPaiInterval defaults to 80 m, so the bound is 40 m at 95%. Android reports a 68%
|
||||
* radius instead, and for a circular 2-D error the 95% radius is about 1.62 times the 68%
|
||||
* one, so 40 m becomes about 24.7 m on Android's scale.
|
||||
*/
|
||||
const val PAI_MAX_ACCURACY_M = 24.7f
|
||||
|
||||
/**
|
||||
* The position vector for [cam], built from the same values the CAM payload carries, so
|
||||
* the two layers of one frame describe the same station at the same moment. [accuracyM] is
|
||||
* Android's horizontal accuracy; null or 0 means unknown and leaves PAI clear.
|
||||
*/
|
||||
fun fromCam(cam: Cam, accuracyM: Float?, mac: ByteArray): GnPositionVector =
|
||||
GnPositionVector(
|
||||
mac = mac,
|
||||
stationType = cam.stationType,
|
||||
pai = accuracyM != null && accuracyM > 0f && accuracyM <= PAI_MAX_ACCURACY_M,
|
||||
tstMs = ItsTime.timestampIts(cam.timestamp),
|
||||
// Same rounding as CamUperCodec's referencePosition, so the GN position and the
|
||||
// CAM's own position agree to the last digit.
|
||||
latTenMicroDeg = (cam.latitude * 1e7).roundToLong().toInt(),
|
||||
lonTenMicroDeg = (cam.longitude * 1e7).roundToLong().toInt(),
|
||||
// Clamped, never wrapped: a wrapped 15-bit speed flips sign and reads as reversing.
|
||||
speedCms = if (cam.speedMps.isFinite()) {
|
||||
(cam.speedMps * 100).roundToInt().coerceIn(SPEED_MIN_CMS, SPEED_MAX_CMS)
|
||||
} else 0,
|
||||
headingDeciDeg = if (cam.headingDeg.isFinite()) {
|
||||
Math.floorMod((cam.headingDeg * 10).roundToInt(), 3600)
|
||||
} else 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun putLe(out: ByteArray, offset: Int, value: Long, bytes: Int) {
|
||||
for (i in 0 until bytes) out[offset + i] = ((value ushr (8 * i)) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
data class DecodedFrame(val type: Int, val payload: ByteArray)
|
||||
|
||||
object SerialFrameEncoder {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.hawhamburg.micr0bu.data.transport
|
||||
|
||||
/**
|
||||
* Phone side of the station-link message layer, version 1: the protocol of the colleague's
|
||||
* vanetza-idf ESP32-C5 firmware (microbu-esp32c5/station-link/README.md), which obu-firmware runs
|
||||
* since 2026-09-23. Kotlin counterpart of `obu-firmware/main/link_protocol.hpp` and of the
|
||||
* colleague's Python `microbu_link/messages.py`; the unit test pins these encoders to bytes that
|
||||
* Python module produced.
|
||||
*
|
||||
* Transport independent: over USB each message is the payload of one serial frame of type
|
||||
* [SERIAL_FRAME_LINK] (same 0xAA55 framing as before, see [SerialFrameEncoder]); over BLE each
|
||||
* message is one GATT value (see [BleLinkTransport]).
|
||||
*
|
||||
* Message: `[opcode:1][flags:1][sequence:2 LE][body]`, at most [LINK_MAX_MESSAGE] octets, all
|
||||
* integers little-endian. The phone numbers its requests; the firmware answers with a RESULT
|
||||
* carrying the same sequence.
|
||||
*
|
||||
* Only what this app uses is implemented: configure, PoTi, BTP-DATA.request, credential
|
||||
* provisioning, RESULT, STATUS, and the MicrOBU extension [LinkOpcode.V2X_RX]. The SF-SAP
|
||||
* identifier-change primitives are not used: the app owns its pseudonym (see
|
||||
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager]) and reconfigures the station on a change.
|
||||
*/
|
||||
const val SERIAL_FRAME_LINK = 0x10
|
||||
const val LINK_MAX_MESSAGE = 512
|
||||
const val LINK_HEADER_SIZE = 4
|
||||
|
||||
object LinkOpcode {
|
||||
const val STATION_CONFIGURE = 0x01
|
||||
const val POTI_UPDATE = 0x02
|
||||
const val BTP_DATA_REQUEST = 0x03
|
||||
const val CREDENTIALS_PROVISION = 0x04
|
||||
const val CREDENTIALS_ERASE = 0x05
|
||||
const val STATUS_REQUEST = 0x0C
|
||||
const val RESULT = 0x80
|
||||
const val BTP_DATA_INDICATION = 0x81
|
||||
const val STATUS = 0x84
|
||||
/** MicrOBU extension: body is exactly the old [SerialFrameType.V2X_RX] payload ([V2xRxFrame]). */
|
||||
const val V2X_RX = 0x85
|
||||
}
|
||||
|
||||
/** RESULT codes: vanetza_idf::Result first, then the link's own. */
|
||||
object LinkResultCode {
|
||||
const val ACCEPTED = 0
|
||||
const val TIME_REGRESSION = 7
|
||||
const val NOT_CONFIGURED = 0x12
|
||||
|
||||
fun name(code: Int): String = when (code) {
|
||||
0 -> "accepted"; 1 -> "invalid_argument"; 2 -> "unsupported"; 3 -> "wrong_entry_point"
|
||||
4 -> "security_unavailable"; 5 -> "resource_limit"; 6 -> "rejected"; 7 -> "time_regression"
|
||||
8 -> "identity_change_pending"; 0x10 -> "unknown_opcode"; 0x11 -> "malformed"
|
||||
0x12 -> "not_configured"; 0x13 -> "busy"; 0x14 -> "no_credentials"
|
||||
else -> "code_$code"
|
||||
}
|
||||
}
|
||||
|
||||
/** GN security profile of a BTP-DATA.request (TS 103 300-3 Table 4). */
|
||||
object LinkSecurityProfile {
|
||||
const val STATION_DEFAULT = 0
|
||||
const val UNSECURED = 1
|
||||
const val SECURED = 2
|
||||
}
|
||||
|
||||
class LinkMessage(val opcode: Int, val sequence: Int, val body: ByteArray, val flags: Int = 0) {
|
||||
fun encode(): ByteArray {
|
||||
require(LINK_HEADER_SIZE + body.size <= LINK_MAX_MESSAGE) {
|
||||
"link message 0x%02x too long: %d".format(opcode, LINK_HEADER_SIZE + body.size)
|
||||
}
|
||||
return byteArrayOf(opcode.toByte(), flags.toByte(), sequence.toByte(), (sequence shr 8).toByte()) + body
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun decode(octets: ByteArray): LinkMessage? {
|
||||
if (octets.size < LINK_HEADER_SIZE || octets.size > LINK_MAX_MESSAGE) return null
|
||||
val sequence = (octets[2].toInt() and 0xFF) or ((octets[3].toInt() and 0xFF) shl 8)
|
||||
return LinkMessage(octets[0].toInt() and 0xFF, sequence,
|
||||
octets.copyOfRange(LINK_HEADER_SIZE, octets.size), octets[1].toInt() and 0xFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class LinkWriter {
|
||||
private val out = java.io.ByteArrayOutputStream()
|
||||
fun u8(v: Int) = apply { out.write(v and 0xFF) }
|
||||
fun u16(v: Int) = apply { u8(v); u8(v shr 8) }
|
||||
fun u32(v: Long) = apply { for (i in 0 until 4) u8((v ushr (8 * i)).toInt()) }
|
||||
fun i32(v: Int) = u32(v.toLong())
|
||||
fun u64(v: Long) = apply { for (i in 0 until 8) u8((v ushr (8 * i)).toInt()) }
|
||||
fun bytes(b: ByteArray) = apply { out.write(b) }
|
||||
fun toByteArray(): ByteArray = out.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* STATION_CONFIGURE body. (Re)creates the GeoNetworking stack and security entity on the micrOBU.
|
||||
* [mid] is the pseudonym MAC: with [addressConfiguration] 0 (AUTO) it becomes both the GN_ADDR MID
|
||||
* and the 802.11 source address, as with the old CAM_TX_PV prefix.
|
||||
*/
|
||||
data class StationConfigure(
|
||||
val stationType: Int,
|
||||
val mid: ByteArray,
|
||||
val security: Int = 1,
|
||||
val addressConfiguration: Int = 0,
|
||||
val beaconing: Int = 0,
|
||||
val channelNumber: Int = 180,
|
||||
val transmitPowerDbm: Int = 20,
|
||||
/** 0 off, 1 receive only, 2 transmit and receive. */
|
||||
val radio: Int = 2,
|
||||
/** Raw GN traffic class octet: TC-ID 2, as the previous firmware's geonet.c. */
|
||||
val defaultTrafficClass: Int = 2,
|
||||
/** Raw GN lifetime octet: 1 s. */
|
||||
val defaultLifetime: Int = 0x05,
|
||||
) {
|
||||
init { require(mid.size == 6) }
|
||||
|
||||
fun encode(): ByteArray = LinkWriter()
|
||||
.u8(stationType).u8(security).u8(addressConfiguration).bytes(mid).u8(beaconing)
|
||||
.u16(channelNumber).u8(transmitPowerDbm).u8(radio).u8(defaultTrafficClass).u8(defaultLifetime)
|
||||
.toByteArray()
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is StationConfigure && encode().contentEquals(other.encode())
|
||||
override fun hashCode(): Int = encode().contentHashCode()
|
||||
}
|
||||
|
||||
/** RESULT detail of STATION_CONFIGURE. */
|
||||
data class StationInfo(val credentialsLoaded: Boolean, val tickets: Int) {
|
||||
companion object {
|
||||
fun decode(detail: ByteArray): StationInfo? =
|
||||
if (detail.size != 18) null
|
||||
else StationInfo(detail[16].toInt() != 0, detail[17].toInt() and 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POTI_UPDATE body (EN 302 890-2 minimum data set). [timestampMs] is TimestampIts under
|
||||
* [com.hawhamburg.micr0bu.domain.asn1.ItsTime]'s convention; it also sets the micrOBU's ITS clock,
|
||||
* which the security entity stamps into every signed message's generationTime.
|
||||
*/
|
||||
data class PotiUpdate(
|
||||
val timestampMs: Long,
|
||||
val latTenMicroDeg: Int,
|
||||
val lonTenMicroDeg: Int,
|
||||
val semiMajorCm: Int = 0,
|
||||
val semiMinorCm: Int = 0,
|
||||
val orientationDeciDeg: Int = 0,
|
||||
val altitudeCm: Int? = null,
|
||||
val speedCms: Int? = null,
|
||||
val headingDeciDeg: Int? = null,
|
||||
val pai: Boolean = false,
|
||||
) {
|
||||
fun encode(): ByteArray {
|
||||
val flags = (if (altitudeCm != null) 1 else 0) or (if (speedCms != null) 2 else 0) or
|
||||
(if (headingDeciDeg != null) 4 else 0) or (if (pai) 8 else 0)
|
||||
return LinkWriter().u64(timestampMs).i32(latTenMicroDeg).i32(lonTenMicroDeg)
|
||||
.u16(semiMajorCm).u16(semiMinorCm).u16(orientationDeciDeg).u8(flags)
|
||||
.i32(altitudeCm ?: 0).u16(speedCms ?: 0).u16(headingDeciDeg ?: 0)
|
||||
.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BTP_DATA_REQUEST body for a BTP-B single-hop broadcast, the only shape this app sends (CAM, VAM).
|
||||
* [permissions] is the SSP the authorization ticket must carry for [itsAid].
|
||||
*/
|
||||
data class BtpDataRequest(
|
||||
val destinationPort: Int,
|
||||
val itsAid: Long,
|
||||
val securityProfile: Int,
|
||||
val permissions: ByteArray,
|
||||
val flSdu: ByteArray,
|
||||
/** Raw GN lifetime octet; 0xFF = station default. */
|
||||
val maximumPacketLifetime: Int = 0xFF,
|
||||
) {
|
||||
fun encode(): ByteArray = LinkWriter()
|
||||
.u8(1) // BTP-B
|
||||
.u16(destinationPort)
|
||||
.u16(0) // destination port info
|
||||
.u8(1) // SHB
|
||||
.u8(1) // communication profile ITS-G5
|
||||
.u8(securityProfile)
|
||||
.u8(0xFF) // traffic class: station default
|
||||
.u8(maximumPacketLifetime)
|
||||
.u8(0) // hop limit: station default
|
||||
.u16(0).u16(0) // no repetition
|
||||
.u32(itsAid)
|
||||
.u8(permissions.size).bytes(permissions)
|
||||
.u8(0) // no SN-ENCAP context
|
||||
.u16(flSdu.size).bytes(flSdu)
|
||||
.toByteArray()
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is BtpDataRequest && encode().contentEquals(other.encode())
|
||||
override fun hashCode(): Int = encode().contentHashCode()
|
||||
}
|
||||
|
||||
/** One CREDENTIALS_PROVISION segment of a `VCR1` bundle. */
|
||||
fun credentialsSegment(totalLength: Int, offset: Int, segment: ByteArray): ByteArray {
|
||||
require(segment.size <= 255)
|
||||
return LinkWriter().u16(totalLength).u16(offset).u8(segment.size).bytes(segment).toByteArray()
|
||||
}
|
||||
|
||||
data class LinkResult(val code: Int, val detail: ByteArray) {
|
||||
val accepted: Boolean get() = code == LinkResultCode.ACCEPTED
|
||||
override fun toString(): String = "LinkResult(${LinkResultCode.name(code)}, ${detail.size} B detail)"
|
||||
override fun equals(other: Any?): Boolean = other is LinkResult && code == other.code && detail.contentEquals(other.detail)
|
||||
override fun hashCode(): Int = 31 * code + detail.contentHashCode()
|
||||
|
||||
companion object {
|
||||
fun decode(body: ByteArray): LinkResult? {
|
||||
if (body.size < 2) return null
|
||||
val length = body[1].toInt() and 0xFF
|
||||
if (body.size != 2 + length) return null
|
||||
return LinkResult(body[0].toInt() and 0xFF, body.copyOfRange(2, body.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** STATUS body (103 octets), sent by the micrOBU every second. Counters are since the last configure. */
|
||||
data class StationStatus(
|
||||
val uptimeMs: Long,
|
||||
val configured: Boolean,
|
||||
val identifier: ByteArray,
|
||||
val tickets: Int,
|
||||
val signedMessages: Long,
|
||||
val refusedNoTicket: Long,
|
||||
val refusedChangePending: Long,
|
||||
val refusedPermission: Long,
|
||||
val signFailed: Long,
|
||||
val verified: Long,
|
||||
val rejected: Long,
|
||||
val requestsAccepted: Long,
|
||||
val requestsRefused: Long,
|
||||
val radioSubmitted: Long,
|
||||
val radioFailed: Long,
|
||||
val radioReceived: Long,
|
||||
val radioDropped: Long,
|
||||
val linkCrcErrors: Long,
|
||||
val linkMalformed: Long,
|
||||
val potiUpdates: Long,
|
||||
val itsTimeMs: Long,
|
||||
) {
|
||||
/** Signing refusals of every kind: no usable ticket, a pending id change, or a missing permission. */
|
||||
val signRefused: Long get() = refusedNoTicket + refusedChangePending + refusedPermission + signFailed
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is StationStatus && toString() == other.toString() &&
|
||||
identifier.contentEquals(other.identifier)
|
||||
override fun hashCode(): Int = toString().hashCode()
|
||||
|
||||
companion object {
|
||||
const val SIZE = 103
|
||||
|
||||
fun decode(body: ByteArray): StationStatus? {
|
||||
if (body.size != SIZE) return null
|
||||
fun u8(i: Int) = body[i].toInt() and 0xFF
|
||||
fun u32(i: Int) = (0 until 4).fold(0L) { acc, k -> acc or ((body[i + k].toLong() and 0xFF) shl (8 * k)) }
|
||||
fun u64(i: Int) = (0 until 8).fold(0L) { acc, k -> acc or ((body[i + k].toLong() and 0xFF) shl (8 * k)) }
|
||||
// [0] uptime u32, [4] configured, [5] gn_address 8, [13] identifier 8, [21] change_pending,
|
||||
// [22] tickets, [23] 18 x u32 counters, [95] its_time u64
|
||||
val c = 23
|
||||
return StationStatus(
|
||||
uptimeMs = u32(0),
|
||||
configured = u8(4) != 0,
|
||||
identifier = body.copyOfRange(13, 21),
|
||||
tickets = u8(22),
|
||||
signedMessages = u32(c), refusedNoTicket = u32(c + 4), refusedChangePending = u32(c + 8),
|
||||
refusedPermission = u32(c + 12), signFailed = u32(c + 16), verified = u32(c + 20),
|
||||
rejected = u32(c + 24), requestsAccepted = u32(c + 28), requestsRefused = u32(c + 32),
|
||||
// c + 36: indications (the stack's own verified deliveries; the app uses V2X_RX)
|
||||
radioSubmitted = u32(c + 40), radioFailed = u32(c + 44), radioReceived = u32(c + 48),
|
||||
radioDropped = u32(c + 52),
|
||||
// c + 56: link_rx_frames
|
||||
linkCrcErrors = u32(c + 60), linkMalformed = u32(c + 64), potiUpdates = u32(c + 68),
|
||||
itsTimeMs = u64(95),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,6 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Connection lifecycle for the ESP32-C5 USB-serial link. */
|
||||
enum class UsbSerialState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
|
||||
|
||||
private const val ACTION_USB_PERMISSION = "com.hawhamburg.micr0bu.USB_SERIAL_PERMISSION"
|
||||
private const val TAG = "UsbSerialTransport"
|
||||
|
||||
@@ -63,6 +60,11 @@ private const val TAG = "UsbSerialTransport"
|
||||
* Baud rate is not applicable here — USB Serial/JTAG has no baud concept; `setParameters` below
|
||||
* is a no-op the library requires anyway for API-shape reasons but the value is otherwise unused.
|
||||
*
|
||||
* Since 2026-09-23 the rest of the app does not use this class directly but [Esp32Link], which
|
||||
* picks this or [BleLinkTransport] and speaks either the previous firmware's frames (0x01-0x05,
|
||||
* [sendCamTx]) or the current firmware's station-link messages (frame type [SERIAL_FRAME_LINK],
|
||||
* [sendFrame]) over it.
|
||||
*
|
||||
* ## Ownership
|
||||
* This is a `@Singleton` shared by the UI ([com.hawhamburg.micr0bu.viewmodel.MqttViewModel]), the
|
||||
* foreground [com.hawhamburg.micr0bu.service.TripRecordingService]'s
|
||||
@@ -111,8 +113,8 @@ class UsbSerialTransport @Inject constructor(
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED)
|
||||
val state: StateFlow<UsbSerialState> = _state.asStateFlow()
|
||||
private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
|
||||
val state: StateFlow<Esp32LinkState> = _state.asStateFlow()
|
||||
|
||||
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
|
||||
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
|
||||
@@ -150,7 +152,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
} else {
|
||||
Log.w(TAG, "usbReceiver: permission denied or device null " +
|
||||
"(granted=$granted, device=$device)")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
}
|
||||
}
|
||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
||||
@@ -174,7 +176,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
* repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
|
||||
*/
|
||||
fun connect() {
|
||||
if (_state.value == UsbSerialState.CONNECTED) {
|
||||
if (_state.value == Esp32LinkState.CONNECTED) {
|
||||
Log.i(TAG, "connect(): already connected, no-op")
|
||||
return
|
||||
}
|
||||
@@ -197,7 +199,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
"(see device list logged above) - either nothing is attached at the Android " +
|
||||
"USB level, or it's attached but its VID/PID doesn't match any entry in " +
|
||||
"customProber's table")
|
||||
_state.value = UsbSerialState.DISCONNECTED
|
||||
_state.value = Esp32LinkState.DISCONNECTED
|
||||
return
|
||||
}
|
||||
if (espDriver == null) {
|
||||
@@ -211,14 +213,14 @@ class UsbSerialTransport @Inject constructor(
|
||||
Log.i(TAG, "connect(): matched device vid=0x${device.vendorId.toString(16)} " +
|
||||
"pid=0x${device.productId.toString(16)} name=${device.deviceName} " +
|
||||
"ports=${driver.ports.size}")
|
||||
_state.value = UsbSerialState.DEVICE_ATTACHED
|
||||
_state.value = Esp32LinkState.DEVICE_ATTACHED
|
||||
|
||||
if (usbManager.hasPermission(device)) {
|
||||
Log.i(TAG, "connect(): permission already granted, opening directly")
|
||||
openDevice(device)
|
||||
} else {
|
||||
Log.i(TAG, "connect(): requesting USB permission from user")
|
||||
_state.value = UsbSerialState.PERMISSION_REQUESTED
|
||||
_state.value = Esp32LinkState.PERMISSION_REQUESTED
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||
val permissionIntent = PendingIntent.getBroadcast(
|
||||
context, 0, Intent(ACTION_USB_PERMISSION).setPackage(context.packageName), flags,
|
||||
@@ -269,14 +271,14 @@ class UsbSerialTransport @Inject constructor(
|
||||
if (driver == null || driver.ports.isEmpty()) {
|
||||
Log.w(TAG, "openDevice(): probeDevice returned null or no ports for " +
|
||||
"vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
return
|
||||
}
|
||||
val connection = usbManager.openDevice(device)
|
||||
if (connection == null) {
|
||||
Log.w(TAG, "openDevice(): usbManager.openDevice() returned null - permission not " +
|
||||
"actually granted, or Android couldn't claim the device")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
@@ -287,7 +289,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e)
|
||||
runCatching { newPort.close() }
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
@@ -329,12 +331,16 @@ class UsbSerialTransport @Inject constructor(
|
||||
prev.oversizeDrops != status.oversizeDrops ||
|
||||
prev.txFailures != status.txFailures ||
|
||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||
prev.status != status.status
|
||||
prev.status != status.status ||
|
||||
prev.capabilities != status.capabilities ||
|
||||
prev.rxQueueDrops != status.rxQueueDrops
|
||||
) {
|
||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||
"oversizeDrops=${status.oversizeDrops} " +
|
||||
"txFailures=${status.txFailures} " +
|
||||
"rxCrcErrors=${status.rxCrcErrors}")
|
||||
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||
"capabilities=${status.capabilities} " +
|
||||
"rxQueueDrops=${status.rxQueueDrops}")
|
||||
}
|
||||
_linkStatus.value = status
|
||||
}
|
||||
@@ -345,7 +351,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
|
||||
override fun onRunError(e: Exception) {
|
||||
Log.e(TAG, "SerialInputOutputManager.onRunError()", e)
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
}
|
||||
})
|
||||
ioManager = manager
|
||||
@@ -358,13 +364,13 @@ class UsbSerialTransport @Inject constructor(
|
||||
_consecutiveWriteFailures.value = 0
|
||||
_linkStatus.value = null
|
||||
lastFrameAtMs = SystemClock.elapsedRealtime()
|
||||
_state.value = UsbSerialState.CONNECTED
|
||||
_state.value = Esp32LinkState.CONNECTED
|
||||
startWatchdog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips the link to [UsbSerialState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
|
||||
* Flips the link to [Esp32LinkState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
|
||||
* missing for [LINK_TIMEOUT_MS]. Without this, "connected" only ever means "the port opened
|
||||
* at some point in the past" — which on a bench is exactly the wrong thing to believe.
|
||||
*/
|
||||
@@ -373,19 +379,38 @@ class UsbSerialTransport @Inject constructor(
|
||||
watchdogJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(WATCHDOG_POLL_MS)
|
||||
if (_state.value != UsbSerialState.CONNECTED) continue
|
||||
if (_state.value != Esp32LinkState.CONNECTED) continue
|
||||
val silentFor = SystemClock.elapsedRealtime() - lastFrameAtMs
|
||||
if (silentFor > LINK_TIMEOUT_MS) {
|
||||
Log.w(TAG, "watchdog: no frame from ESP32 for ${silentFor}ms (heartbeat " +
|
||||
"expected at 1 Hz) - marking link ERROR. Either the firmware is wedged/" +
|
||||
"not running, or the host->device direction opened but device->host " +
|
||||
"never did (see the DTR note in openDevice()).")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which frame type the last CAM went out as, so a change of path is logged once, not per CAM. */
|
||||
@Volatile private var lastTxWithPositionVector: Boolean? = null
|
||||
|
||||
/**
|
||||
* Logs whenever CAMs switch between [SerialFrameType.CAM_TX_PV] and legacy
|
||||
* [SerialFrameType.CAM_TX]. Without it, "the GN header still says bench" has no visible cause
|
||||
* in a logcat capture: it looks identical whether the firmware is old or the phone is.
|
||||
*/
|
||||
private fun noteTxPath(withPositionVector: Boolean, requested: Boolean) {
|
||||
if (lastTxWithPositionVector == withPositionVector) return
|
||||
lastTxWithPositionVector = withPositionVector
|
||||
Log.i(TAG, when {
|
||||
withPositionVector -> "CAM TX path: CAM_TX_PV, GN position vector supplied by the phone"
|
||||
requested -> "CAM TX path: legacy CAM_TX, firmware has not advertised CAM_TX_PV yet; " +
|
||||
"GN position vector is the firmware's bench placeholder"
|
||||
else -> "CAM TX path: legacy CAM_TX, no position vector supplied"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
|
||||
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
|
||||
@@ -394,22 +419,57 @@ class UsbSerialTransport @Inject constructor(
|
||||
* [consecutiveWriteFailures] so they can't stay invisible.
|
||||
*
|
||||
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
|
||||
*
|
||||
* [positionVector], when given, travels with the CAM as a [SerialFrameType.CAM_TX_PV] frame so
|
||||
* the ESP32 builds the GeoNetworking Source Position Vector from real values. It is used only
|
||||
* once the heartbeat advertises [EspLinkStatus.supportsCamTxPv]. Until then, and against
|
||||
* firmware that predates it, the CAM goes out as a plain [SerialFrameType.CAM_TX] exactly as
|
||||
* before and the GN header carries the firmware's bench placeholders. Neither mixed-version
|
||||
* combination breaks transmission; `serial_link.h` explains why.
|
||||
*/
|
||||
fun sendCamTx(camUperBytes: ByteArray): Boolean {
|
||||
fun sendCamTx(camUperBytes: ByteArray, positionVector: GnPositionVector? = null): Boolean {
|
||||
val p = port
|
||||
if (p == null) {
|
||||
_consecutiveWriteFailures.update { it + 1 }
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
val pv = positionVector?.takeIf { _linkStatus.value?.supportsCamTxPv == true }
|
||||
noteTxPath(withPositionVector = pv != null, requested = positionVector != null)
|
||||
val frame = if (pv != null) {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX_PV, pv.toSerialPrefix() + camUperBytes)
|
||||
} else {
|
||||
SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
}
|
||||
p.write(frame, /* timeout ms */ 200)
|
||||
_consecutiveWriteFailures.value = 0
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
|
||||
Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e)
|
||||
_state.value = UsbSerialState.ERROR
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one frame of any [type] (the station-link messages go as [SERIAL_FRAME_LINK]). Same
|
||||
* failure accounting as [sendCamTx]. Blocking, 200 ms timeout: call from a background dispatcher.
|
||||
*/
|
||||
fun sendFrame(type: Int, payload: ByteArray): Boolean {
|
||||
val p = port
|
||||
if (p == null) {
|
||||
_consecutiveWriteFailures.update { it + 1 }
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
p.write(SerialFrameEncoder.encode(type, payload), /* timeout ms */ 200)
|
||||
_consecutiveWriteFailures.value = 0
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
|
||||
Log.w(TAG, "sendFrame(0x${type.toString(16)}): write failed (consecutive failures: $failures)", e)
|
||||
_state.value = Esp32LinkState.ERROR
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -425,7 +485,7 @@ class UsbSerialTransport @Inject constructor(
|
||||
openDeviceName = null
|
||||
_linkStatus.value = null
|
||||
_consecutiveWriteFailures.value = 0
|
||||
_state.value = UsbSerialState.DISCONNECTED
|
||||
_state.value = Esp32LinkState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import javax.inject.Singleton
|
||||
* UPER (Unaligned Packed Encoding Rules) codec for CAM, used on the ESP32-C5 hardware path
|
||||
* (Phase 03, Section 13): the phone builds outgoing CAM itself
|
||||
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]) and UPER-encodes it before handing bytes
|
||||
* to [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport], and UPER-decodes whatever the
|
||||
* to [com.hawhamburg.micr0bu.data.transport.Esp32Link], and UPER-decodes whatever the
|
||||
* ESP32-C5 forwards back on receive (already stripped of 802.11/GeoNetworking/BTP framing by
|
||||
* the firmware's `gn_unwrap.c` — this only ever sees CAM UPER bytes, never raw radio frames).
|
||||
*
|
||||
|
||||
@@ -34,9 +34,6 @@ object CamUperCodec {
|
||||
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
|
||||
private const val ENCODE_BUFFER_BYTES = 96
|
||||
|
||||
// TimestampIts epoch: 2004-01-01T00:00:00Z, in Unix epoch milliseconds.
|
||||
private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L
|
||||
|
||||
// ASN.1 "unavailable" sentinel values, straight from the CAM/ITS-Container modules (also
|
||||
// documented inline in cam.c against each field).
|
||||
private const val HEADING_UNAVAILABLE = 3601
|
||||
@@ -47,9 +44,13 @@ object CamUperCodec {
|
||||
private const val ACCEL_UNAVAILABLE = 161
|
||||
private const val YAW_RATE_UNAVAILABLE = 32767
|
||||
|
||||
/** Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod 65536). */
|
||||
/**
|
||||
* Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod
|
||||
* 65536). Goes through [ItsTime], the same rule the GeoNetworking TST uses, so the two
|
||||
* timestamps in one transmitted frame cannot disagree.
|
||||
*/
|
||||
fun generationDeltaTime(epochMs: Long): Int {
|
||||
val itsMs = epochMs - TS_ITS_EPOCH_MS
|
||||
val itsMs = ItsTime.timestampIts(epochMs)
|
||||
// floorMod so this stays well-defined even for epochMs before the ITS epoch (shouldn't
|
||||
// happen with a real clock, but avoids a negative/UB result if it ever does).
|
||||
return Math.floorMod(itsMs, 65536L).toInt()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.hawhamburg.micr0bu.domain.asn1
|
||||
|
||||
/**
|
||||
* ITS time, as used by every timestamp this app puts on the air.
|
||||
*
|
||||
* TimestampIts (ETSI TS 102 894-2) counts milliseconds from 2004-01-01T00:00:00Z. Two fields in a
|
||||
* single transmitted frame come from it: the CAM's generationDeltaTime (modulo 65536) and the
|
||||
* GeoNetworking Source Position Vector's TST (modulo 2^32). A receiver can compare the two, so
|
||||
* they must follow one rule. Both go through here so they cannot drift apart.
|
||||
*
|
||||
* **Which clock.** The input should be GNSS time, not the phone's wall clock. A phone with no SIM
|
||||
* and no internet time has no automatic time source at all, and the bench phone was found 24
|
||||
* minutes fast that way. `GnssTimeSource` moves a timestamp onto GNSS time, using [onGnssTime],
|
||||
* before it gets here.
|
||||
*
|
||||
* **Open question: leap seconds.** This is Unix time minus the 2004 epoch, with no leap-second
|
||||
* term. If TimestampIts is read as TAI-based, the correct value is currently 5 s higher, for the
|
||||
* five leap seconds inserted since 2004. Whichever reading turns out right, it is changed here and
|
||||
* nowhere else. Settling it needs a frame from a third-party stack with a trusted clock, such as
|
||||
* the RSU's CAM compared against GNSS time, and no such traffic was on air when this was written.
|
||||
*/
|
||||
object ItsTime {
|
||||
/** 2004-01-01T00:00:00Z in Unix epoch milliseconds. */
|
||||
const val EPOCH_MS = 1_072_915_200_000L
|
||||
|
||||
/** TimestampIts for wall-clock [epochMs], before any modulo is applied. */
|
||||
fun timestampIts(epochMs: Long): Long = epochMs - EPOCH_MS
|
||||
|
||||
/**
|
||||
* Moves [systemMs], a reading of this phone's wall clock, onto GNSS time, using one pair of
|
||||
* simultaneous readings of both clocks: [gnssNowMs] and [systemNowMs]. Their difference is the
|
||||
* wall clock's error, whatever caused it, and the age of [systemMs] is preserved. Returns
|
||||
* [systemMs] unchanged when there is no GNSS reading.
|
||||
*
|
||||
* Pure so the arithmetic can be tested apart from the Android clock API, which is where the
|
||||
* readings come from (see `GnssTimeSource`).
|
||||
*/
|
||||
fun onGnssTime(systemMs: Long, gnssNowMs: Long?, systemNowMs: Long): Long =
|
||||
if (gnssNowMs == null) systemMs else systemMs + (gnssNowMs - systemNowMs)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hawhamburg.micr0bu.domain.asn1
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* One VAM's content, in the units the phone has. Everything optional here is encoded as the ASN.1
|
||||
* "unavailable" value when null, never as a made-up number.
|
||||
*/
|
||||
data class VamContent(
|
||||
val stationId: Long,
|
||||
/** Wall-clock epoch ms of the fix, GNSS-corrected; the generationDeltaTime source. */
|
||||
val timestamp: Long,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
/** Android horizontal accuracy, metres (68 %). Null or 0: unknown. */
|
||||
val accuracyM: Float?,
|
||||
val speedMps: Double,
|
||||
val headingDeg: Double,
|
||||
/** Along-track acceleration, m/s². */
|
||||
val accelerationMps2: Double? = null,
|
||||
/** Include the low-frequency container (profile and size class). */
|
||||
val includeLowFrequency: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* UPER encoder for the VAM of ETSI TS 103 300-3 V2.3.1 (VAM-PDU-Descriptions major version 3,
|
||||
* over the CDD of TS 102 894-2 V2.4.1), for a bicyclist in VRU profile 2.
|
||||
*
|
||||
* Covers the same field set as the colleague's reference VBS (microbu-esp32c5,
|
||||
* station-link/python/microbu_link/vbs.py, `VbsLite.assemble`), which encodes with asn1tools from
|
||||
* the ETSI modules: header, basic container, the three mandatory fields of the high-frequency
|
||||
* container, and optionally the low-frequency container with profile and size class. No cluster
|
||||
* or motion-prediction containers. The unit test cross-checks the bytes against asn1tools.
|
||||
*
|
||||
* Transmit only for now: the app neither decodes received VAMs nor shows them.
|
||||
*/
|
||||
object VamUperCodec {
|
||||
|
||||
const val PROTOCOL_VERSION = 3
|
||||
const val MESSAGE_ID_VAM = 16
|
||||
|
||||
/** VruSubProfileBicyclist.bicyclist */
|
||||
private const val SUBPROFILE_BICYCLIST = 1
|
||||
/** VruSizeClass.low */
|
||||
private const val SIZE_CLASS_LOW = 1
|
||||
/** VruProfileAndSubprofile CHOICE index of bicyclistAndLightVruVehicle (root: 4 alternatives). */
|
||||
private const val PROFILE_BICYCLIST_INDEX = 1
|
||||
|
||||
private const val SEMI_AXIS_OUT_OF_RANGE = 4094
|
||||
private const val SEMI_AXIS_UNAVAILABLE = 4095
|
||||
private const val WGS84_ANGLE_UNAVAILABLE = 3601
|
||||
private const val ANGLE_CONFIDENCE_UNAVAILABLE = 127
|
||||
private const val SPEED_OUT_OF_RANGE = 16382
|
||||
private const val SPEED_CONFIDENCE_UNAVAILABLE = 127
|
||||
private const val ACCEL_UNAVAILABLE = 161
|
||||
private const val ACCEL_CONFIDENCE_UNAVAILABLE = 102
|
||||
private const val ALTITUDE_UNAVAILABLE = 800001
|
||||
private const val ALTITUDE_CONFIDENCE_UNAVAILABLE = 15
|
||||
|
||||
/**
|
||||
* Android's accuracy is a 68 % radius; the confidence ellipse is 95 %. For a circular 2-D error
|
||||
* the ratio is about 1.62, the same factor [com.hawhamburg.micr0bu.data.transport.GnPositionVector]
|
||||
* uses for its PAI bound.
|
||||
*/
|
||||
private const val ACCURACY_68_TO_95 = 1.62
|
||||
|
||||
private const val ENCODE_BUFFER_BYTES = 64
|
||||
|
||||
fun encode(vam: VamContent): ByteArray {
|
||||
val w = BitWriter(ENCODE_BUFFER_BYTES)
|
||||
|
||||
// VAM ::= SEQUENCE { header, vam } -- not extensible
|
||||
// ItsPduHeader
|
||||
w.putBits(PROTOCOL_VERSION, 8)
|
||||
w.putBits(MESSAGE_ID_VAM, 8)
|
||||
w.putBits(vam.stationId and 0xFFFFFFFFL, 32)
|
||||
|
||||
// VruAwareness ::= SEQUENCE { generationDeltaTime, vamParameters }
|
||||
w.putBits(CamUperCodec.generationDeltaTime(vam.timestamp), 16)
|
||||
|
||||
// VamParameters ::= SEQUENCE { basic, hf, lf OPT, clusterInfo OPT, clusterOp OPT, motion OPT, ... }
|
||||
w.putBits(0, 1) // extension bit
|
||||
w.putBits(if (vam.includeLowFrequency) 0b1000 else 0b0000, 4)
|
||||
|
||||
// BasicContainer ::= SEQUENCE { stationType, referencePosition, ... }
|
||||
w.putBits(0, 1)
|
||||
w.putBits(StationType.CYCLIST, 8)
|
||||
// ReferencePositionWithConfidence ::= SEQUENCE { latitude, longitude, ellipse, altitude }
|
||||
w.putBits(latitude(vam.latitude) + 900_000_000L, 31)
|
||||
w.putBits(longitude(vam.longitude) + 1_800_000_000L, 32)
|
||||
val semiAxis = semiAxisCm(vam.accuracyM)
|
||||
w.putBits(semiAxis, 12) // semiMajorAxisLength
|
||||
w.putBits(semiAxis, 12) // semiMinorAxisLength
|
||||
// Circular error: the orientation of the major axis says nothing, so it is unavailable.
|
||||
w.putBits(WGS84_ANGLE_UNAVAILABLE, 12)
|
||||
// Altitude: GnssReading carries no "has altitude" flag, so an honest unavailable.
|
||||
w.putBits(ALTITUDE_UNAVAILABLE + 100_000, 20)
|
||||
w.putBits(ALTITUDE_CONFIDENCE_UNAVAILABLE, 4)
|
||||
|
||||
// VruHighFrequencyContainer ::= SEQUENCE { heading, speed, longitudinalAcceleration, 11 OPTIONAL, ... }
|
||||
w.putBits(0, 1)
|
||||
w.putBits(0, 11)
|
||||
w.putBits(headingDeciDeg(vam.headingDeg), 12)
|
||||
w.putBits(ANGLE_CONFIDENCE_UNAVAILABLE - 1, 7) // Wgs84AngleConfidence (1..127)
|
||||
w.putBits(speedCms(vam.speedMps), 14)
|
||||
w.putBits(SPEED_CONFIDENCE_UNAVAILABLE - 1, 7) // SpeedConfidence (1..127)
|
||||
w.putBits(accelDeciMps2(vam.accelerationMps2) + 160, 9)
|
||||
w.putBits(ACCEL_CONFIDENCE_UNAVAILABLE, 7)
|
||||
|
||||
if (vam.includeLowFrequency) {
|
||||
// VruLowFrequencyContainer ::= SEQUENCE { profileAndSubprofile, sizeClass OPT, exteriorLights OPT, ... }
|
||||
w.putBits(0, 1)
|
||||
w.putBits(0b10, 2)
|
||||
// VruProfileAndSubprofile ::= CHOICE { pedestrian, bicyclistAndLightVruVehicle, motorcyclist, animal, ... }
|
||||
w.putBits(0, 1)
|
||||
w.putBits(PROFILE_BICYCLIST_INDEX, 2)
|
||||
w.putBits(SUBPROFILE_BICYCLIST, 4)
|
||||
w.putBits(SIZE_CLASS_LOW, 4)
|
||||
}
|
||||
return w.toByteArray()
|
||||
}
|
||||
|
||||
private fun latitude(deg: Double): Long =
|
||||
if (deg.isFinite()) (deg * 1e7).roundToLong().coerceIn(-900_000_000L, 900_000_000L) else 900_000_001L
|
||||
|
||||
private fun longitude(deg: Double): Long =
|
||||
if (deg.isFinite()) (deg * 1e7).roundToLong().coerceIn(-1_799_999_999L, 1_800_000_000L) else 1_800_000_001L
|
||||
|
||||
private fun semiAxisCm(accuracyM: Float?): Int {
|
||||
if (accuracyM == null || !accuracyM.isFinite() || accuracyM <= 0f) return SEMI_AXIS_UNAVAILABLE
|
||||
val cm = (accuracyM * ACCURACY_68_TO_95 * 100).roundToInt()
|
||||
return if (cm >= SEMI_AXIS_OUT_OF_RANGE) SEMI_AXIS_OUT_OF_RANGE else cm.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
private fun headingDeciDeg(deg: Double): Int =
|
||||
if (deg.isFinite()) Math.floorMod((deg * 10).roundToInt(), 3600) else WGS84_ANGLE_UNAVAILABLE
|
||||
|
||||
private fun speedCms(mps: Double): Int =
|
||||
if (mps.isFinite()) (mps * 100).roundToInt().coerceIn(0, SPEED_OUT_OF_RANGE) else 16383
|
||||
|
||||
private fun accelDeciMps2(mps2: Double?): Int =
|
||||
if (mps2 == null || !mps2.isFinite()) ACCEL_UNAVAILABLE else (mps2 * 10).roundToInt().coerceIn(-160, 160)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.hawhamburg.micr0bu.domain.cam
|
||||
|
||||
/**
|
||||
* Which station IDs belong to this phone, and therefore must never be treated as another road
|
||||
* user when a frame comes back off the air.
|
||||
*
|
||||
* ## Why this exists
|
||||
* A receiver that fails to recognise its own transmissions tracks itself: a station sitting exactly
|
||||
* on top of the ego position, moving at the ego's own speed and heading, handed to
|
||||
* [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine] as a collision partner for itself.
|
||||
* The phone's own frames can come back to it off the air, for example relayed by the CiT One's
|
||||
* radio when a phone is connected to both OBUs at once.
|
||||
*
|
||||
* ## Which IDs count
|
||||
* - The current transmit pseudonym used by [com.hawhamburg.micr0bu.service.CamTransmitLoop], and
|
||||
* the one or two it most recently replaced. The pseudonym rotates every ten minutes (see
|
||||
* [Pseudonym]), and a frame sent just before a rotation can come back just after it, so a
|
||||
* retired ID has to stay ours for a while. `PseudonymManager.ownStationIds()` supplies these.
|
||||
* - On the CiT One path, the OBU's own ID learned from obu_gnss.
|
||||
* - [BENCH_PING], but only while this phone's own pinger is running or has just stopped. See
|
||||
* [benchPingIsOurs].
|
||||
*
|
||||
* ## Why the bench ID is conditional
|
||||
* It used to count as ours unconditionally, on every phone, and that hid other phones' pings. On
|
||||
* the 2026-09-10 bench one phone pinged through an ESP32 while a second phone watched through the
|
||||
* CiT One, and the watcher silently discarded every ping as its own frame heard back, although it
|
||||
* had sent none. A fixed ID shared by every MicrOBU is only ours on the phone actually using it.
|
||||
* The one case this cannot resolve is two phones pinging at the same time: each hides the other.
|
||||
*/
|
||||
object OwnStationIds {
|
||||
|
||||
/**
|
||||
* The bench pinger's station ID. Fixed rather than derived so a ping is recognisable at a
|
||||
* glance in a capture or a log line.
|
||||
*/
|
||||
const val BENCH_PING = 999_999L
|
||||
|
||||
/**
|
||||
* The bench pinger's link-layer address, which the ESP32 writes into both the 802.11 source
|
||||
* address and the GN_ADDR MID. It is the address the firmware always used for its fixed
|
||||
* pseudonym, so bench traffic looks the same in a capture before and after the phone took
|
||||
* over the GeoNetworking identity. A fresh copy each time, so no caller can alter it for all.
|
||||
*/
|
||||
val BENCH_PING_MAC: ByteArray get() = byteArrayOf(0x02, 0x00, 0x00, 0x00, 0x00, 0x01)
|
||||
|
||||
/**
|
||||
* How long after this phone's pinger stops its pings still count as ours. A frame sent just
|
||||
* before Stop can arrive just after it, relayed through another radio. A relay takes a
|
||||
* fraction of a second, so five seconds leaves ample margin without hiding a genuine sender
|
||||
* for long.
|
||||
*/
|
||||
const val BENCH_PING_GRACE_MS = 5_000L
|
||||
|
||||
/**
|
||||
* True when station [BENCH_PING] on air is this phone's own ping: while [pingerActive], or
|
||||
* within [BENCH_PING_GRACE_MS] of [pingerStoppedAtMs]. Both times must come from one monotonic
|
||||
* clock. A [nowMs] earlier than the stop time means that clock is not monotonic after all, and
|
||||
* the ping is then not claimed.
|
||||
*/
|
||||
fun benchPingIsOurs(pingerActive: Boolean, pingerStoppedAtMs: Long?, nowMs: Long): Boolean {
|
||||
if (pingerActive) return true
|
||||
val stoppedAt = pingerStoppedAtMs ?: return false
|
||||
return nowMs - stoppedAt in 0..BENCH_PING_GRACE_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [stationId] is one this phone transmits under.
|
||||
*
|
||||
* [ownIds] is every non-bench ID currently counted as ours: the current and recently retired
|
||||
* transmit pseudonyms, plus the CiT One's own ID on that path. [benchPingIsOurs] says whether
|
||||
* [BENCH_PING] is ours right now; see the function of the same name.
|
||||
*
|
||||
* Station ID 0 is never ours: it is the "not known yet" placeholder used while the ego
|
||||
* identity is still being resolved, and matching on it would swallow real traffic.
|
||||
*/
|
||||
fun isOwn(stationId: Long, ownIds: Set<Long>, benchPingIsOurs: Boolean): Boolean {
|
||||
if (stationId == 0L) return false
|
||||
if (stationId == BENCH_PING) return benchPingIsOurs
|
||||
return stationId in ownIds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.hawhamburg.micr0bu.domain.cam
|
||||
|
||||
/**
|
||||
* A tally of this phone's own transmissions heard back off the air.
|
||||
*
|
||||
* On the ESP32-C5 path the radio receives promiscuously, so a frame the phone sent out over the
|
||||
* serial link comes back through the receive path a moment later. Those frames are deliberately
|
||||
* kept out of the detection engine, since the phone is not a road user to itself, but they are
|
||||
* worth counting: a frame completing that round trip is direct evidence that the serial link, the
|
||||
* ESP32's transmit path and its receive path all work. That is exactly what
|
||||
* [com.hawhamburg.micr0bu.service.CamPinger] exists to demonstrate.
|
||||
*
|
||||
* Compare [frames] against the pinger's own sent count to see the loop rate. Equal numbers mean
|
||||
* every ping made it out and back; a shortfall means frames are being lost on air or dropped in
|
||||
* the receive chain, which is a different fault from "nothing is being sent at all".
|
||||
*/
|
||||
data class OwnTxLoopback(
|
||||
/** How many own frames have been heard back since the tally was last reset. */
|
||||
val frames: Int,
|
||||
|
||||
/**
|
||||
* Signal strength of the most recent one, dBm, or null if no transport reported it. Retained
|
||||
* across frames that carry no reading rather than being cleared, so the figure does not blink
|
||||
* in and out on screen.
|
||||
*/
|
||||
val lastRssiDbm: Int?,
|
||||
|
||||
/** Wall-clock ms the most recent own frame was heard back. */
|
||||
val lastHeardMs: Long,
|
||||
)
|
||||
@@ -10,7 +10,7 @@ import kotlin.math.abs
|
||||
* This class only does the sensor-fusion-into-CAM-fields part, independent of the wire protocol
|
||||
* to the ESP32-C5. Live flow, driven by [com.hawhamburg.micr0bu.service.CamTransmitLoop]:
|
||||
*
|
||||
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
|
||||
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `Esp32Link` (write).
|
||||
*
|
||||
* Position/speed/heading come straight from GNSS. Yaw rate is derived from the gyroscope's
|
||||
* z-axis reading (rotation about the vertical axis while the phone is roughly flat/mounted
|
||||
@@ -24,10 +24,10 @@ object PhoneCamBuilder {
|
||||
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
|
||||
* Android's convention is counter-clockwise around +Z; converted to the clockwise-positive
|
||||
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
|
||||
* @param stationId this device's own station ID, from
|
||||
* [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences.getOrCreateOwnStationId] — a
|
||||
* persisted random value, not a placeholder. Receivers use it to track this station across
|
||||
* successive CAMs, so it must be stable for the life of the install and distinct per device.
|
||||
* @param stationId the station ID to transmit under: the current pseudonym from
|
||||
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager], or the bench pinger's fixed ID.
|
||||
* Receivers track a station across successive CAMs by this ID, which is why it only ever
|
||||
* changes in a coordinated rotation together with the link-layer address.
|
||||
* @param longitudinalAccelMps2 along-track acceleration, signed (positive = accelerating).
|
||||
* Derived from successive GNSS speed samples by [com.hawhamburg.micr0bu.service.CamTransmitLoop]
|
||||
* rather than from the accelerometer: CAM wants acceleration along the direction of travel,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.hawhamburg.micr0bu.domain.cam
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* The identity this phone transmits under on the ESP32-C5 path: the CAM stationID, and the
|
||||
* link-layer address the firmware writes into both the GeoNetworking GN_ADDR and the 802.11
|
||||
* source address.
|
||||
*
|
||||
* ## Why the two change together
|
||||
* A pseudonym only makes a station harder to follow if every identifier on the frame changes at
|
||||
* the same moment. Rotating the address while keeping the stationID, or the reverse, leaves the
|
||||
* unchanged one as a stable handle, so a receiver loses nothing and the rotation buys nothing.
|
||||
* Holding both in one value that is only ever replaced whole makes a partial rotation impossible
|
||||
* to express.
|
||||
*
|
||||
* ## Why every [ROTATION_INTERVAL_MS]
|
||||
* Real ITS stacks change pseudonym every few minutes, 5 to 15 being typical, and the CiT One was
|
||||
* seen rotating its station ID twice within one bench session. Ten minutes sits in that range.
|
||||
*
|
||||
* ## A limit worth stating
|
||||
* Nothing this app transmits is signed (there is no ETSI TS 103 097 security), so rotation gives
|
||||
* nominal unlinkability at best: an unsigned frame's content can still be correlated across a
|
||||
* change. This is the correct behaviour to build on, not a privacy guarantee.
|
||||
*/
|
||||
data class Pseudonym(
|
||||
val stationId: Long,
|
||||
/** Six bytes, locally administered and unicast. See [generate]. */
|
||||
val mac: ByteArray,
|
||||
/** Wall-clock ms this pseudonym was created, for [isExpired]. */
|
||||
val createdAtMs: Long,
|
||||
) {
|
||||
init {
|
||||
require(mac.size == 6) { "a MAC is 6 bytes, got ${mac.size}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* True once this pseudonym has been in use for [intervalMs], or if the clock has moved back
|
||||
* past its creation time. The second case rotates rather than trusting a creation time that
|
||||
* now lies in the future, which would otherwise pin one identity until the clock caught up.
|
||||
*/
|
||||
fun isExpired(nowMs: Long, intervalMs: Long = ROTATION_INTERVAL_MS): Boolean =
|
||||
nowMs < createdAtMs || nowMs - createdAtMs >= intervalMs
|
||||
|
||||
// Generated equals/hashCode would compare the MAC array by identity, so two pseudonyms with
|
||||
// the same bytes would compare unequal.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Pseudonym) return false
|
||||
return stationId == other.stationId && createdAtMs == other.createdAtMs &&
|
||||
mac.contentEquals(other.mac)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int =
|
||||
31 * (31 * stationId.hashCode() + mac.contentHashCode()) + createdAtMs.hashCode()
|
||||
|
||||
companion object {
|
||||
const val ROTATION_INTERVAL_MS = 10 * 60_000L
|
||||
|
||||
/**
|
||||
* A fresh identity. StationID is INTEGER(0..4294967295); 0 is avoided because it is the
|
||||
* "not yet known" placeholder elsewhere in this app, and [OwnStationIds.BENCH_PING] is
|
||||
* avoided so a rider can never be mistaken for the bench pinger.
|
||||
*
|
||||
* The MAC is random with the locally-administered bit set and the group bit clear. A
|
||||
* source address must never be a group address, and a random one must not claim a real
|
||||
* vendor's OUI. [OwnStationIds.BENCH_PING_MAC] is excluded for the same reason as the ID.
|
||||
*/
|
||||
fun generate(nowMs: Long, random: Random = Random.Default): Pseudonym {
|
||||
var stationId: Long
|
||||
do {
|
||||
stationId = random.nextLong(1L, 0xFFFF_FFFEL)
|
||||
} while (stationId == OwnStationIds.BENCH_PING)
|
||||
|
||||
var mac: ByteArray
|
||||
do {
|
||||
mac = random.nextBytes(6)
|
||||
mac[0] = ((mac[0].toInt() and 0xFC) or 0x02).toByte()
|
||||
} while (mac.contentEquals(OwnStationIds.BENCH_PING_MAC))
|
||||
|
||||
return Pseudonym(stationId, mac, nowMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,26 @@ package com.hawhamburg.micr0bu.domain.detection
|
||||
* All detection thresholds in one place.
|
||||
*
|
||||
* Pass a custom instance to [EventDetector] to tune behaviour without
|
||||
* recompiling. The defaults match the Phase A specification.
|
||||
* recompiling.
|
||||
*
|
||||
* **These defaults are the values the app actually runs.** They are *not* the
|
||||
* Phase A specification figures. Phase A specified a more sensitive detector;
|
||||
* running it on a real bicycle over-triggered, so every signal threshold was
|
||||
* raised and every sustain requirement lengthened. Those tuned values used to
|
||||
* live as literals in `TripRecordingService`'s constructor, which meant the
|
||||
* unit tests exercised the Phase A defaults and nothing exercised what shipped.
|
||||
* They are the defaults now so that there is exactly one configuration.
|
||||
*
|
||||
* The original Phase A figures, kept for provenance:
|
||||
* `brakingSpeedDropThreshold` 0.5, `brakingAccelStdDevThreshold` 1.2,
|
||||
* `brakingSustainedFrames` 15, `turningGyroMeanThreshold` 0.4,
|
||||
* `turningBearingChangeThreshold` 10.0, `turningSustainedFrames` 20,
|
||||
* `stoppingSpeedThreshold` 0.5, `stoppingFrames` 100,
|
||||
* `stoppingAccelStdDevThreshold` 0.15.
|
||||
*
|
||||
* What motivated each change was never recorded, and the effect on the
|
||||
* false-positive and false-negative rates has never been measured. That
|
||||
* remains open; sensitivity is deliberately unchanged by this consolidation.
|
||||
*/
|
||||
data class DetectionConfig(
|
||||
|
||||
@@ -17,42 +36,51 @@ data class DetectionConfig(
|
||||
* Minimum speed drop (m/s) from the reference speed at braking onset for
|
||||
* a frame to qualify as a braking frame.
|
||||
*/
|
||||
val brakingSpeedDropThreshold: Double = 0.5,
|
||||
val brakingSpeedDropThreshold: Double = 1.0,
|
||||
|
||||
/** Minimum accel std-dev (m/s²) required for a frame to count as braking. */
|
||||
val brakingAccelStdDevThreshold: Double = 1.2,
|
||||
val brakingAccelStdDevThreshold: Double = 1.8,
|
||||
|
||||
/** Consecutive braking frames required before an event is emitted. */
|
||||
val brakingSustainedFrames: Int = 15,
|
||||
val brakingSustainedFrames: Int = 25,
|
||||
|
||||
/**
|
||||
* Peak speed-drop rate (m/s per GPS update ≈ m/s²) above which the braking
|
||||
* confidence is upgraded from MEDIUM to HIGH.
|
||||
* Peak *cumulative* speed drop (m/s) from the onset reference speed above
|
||||
* which the braking confidence is upgraded from MEDIUM to HIGH.
|
||||
*
|
||||
* This is a total drop for the episode, not a rate. It was previously
|
||||
* named `brakingHighConfidenceRate` and documented as "m/s per GPS update
|
||||
* ≈ m/s²", but the quantity it is compared against in
|
||||
* [EventDetector.detectBraking] has always been the cumulative drop, which
|
||||
* grows for as long as the episode lasts. The name was wrong, not the
|
||||
* comparison: "the rider lost more than this much speed in one braking
|
||||
* episode" is a coherent criterion, so the name was corrected to match the
|
||||
* behaviour rather than the other way round. Detector output is unchanged.
|
||||
*/
|
||||
val brakingHighConfidenceRate: Double = 1.5,
|
||||
val brakingHighConfidencePeakDrop: Double = 1.5,
|
||||
|
||||
// ── TURNING ───────────────────────────────────────────────────────────────
|
||||
/** Minimum gyro mean (rad/s) required for a frame to qualify as turning. */
|
||||
val turningGyroMeanThreshold: Double = 0.4,
|
||||
val turningGyroMeanThreshold: Double = 0.6,
|
||||
|
||||
/** Bearing-change rate (°/s) that must be exceeded when speed is above the
|
||||
* minimum threshold for a HIGH-confidence turning confirmation. */
|
||||
val turningBearingChangeThreshold: Double = 10.0,
|
||||
val turningBearingChangeThreshold: Double = 15.0,
|
||||
|
||||
/** GPS speed (m/s) above which the bearing-change criterion is enforced. */
|
||||
val turningMinSpeedThreshold: Double = 2.0,
|
||||
|
||||
/** Consecutive turning frames required before an event is emitted. */
|
||||
val turningSustainedFrames: Int = 20,
|
||||
val turningSustainedFrames: Int = 30,
|
||||
|
||||
// ── STOPPING ─────────────────────────────────────────────────────────────
|
||||
/** GPS speed (m/s) below which a frame is considered a potential stop. */
|
||||
val stoppingSpeedThreshold: Double = 0.5,
|
||||
val stoppingSpeedThreshold: Double = 0.3,
|
||||
|
||||
/** Consecutive stop frames required (> this value) before an event is emitted.
|
||||
* At 50 Hz, 100 frames ≈ 2 s. */
|
||||
val stoppingFrames: Int = 100,
|
||||
* At 50 Hz, 150 frames ≈ 3 s. */
|
||||
val stoppingFrames: Int = 150,
|
||||
|
||||
/** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */
|
||||
val stoppingAccelStdDevThreshold: Double = 0.15,
|
||||
val stoppingAccelStdDevThreshold: Double = 0.10,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,16 @@ import kotlin.math.abs
|
||||
* to [events] (a hot [SharedFlow]). Debounce is implemented with
|
||||
* consecutive-frame counters, not timers.
|
||||
*
|
||||
* **Who consumes this.** The detector's live consumer is the CAM transmit-rate
|
||||
* policy: [com.hawhamburg.micr0bu.service.TripRecordingService] forwards every
|
||||
* emitted event to
|
||||
* [com.hawhamburg.micr0bu.service.CamTransmitLoop.onDetectedEvent], which
|
||||
* raises the CAM rate from 1 Hz to the elevated rate for a hold window so that
|
||||
* nearby stations get denser updates *through* a manoeuvre rather than only at
|
||||
* the instant it was detected. These thresholds therefore govern a V2X
|
||||
* behaviour, not a statistic. Events are also persisted per trip for offline
|
||||
* analysis and CSV export, but nothing in the UI displays them.
|
||||
*
|
||||
* GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and
|
||||
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
|
||||
* the detector compares speed against a *reference speed at braking onset*
|
||||
@@ -37,7 +47,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
||||
private var brakingFrames = 0
|
||||
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
|
||||
private var brakingStartTime = 0L
|
||||
private var peakBrakingDrop = 0.0 // peak speed drop observed during this window
|
||||
private var peakBrakingDrop = 0.0 // peak CUMULATIVE drop from onset speed, m/s (not a rate)
|
||||
private var peakAccelBraking = 0.0
|
||||
|
||||
// ── Turning state ─────────────────────────────────────────────────────────
|
||||
@@ -124,7 +134,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
||||
|
||||
if (brakingFrames == config.brakingSustainedFrames) {
|
||||
val confidence =
|
||||
if (peakBrakingDrop > config.brakingHighConfidenceRate) Confidence.HIGH
|
||||
if (peakBrakingDrop > config.brakingHighConfidencePeakDrop) Confidence.HIGH
|
||||
else Confidence.MEDIUM
|
||||
|
||||
_events.tryEmit(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.hawhamburg.micr0bu.domain.vam
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* When to send an individual VAM: ETSI TS 103 300-3 V2.3.1 clause 6.4, items 1 to 4, with the
|
||||
* recommended values of Tables 16 and 17 — the same rules the colleague's reference VBS applies
|
||||
* (microbu-esp32c5/station-link/python/microbu_link/vbs.py, `VbsLite.due`). No clustering, no
|
||||
* redundancy mitigation, T_GenVam fixed at its minimum (no DCC input).
|
||||
*
|
||||
* Pure and clock-free (callers pass times in ms), so it can be tested without Android.
|
||||
*/
|
||||
class VamGenerationRules {
|
||||
|
||||
data class Kinematics(val latitude: Double, val longitude: Double, val speedMps: Double, val headingDeg: Double)
|
||||
|
||||
private var last: Kinematics? = null
|
||||
private var lastAtMs = 0L
|
||||
private var lastLowFrequencyAtMs: Long? = null
|
||||
|
||||
/** True if a VAM is due at [nowMs] for the VRU now at [now]. */
|
||||
fun due(nowMs: Long, now: Kinematics): Boolean {
|
||||
val previous = last ?: return true
|
||||
val elapsed = nowMs - lastAtMs
|
||||
if (elapsed < T_GEN_VAM_MIN_MS) return false
|
||||
if (elapsed > T_GEN_VAM_MAX_MS) return true // item 1
|
||||
if (GeoMath.haversineMeters(now.latitude, now.longitude, previous.latitude, previous.longitude) >
|
||||
MIN_POSITION_CHANGE_M) return true // item 2
|
||||
if (abs(now.speedMps - previous.speedMps) > MIN_SPEED_CHANGE_MPS) return true // item 3
|
||||
val headingDelta = abs(((now.headingDeg - previous.headingDeg + 180.0) % 360.0 + 360.0) % 360.0 - 180.0)
|
||||
return headingDelta > MIN_ORIENTATION_CHANGE_DEG // item 4
|
||||
}
|
||||
|
||||
/** True if the VAM sent at [nowMs] carries the low-frequency container (first VAM, then every T_GenVamLFMin). */
|
||||
fun includeLowFrequency(nowMs: Long): Boolean =
|
||||
lastLowFrequencyAtMs.let { it == null || nowMs - it >= T_GEN_VAM_LF_MIN_MS }
|
||||
|
||||
/** Records that a VAM went out at [nowMs] for [sent], with or without the low-frequency container. */
|
||||
fun onSent(nowMs: Long, sent: Kinematics, withLowFrequency: Boolean) {
|
||||
last = sent
|
||||
lastAtMs = nowMs
|
||||
if (withLowFrequency) lastLowFrequencyAtMs = nowMs
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
last = null
|
||||
lastAtMs = 0L
|
||||
lastLowFrequencyAtMs = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val T_GEN_VAM_MIN_MS = 100L
|
||||
const val T_GEN_VAM_MAX_MS = 5_000L
|
||||
const val T_GEN_VAM_LF_MIN_MS = 2_000L
|
||||
const val MIN_POSITION_CHANGE_M = 4.0
|
||||
const val MIN_SPEED_CHANGE_MPS = 0.5
|
||||
const val MIN_ORIENTATION_CHANGE_DEG = 4.0
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingIts
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -17,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -45,7 +53,8 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class CamPinger @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val esp32Link: Esp32Link,
|
||||
private val prefs: ObuHardwarePreferences,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -67,6 +76,20 @@ class CamPinger @Inject constructor(
|
||||
/** False while the pinger is running but has no GNSS fix yet — nothing is being transmitted. */
|
||||
val hasFix: StateFlow<Boolean> = _hasFix.asStateFlow()
|
||||
|
||||
/** [SystemClock.elapsedRealtime] when the pinger last stopped, or null if it never ran. */
|
||||
@Volatile private var stoppedAtElapsedMs: Long? = null
|
||||
|
||||
/**
|
||||
* True while station [OwnStationIds.BENCH_PING] on air is this phone's own ping: while the
|
||||
* pinger runs, and briefly after it stops, so a frame sent just before Stop is not taken for a
|
||||
* stranger. Uses elapsed realtime, so changing the wall clock cannot move the window.
|
||||
*
|
||||
* Otherwise that ID belongs to someone else, typically another MicrOBU phone pinging on the
|
||||
* same bench, and must be shown like any remote station. See [OwnStationIds.benchPingIsOurs].
|
||||
*/
|
||||
fun benchPingIsOurs(): Boolean =
|
||||
OwnStationIds.benchPingIsOurs(_isActive.value, stoppedAtElapsedMs, SystemClock.elapsedRealtime())
|
||||
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
_sentCount.value = 0
|
||||
@@ -86,13 +109,18 @@ class CamPinger @Inject constructor(
|
||||
_hasFix.value = gnss != null
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(
|
||||
gnss = gnss,
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource.
|
||||
gnss = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp)),
|
||||
gyroZRadPerSec = latestGyroZ,
|
||||
stationId = PING_STATION_ID,
|
||||
stationId = OwnStationIds.BENCH_PING,
|
||||
longitudinalAccelMps2 = longitudinalAccel(gnss),
|
||||
)
|
||||
val bytes = codec.encodeCam(cam)
|
||||
if (usbSerialTransport.sendCamTx(bytes)) {
|
||||
// Fixed bench identity on every layer, the link-layer address included, so a ping
|
||||
// stays recognisable in a capture and never rotates.
|
||||
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
|
||||
val signed = prefs.signOutgoingFlow.first()
|
||||
if (esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes, pv, gnss.accuracyM, signed))) {
|
||||
_sentCount.update { it + 1 }
|
||||
}
|
||||
}
|
||||
@@ -119,6 +147,9 @@ class CamPinger @Inject constructor(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
// Only a real stop opens the grace window. stop() is also called unconditionally on
|
||||
// teardown, and that must not make a phone that never pinged claim 999999 for a while.
|
||||
if (_isActive.value) stoppedAtElapsedMs = SystemClock.elapsedRealtime()
|
||||
job?.cancel()
|
||||
job = null
|
||||
_isActive.value = false
|
||||
@@ -131,11 +162,10 @@ class CamPinger @Inject constructor(
|
||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||
private const val MAX_ACCEL_DT_SEC = 3.0
|
||||
|
||||
/**
|
||||
* Recognizable station id, deliberately distinct from the persisted real one
|
||||
* [CamTransmitLoop] uses, so manual bench pings stay identifiable in captures and can't be
|
||||
* confused with the recording-driven stream if both happen to run at once.
|
||||
*/
|
||||
private const val PING_STATION_ID = 999_999L
|
||||
// The station id these pings go out under lives in
|
||||
// [com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING], not here. It is not a
|
||||
// private detail of this class: the ESP32 hears these frames back off the air, so the
|
||||
// receive path has to recognise the same value, and a second copy of it is exactly how
|
||||
// the two sides would drift apart.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,22 @@ package com.hawhamburg.micr0bu.service
|
||||
|
||||
import android.content.Context
|
||||
import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingIts
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamContent
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamTransmitConfig
|
||||
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -42,13 +50,21 @@ import javax.inject.Singleton
|
||||
* [com.hawhamburg.micr0bu.domain.detection.EventDetector] stream that already drives trip event
|
||||
* logging). Both rate figures are placeholders pending real-world tuning, per
|
||||
* [CamTransmitConfig]'s own disclaimer.
|
||||
*
|
||||
* ## CAM or VAM
|
||||
* Settings chooses what goes out ([OutgoingMessage]). CAM follows the rate policy above. VAM is
|
||||
* checked every [VAM_TICK_MS] against the generation rules of TS 103 300-3 clause 6.4
|
||||
* ([VamGenerationRules]) and sent when one fires, from the same GNSS fix, pseudonym and position
|
||||
* vector a CAM would use. Whether either is signed is the "Sign outgoing messages" setting; the
|
||||
* micrOBU does the signing ([Esp32Link]).
|
||||
*/
|
||||
@Singleton
|
||||
class CamTransmitLoop @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val esp32Link: Esp32Link,
|
||||
private val codec: RealAsn1UperCodec,
|
||||
private val pseudonymManager: PseudonymManager,
|
||||
) {
|
||||
private val config = CamTransmitConfig()
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
@@ -63,13 +79,9 @@ class CamTransmitLoop @Inject constructor(
|
||||
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
||||
@Volatile private var previousGnss: GnssReading? = null
|
||||
|
||||
/**
|
||||
* Own station id for the ESP32-C5 path, loaded once per [start] from
|
||||
* [ObuHardwarePreferences.getOrCreateOwnStationId]. 0 means "not loaded yet" — the loop waits
|
||||
* for the real value rather than beaconing as station 0, which would be indistinguishable
|
||||
* from every other MicrOBU to any receiver.
|
||||
*/
|
||||
@Volatile var stationId: Long = 0L
|
||||
@Volatile private var outgoing: OutgoingMessage = OutgoingMessage.CAM
|
||||
@Volatile private var signOutgoing: Boolean = true
|
||||
private val vamRules = VamGenerationRules()
|
||||
|
||||
/**
|
||||
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
||||
@@ -89,8 +101,8 @@ class CamTransmitLoop @Inject constructor(
|
||||
if (job?.isActive == true) return
|
||||
elevatedUntilMs = 0L
|
||||
previousGnss = null
|
||||
vamRules.reset()
|
||||
job = scope.launch {
|
||||
stationId = obuHardwarePrefs.getOrCreateOwnStationId()
|
||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||
runTransmitLoop()
|
||||
@@ -107,18 +119,66 @@ class CamTransmitLoop @Inject constructor(
|
||||
private suspend fun runTransmitLoop() = coroutineScope {
|
||||
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
|
||||
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
|
||||
launch { obuHardwarePrefs.signOutgoingFlow.collect { signOutgoing = it } }
|
||||
launch {
|
||||
obuHardwarePrefs.outgoingMessageFlow.collect {
|
||||
if (it != outgoing) vamRules.reset()
|
||||
outgoing = it
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val gnss = latestGnss
|
||||
if (gnss != null) {
|
||||
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId, longitudinalAccel(gnss))
|
||||
val bytes = codec.encodeCam(cam)
|
||||
usbSerialTransport.sendCamTx(bytes)
|
||||
when (outgoing) {
|
||||
OutgoingMessage.CAM -> sendCam(gnss)
|
||||
OutgoingMessage.VAM -> sendVamIfDue(gnss)
|
||||
}
|
||||
}
|
||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
||||
delay(if (outgoing == OutgoingMessage.VAM) VAM_TICK_MS else (1000.0 / currentRateHz(gnss)).toLong())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendCam(gnss: GnssReading) {
|
||||
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||
// rotation fall cleanly between two frames instead of inside one.
|
||||
val pseudonym = pseudonymManager.current()
|
||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
||||
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||
val bytes = codec.encodeCam(cam)
|
||||
esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes,
|
||||
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||
}
|
||||
|
||||
private suspend fun sendVamIfDue(gnss: GnssReading) {
|
||||
val now = System.currentTimeMillis()
|
||||
val kinematics = VamGenerationRules.Kinematics(gnss.latitude, gnss.longitude,
|
||||
gnss.speedMs.toDouble(), gnss.bearingDeg.toDouble())
|
||||
if (!vamRules.due(now, kinematics)) return
|
||||
val pseudonym = pseudonymManager.current()
|
||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||
// The CAM view of this fix is built only for its position vector, so the GN header of a VAM
|
||||
// follows exactly the rules a CAM's does. It is not transmitted.
|
||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||
val withLowFrequency = vamRules.includeLowFrequency(now)
|
||||
val bytes = VamUperCodec.encode(VamContent(
|
||||
stationId = pseudonym.stationId,
|
||||
timestamp = cam.timestamp,
|
||||
latitude = cam.latitude,
|
||||
longitude = cam.longitude,
|
||||
accuracyM = gnss.accuracyM,
|
||||
speedMps = cam.speedMps,
|
||||
headingDeg = cam.headingDeg,
|
||||
accelerationMps2 = cam.accelerationMps2,
|
||||
includeLowFrequency = withLowFrequency,
|
||||
))
|
||||
val handedOver = esp32Link.send(OutgoingIts(OutgoingMessage.VAM, bytes,
|
||||
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||
if (handedOver) vamRules.onSent(now, kinematics, withLowFrequency)
|
||||
}
|
||||
|
||||
/**
|
||||
* Along-track acceleration in m/s², from the change in GNSS speed since the previous fix.
|
||||
*
|
||||
@@ -157,6 +217,9 @@ class CamTransmitLoop @Inject constructor(
|
||||
companion object {
|
||||
private const val ELEVATED_HOLD_MS = 5_000L
|
||||
|
||||
/** How often VAM generation rules are checked: T_GenVamMin, TS 103 300-3 Table 16. */
|
||||
private const val VAM_TICK_MS = VamGenerationRules.T_GEN_VAM_MIN_MS
|
||||
|
||||
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
|
||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||
|
||||
|
||||
@@ -26,9 +26,7 @@ import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.TripRepository
|
||||
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
|
||||
import com.hawhamburg.micr0bu.domain.detection.EventDetector
|
||||
import com.hawhamburg.micr0bu.domain.detection.EventType
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -108,19 +106,10 @@ class TripRecordingService : Service() {
|
||||
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
|
||||
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
|
||||
private var v2xLoggingJob: Job? = null
|
||||
private val detector = EventDetector(
|
||||
DetectionConfig(
|
||||
brakingSpeedDropThreshold = 1.0,
|
||||
brakingAccelStdDevThreshold = 1.8,
|
||||
brakingSustainedFrames = 25,
|
||||
turningGyroMeanThreshold = 0.6,
|
||||
turningBearingChangeThreshold = 15.0,
|
||||
turningSustainedFrames = 30,
|
||||
stoppingSpeedThreshold = 0.3,
|
||||
stoppingFrames = 150,
|
||||
stoppingAccelStdDevThreshold = 0.10,
|
||||
)
|
||||
)
|
||||
// These nine thresholds used to be overridden here; they are now the DetectionConfig
|
||||
// defaults, so there is one configuration and the unit tests exercise it. Behaviour is
|
||||
// unchanged - see DetectionConfig's KDoc.
|
||||
private val detector = EventDetector()
|
||||
|
||||
// ── Sensor fusion state ───────────────────────────────────────────────────
|
||||
|
||||
@@ -153,10 +142,11 @@ class TripRecordingService : Service() {
|
||||
private val gpsTrackBuilder = StringBuilder("[")
|
||||
private var gpsPointCount = 0
|
||||
|
||||
// Event counts
|
||||
private var brakingCount = 0
|
||||
private var turningCount = 0
|
||||
private var stoppingCount = 0
|
||||
// Number of manoeuvres the detector fired during this trip. The only thing kept about
|
||||
// them: it fills the trips.eventCount column, which predates this change and cannot be
|
||||
// dropped without rebuilding the trips table. See EventDetector's KDoc for why the
|
||||
// detector still runs at all.
|
||||
private var detectedEventCount = 0
|
||||
|
||||
// ── SensorEventListener ───────────────────────────────────────────────────
|
||||
|
||||
@@ -253,9 +243,7 @@ class TripRecordingService : Service() {
|
||||
).also { it.acquire() }
|
||||
|
||||
detector.reset()
|
||||
brakingCount = 0
|
||||
turningCount = 0
|
||||
stoppingCount = 0
|
||||
detectedEventCount = 0
|
||||
distanceMetres = 0f
|
||||
prevLat = Double.NaN
|
||||
prevLon = Double.NaN
|
||||
@@ -273,35 +261,20 @@ class TripRecordingService : Service() {
|
||||
isRecording = true,
|
||||
currentTripId = currentTripId,
|
||||
elapsedSeconds = 0L,
|
||||
brakingCount = 0,
|
||||
turningCount = 0,
|
||||
stoppingCount = 0,
|
||||
currentSpeedMs = 0f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect detector events and persist them
|
||||
// Collect detector events. The CAM transmit-rate policy is their only consumer:
|
||||
// detected manoeuvres are not persisted, exported, or displayed.
|
||||
serviceScope.launch {
|
||||
detector.events.collect { event ->
|
||||
detector.events.collect { _ ->
|
||||
if (currentTripId < 0) return@collect
|
||||
repository.insertEvent(currentTripId, event)
|
||||
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
|
||||
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
|
||||
camTransmitLoop.onDetectedEvent()
|
||||
when (event.type) {
|
||||
EventType.BRAKING -> brakingCount++
|
||||
EventType.TURNING -> turningCount++
|
||||
EventType.STOPPING -> stoppingCount++
|
||||
}
|
||||
TripServiceBus.update {
|
||||
copy(
|
||||
brakingCount = this@TripRecordingService.brakingCount,
|
||||
turningCount = this@TripRecordingService.turningCount,
|
||||
stoppingCount = this@TripRecordingService.stoppingCount,
|
||||
)
|
||||
}
|
||||
updateNotification()
|
||||
detectedEventCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +329,7 @@ class TripRecordingService : Service() {
|
||||
v2xLoggingJob = null
|
||||
|
||||
val endTime = System.currentTimeMillis()
|
||||
val totalEvents = brakingCount + turningCount + stoppingCount
|
||||
val totalEvents = detectedEventCount
|
||||
|
||||
// Close GPS track JSON
|
||||
gpsTrackBuilder.append("]")
|
||||
@@ -454,10 +427,7 @@ class TripRecordingService : Service() {
|
||||
private fun buildNotification(elapsedSeconds: Long) =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Recording trip")
|
||||
.setContentText(
|
||||
"⏱ ${formatElapsed(elapsedSeconds)} · " +
|
||||
"🚨 $brakingCount 🔄 $turningCount 🛑 $stoppingCount"
|
||||
)
|
||||
.setContentText("⏱ ${formatElapsed(elapsedSeconds)}")
|
||||
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
|
||||
@@ -17,9 +17,6 @@ object TripServiceBus {
|
||||
val isRecording: Boolean = false,
|
||||
val currentTripId: Long = -1L,
|
||||
val elapsedSeconds: Long = 0L,
|
||||
val brakingCount: Int = 0,
|
||||
val turningCount: Int = 0,
|
||||
val stoppingCount: Int = 0,
|
||||
val currentSpeedMs: Float = 0f,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.compose.material.icons.filled.GpsOff
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.material.icons.filled.SensorsOff
|
||||
import androidx.compose.material.icons.filled.Usb
|
||||
import androidx.compose.material.icons.filled.BluetoothDisabled
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.UsbOff
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.WifiOff
|
||||
@@ -39,7 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
|
||||
private val GreenActive = Color(0xFF4CAF50)
|
||||
@@ -53,7 +55,9 @@ fun StatusTopBar(
|
||||
state: SensorUiState,
|
||||
mqttConnectionState: MqttConnectionState,
|
||||
isEsp32: Boolean = false,
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
||||
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
|
||||
esp32Bluetooth: Boolean = false,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
@@ -93,7 +97,7 @@ fun StatusTopBar(
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState)
|
||||
ObuStatusIcon(mqttConnectionState, isEsp32, esp32LinkState, esp32Bluetooth = esp32Bluetooth)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -106,20 +110,21 @@ fun StatusTopBar(
|
||||
* OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One
|
||||
* reaches the phone over MQTT (Wi-Fi / USB-C tethering), the ESP32-C5 over a USB-serial link with
|
||||
* no broker at all - so on that path [mqttConnectionState] is permanently DISCONNECTED and would
|
||||
* report the OBU as offline while CAMs were streaming in. Uses a USB glyph there rather than the
|
||||
* Wi-Fi one, since that is literally what the connection is.
|
||||
* report the OBU as offline while CAMs were streaming in. Uses a USB or Bluetooth glyph there
|
||||
* rather than the Wi-Fi one, since that is literally what the connection is.
|
||||
*/
|
||||
@Composable
|
||||
private fun ObuStatusIcon(
|
||||
mqttState: MqttConnectionState,
|
||||
isEsp32: Boolean,
|
||||
usbSerialState: UsbSerialState,
|
||||
esp32LinkState: Esp32LinkState,
|
||||
esp32Bluetooth: Boolean,
|
||||
) {
|
||||
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState
|
||||
val state = if (isEsp32) esp32LinkState.asConnectionState() else mqttState
|
||||
val linkUp = state == MqttConnectionState.CONNECTED || state == MqttConnectionState.CONNECTING
|
||||
val icon = when {
|
||||
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb
|
||||
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb
|
||||
isEsp32 -> Icons.Default.UsbOff
|
||||
isEsp32 && esp32Bluetooth -> if (linkUp) Icons.Default.Bluetooth else Icons.Default.BluetoothDisabled
|
||||
isEsp32 -> if (linkUp) Icons.Default.Usb else Icons.Default.UsbOff
|
||||
state == MqttConnectionState.CONNECTED ||
|
||||
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
||||
else -> Icons.Default.WifiOff
|
||||
@@ -194,10 +199,10 @@ private fun RecordingPulse() {
|
||||
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour
|
||||
* and pulse logic already speaks, so one indicator serves both transports.
|
||||
*/
|
||||
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
UsbSerialState.DEVICE_ATTACHED,
|
||||
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
UsbSerialState.ERROR -> MqttConnectionState.ERROR
|
||||
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
Esp32LinkState.DEVICE_ATTACHED,
|
||||
Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
Esp32LinkState.ERROR -> MqttConnectionState.ERROR
|
||||
Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ sealed class Screen(val route: String, val labelRes: Int) {
|
||||
data object Connection : Screen("connection", R.string.nav_connection)
|
||||
data object Map : Screen("map", R.string.map_title)
|
||||
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
|
||||
/** Full-screen V2X live map, opened from the V2X Monitor's map button. */
|
||||
data object V2xMap : Screen("v2x_map", R.string.v2x_map_title)
|
||||
|
||||
// Phase A — Trip Recording
|
||||
data object TripHistory : Screen("trip_history", R.string.nav_trips)
|
||||
@@ -76,6 +78,14 @@ private fun Screen.ownsRoute(route: String?): Boolean {
|
||||
return when (this) {
|
||||
Screen.Settings -> route.startsWith("settings/")
|
||||
Screen.TripHistory -> route.startsWith("trip_review")
|
||||
// Connection, Map and Sensors are only reachable from the Dashboard's own cards, and
|
||||
// the session log only from Record, so those tabs stay lit while the rider is inside
|
||||
// one of them. Without this the bar goes blank on screens that clearly belong to a tab.
|
||||
Screen.Dashboard -> route == Screen.Connection.route ||
|
||||
route == Screen.Map.route ||
|
||||
route == Screen.Sensors.route
|
||||
Screen.Record -> route == Screen.Log.route
|
||||
Screen.MqttViewer -> route == Screen.V2xMap.route
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -87,23 +97,26 @@ fun BottomNavBar(navController: NavController) {
|
||||
|
||||
NavigationBar {
|
||||
bottomNavItems.forEach { screen ->
|
||||
val onThisTab = screen.ownsRoute(currentRoute)
|
||||
NavigationBarItem(
|
||||
selected = onThisTab,
|
||||
selected = screen.ownsRoute(currentRoute),
|
||||
onClick = {
|
||||
if (onThisTab && currentRoute != screen.route) {
|
||||
// Already inside this tab, just deeper in: pop back to the tab's own
|
||||
// screen. Navigating instead would restoreState the saved back stack and
|
||||
// land straight back on the sub-screen, which reads as the tap doing
|
||||
// nothing - the reason Settings > Connection could not be left by tapping
|
||||
// Settings. Leaves the rest of the stack intact, so Back still works
|
||||
// exactly as before.
|
||||
navController.popBackStack(screen.route, inclusive = false)
|
||||
} else {
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(Screen.Dashboard.route) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
// One rule for every tab, including the one already selected: a tap lands on
|
||||
// that tab's own screen. Nothing happens only when we are already on it.
|
||||
if (currentRoute != screen.route) {
|
||||
// Prefer a pop when this tab's screen is still on the back stack. That is
|
||||
// exactly what Back or a back swipe would do, so tapping Settings from
|
||||
// Settings > Connection, or Dashboard from the Map, behaves identically
|
||||
// whichever way the rider asks for it. popBackStack reports false when the
|
||||
// screen is not on the stack, which is the case for a genuine tab switch.
|
||||
if (!navController.popBackStack(screen.route, inclusive = false)) {
|
||||
// No saveState/restoreState here. The graph is flat, so a restored
|
||||
// back stack brings back the sub-screen the rider was on rather than
|
||||
// the tab's own screen, which is the opposite of what the tap asked
|
||||
// for. Tab state that matters lives in the view models anyway.
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -44,7 +44,9 @@ import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||
|
||||
private val UsbGreen = Color(0xFF4CAF50)
|
||||
@@ -65,7 +67,11 @@ fun ConnectionSetupScreen(
|
||||
val activeTransport by viewModel.activeTransport.collectAsState()
|
||||
val mqttPrefs by viewModel.mqttPrefs.collectAsState()
|
||||
val obuHardware by viewModel.obuHardware.collectAsState()
|
||||
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||
val esp32LinkState by viewModel.esp32LinkState.collectAsState()
|
||||
val esp32Transport by viewModel.esp32Transport.collectAsState()
|
||||
val esp32Detail by viewModel.esp32Detail.collectAsState()
|
||||
val stationStatus by viewModel.stationStatus.collectAsState()
|
||||
val signOutgoing by viewModel.signOutgoing.collectAsState()
|
||||
|
||||
val isConnected = connectionState == MqttConnectionState.CONNECTED
|
||||
val isConnecting = connectionState == MqttConnectionState.CONNECTING
|
||||
@@ -229,22 +235,22 @@ fun ConnectionSetupScreen(
|
||||
}
|
||||
} else {
|
||||
// ── ESP32-C5 real connection card (Phase 03) ────────────────────────
|
||||
// Backed by UsbSerialTransport (native USB Serial/JTAG CDC-ACM link) - see that
|
||||
// class's KDoc for the VID/PID (0x303A/0x1001) and native-vs-UART-bridge port note.
|
||||
val isEspConnected = usbSerialState == UsbSerialState.CONNECTED
|
||||
val isEspBusy = usbSerialState == UsbSerialState.DEVICE_ATTACHED ||
|
||||
usbSerialState == UsbSerialState.PERMISSION_REQUESTED
|
||||
// Backed by Esp32Link: USB (UsbSerialTransport, native USB Serial/JTAG CDC-ACM - see
|
||||
// its KDoc for the VID/PID and native-vs-UART-bridge port note) or BLE (BleLinkTransport).
|
||||
val isEspConnected = esp32LinkState == Esp32LinkState.CONNECTED
|
||||
val isEspBusy = esp32LinkState == Esp32LinkState.DEVICE_ATTACHED ||
|
||||
esp32LinkState == Esp32LinkState.PERMISSION_REQUESTED
|
||||
val espContainerColor = when {
|
||||
isEspConnected -> UsbGreenBg
|
||||
isEspBusy -> UsbAmberBg
|
||||
else -> UsbGrayBg
|
||||
}
|
||||
val (espColor, espStateLabel) = when (usbSerialState) {
|
||||
UsbSerialState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
|
||||
UsbSerialState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
|
||||
UsbSerialState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
UsbSerialState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
|
||||
UsbSerialState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
|
||||
val (espColor, espStateLabel) = when (esp32LinkState) {
|
||||
Esp32LinkState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
|
||||
Esp32LinkState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
|
||||
Esp32LinkState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
Esp32LinkState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
|
||||
Esp32LinkState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
|
||||
}
|
||||
|
||||
Card(
|
||||
@@ -257,7 +263,7 @@ fun ConnectionSetupScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Usb,
|
||||
if (esp32Transport == Esp32Transport.BLE) Icons.Default.Bluetooth else Icons.Default.Usb,
|
||||
contentDescription = null,
|
||||
tint = espColor,
|
||||
modifier = Modifier.size(20.dp),
|
||||
@@ -285,11 +291,36 @@ fun ConnectionSetupScreen(
|
||||
Text(espStateLabel, style = MaterialTheme.typography.bodySmall, color = espColor)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(
|
||||
if (esp32Transport == Esp32Transport.BLE) R.string.conn_esp32_via_ble else R.string.conn_esp32_via_usb
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Pairing passkey, provisioning progress, or why the micrOBU refused something.
|
||||
esp32Detail?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = UsbAmber)
|
||||
}
|
||||
if (isEspConnected) {
|
||||
stationStatus?.let { s ->
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.conn_esp32_signing,
|
||||
if (signOutgoing) stringResource(R.string.conn_esp32_signing_on) else stringResource(R.string.conn_esp32_signing_off),
|
||||
s.tickets, s.signedMessages, s.signRefused, s.radioSubmitted,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (isEspConnected) {
|
||||
if (isEspConnected || isEspBusy) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.disconnectUsbSerial() },
|
||||
onClick = { viewModel.disconnectEsp32() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = Color(0xFFFF5252),
|
||||
@@ -297,13 +328,13 @@ fun ConnectionSetupScreen(
|
||||
) {
|
||||
Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.conn_disconnect))
|
||||
// While connecting (BLE retries until it succeeds) this cancels the attempt.
|
||||
Text(stringResource(if (isEspConnected) R.string.conn_disconnect else R.string.conn_esp32_cancel))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.connectUsbSerial() },
|
||||
onClick = { viewModel.connectEsp32() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isEspBusy,
|
||||
) {
|
||||
Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.hawhamburg.micr0bu.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -28,6 +31,7 @@ import androidx.compose.material.icons.filled.GpsOff
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.material.icons.filled.SensorsOff
|
||||
import androidx.compose.material.icons.filled.Traffic
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -37,14 +41,18 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -52,8 +60,15 @@ import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -63,14 +78,20 @@ fun DashboardScreen(
|
||||
mqttConnectionState: MqttConnectionState,
|
||||
activeTransport: TransportType = TransportType.USB_C,
|
||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
||||
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
|
||||
esp32Bluetooth: Boolean = false,
|
||||
usbCableConnected: Boolean = false,
|
||||
obuStationTypeWarning: Boolean = false,
|
||||
obuStationType: Int? = null,
|
||||
hazards: List<DenmEvent> = emptyList(),
|
||||
signals: List<SpatIntersection> = emptyList(),
|
||||
ownPosition: Cam? = null,
|
||||
onNavigateToConnection: () -> Unit,
|
||||
onNavigateToSensors: () -> Unit,
|
||||
onNavigateToMap: () -> Unit,
|
||||
onNavigateToRecord: () -> Unit = {},
|
||||
onNavigateToV2x: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -262,14 +283,14 @@ fun DashboardScreen(
|
||||
// even once the serial link is actually up.
|
||||
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
|
||||
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
|
||||
val obuConnected = if (isEsp32) usbSerialState == UsbSerialState.CONNECTED else mqttConnected
|
||||
val transportIcon = when (activeTransport) {
|
||||
val obuConnected = if (isEsp32) esp32LinkState == Esp32LinkState.CONNECTED else mqttConnected
|
||||
val transportIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.Bluetooth else when (activeTransport) {
|
||||
TransportType.USB_C -> Icons.Default.Usb
|
||||
TransportType.USB_SERIAL -> Icons.Default.Usb
|
||||
TransportType.WIFI -> Icons.Default.Wifi
|
||||
TransportType.BLUETOOTH -> Icons.Default.Bluetooth
|
||||
}
|
||||
val transportInactiveIcon = when (activeTransport) {
|
||||
val transportInactiveIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.BluetoothDisabled else when (activeTransport) {
|
||||
TransportType.USB_C -> Icons.Default.Usb
|
||||
TransportType.USB_SERIAL -> Icons.Default.Usb
|
||||
TransportType.WIFI -> Icons.Default.WifiOff
|
||||
@@ -305,12 +326,12 @@ fun DashboardScreen(
|
||||
)
|
||||
Text(
|
||||
text = if (isEsp32) {
|
||||
when (usbSerialState) {
|
||||
UsbSerialState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
|
||||
UsbSerialState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
|
||||
UsbSerialState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
UsbSerialState.ERROR -> stringResource(R.string.conn_esp32_state_error)
|
||||
UsbSerialState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
|
||||
when (esp32LinkState) {
|
||||
Esp32LinkState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
|
||||
Esp32LinkState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
|
||||
Esp32LinkState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
Esp32LinkState.ERROR -> stringResource(R.string.conn_esp32_state_error)
|
||||
Esp32LinkState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
|
||||
}
|
||||
} else when (mqttConnectionState) {
|
||||
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
|
||||
@@ -343,20 +364,67 @@ fun DashboardScreen(
|
||||
TransportChip(
|
||||
label = stringResource(R.string.dash_transport_usb_serial),
|
||||
icon = Icons.Default.Usb,
|
||||
active = activeTransport == TransportType.USB_SERIAL,
|
||||
active = !esp32Bluetooth,
|
||||
hasCable = usbCableConnected,
|
||||
)
|
||||
}
|
||||
TransportChip(
|
||||
label = stringResource(R.string.dash_transport_bt),
|
||||
icon = Icons.Default.Bluetooth,
|
||||
active = activeTransport == TransportType.BLUETOOTH,
|
||||
dimmed = true, // Phase 03 — production BT transport still under discussion
|
||||
active = isEsp32 && esp32Bluetooth,
|
||||
// Only the ESP32-C5 has a BLE link; the CiT One has none.
|
||||
dimmed = !isEsp32,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live V2X, below the status cards: the hazard that matters most and the signalised
|
||||
// intersection about to change. Both are summaries of what the V2X screen shows in full,
|
||||
// so tapping either opens that screen rather than repeating its detail here. One of each
|
||||
// is shown deliberately: a dashboard read from a bike mount has room for the single most
|
||||
// relevant thing, not for a list.
|
||||
val ownLatLon = ownPosition?.let { it.latitude to it.longitude }
|
||||
?: state.gnss?.let { it.latitude to it.longitude }
|
||||
|
||||
val rankedHazards = remember(hazards, ownLatLon) {
|
||||
hazards
|
||||
.map { denm ->
|
||||
val distance = ownLatLon?.let { (lat, lon) ->
|
||||
GeoMath.haversineMeters(lat, lon, denm.latitude, denm.longitude)
|
||||
}
|
||||
denm to distance
|
||||
}
|
||||
// Closest first. A hazard whose distance cannot be worked out, because there is
|
||||
// no fix yet, sorts last rather than being dropped: it is still a real hazard,
|
||||
// we just cannot say how far away it is.
|
||||
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
|
||||
}
|
||||
rankedHazards.firstOrNull()?.let { (denm, distance) ->
|
||||
HazardCard(
|
||||
hazard = denm,
|
||||
distanceMeters = distance,
|
||||
additionalCount = rankedHazards.size - 1,
|
||||
onClick = onNavigateToV2x,
|
||||
)
|
||||
}
|
||||
|
||||
// Signals cannot be ranked by distance: SPATEM carries no position at all. The geometry
|
||||
// that would place an intersection lives in MAPEM, which nothing on the air is currently
|
||||
// sending. So the one shown is the one changing soonest, which is in any case the one a
|
||||
// rider approaching a junction needs to see.
|
||||
val nextSignal = remember(signals) {
|
||||
val now = System.currentTimeMillis()
|
||||
signals.minByOrNull { it.secondsToNextChange(now) ?: Double.MAX_VALUE }
|
||||
}
|
||||
nextSignal?.let { signal ->
|
||||
SignalCard(
|
||||
signal = signal,
|
||||
additionalCount = signals.size - 1,
|
||||
onClick = onNavigateToV2x,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
if (state.pressureHpa != null)
|
||||
@@ -485,3 +553,208 @@ private fun QuickStatRow(label: String, value: String) {
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
// Hazard red and the three signal states. Kept local to this file for the same reason the V2X
|
||||
// screen keeps its own: these are traffic-light and warning semantics, not theme roles, and
|
||||
// tying them to the colour scheme would let a theme change turn a red light amber.
|
||||
private val HazardRed = Color(0xFFE53935)
|
||||
private val HazardRedBg = Color(0xFF3A0A0A)
|
||||
private val SignalGreen = Color(0xFF4CAF50)
|
||||
private val SignalAmber = Color(0xFFFFC107)
|
||||
private val SignalGray = Color(0xFF8B949E)
|
||||
|
||||
/** How many signal groups fit on the dashboard before the rest are summarised as a count. */
|
||||
private const val DASH_MAX_SIGNAL_GROUPS = 6
|
||||
|
||||
/**
|
||||
* Seconds until the first of this intersection's signal groups changes, or null when no group
|
||||
* supplies a usable countdown. Marks already in the past are excluded: a change that has already
|
||||
* happened says nothing about what the light will do next.
|
||||
*/
|
||||
private fun SpatIntersection.secondsToNextChange(nowMs: Long): Double? =
|
||||
state.movements
|
||||
.mapNotNull { it.current?.secondsUntil(nowMs) }
|
||||
.filter { it >= 0.0 }
|
||||
.minOrNull()
|
||||
|
||||
/**
|
||||
* The nearest received hazard, as a glanceable summary.
|
||||
*
|
||||
* Deliberately says less than the V2X screen's row: what it is, how far away, and whether there
|
||||
* are others behind it. Anything more detailed belongs on the screen this card opens.
|
||||
*/
|
||||
@Composable
|
||||
private fun HazardCard(
|
||||
hazard: DenmEvent,
|
||||
distanceMeters: Double?,
|
||||
additionalCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val title = DenmParser.causeCodeName(hazard.causeCode)
|
||||
?: hazard.causeCode?.let {
|
||||
stringResource(R.string.v2x_denm_rx_cause_code, it, hazard.subCauseCode ?: 0)
|
||||
}
|
||||
?: stringResource(R.string.v2x_map_denm_plain, hazard.stationId)
|
||||
|
||||
val detail = listOfNotNull(
|
||||
distanceMeters?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
|
||||
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
|
||||
stringResource(R.string.dash_hazard_station, hazard.stationId),
|
||||
if (additionalCount > 0) stringResource(R.string.dash_more_count, additionalCount) else null,
|
||||
).joinToString(" · ")
|
||||
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onClick() },
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = HazardRedBg),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Warning, null, tint = HazardRed, modifier = Modifier.size(28.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.dash_hazard_warning),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = HazardRed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The signalised intersection changing soonest: its leading phase with a countdown, then every
|
||||
* signal group as a coloured chip.
|
||||
*
|
||||
* Signal groups are bare numbers because that is all the app knows. Calling one "your lane" needs
|
||||
* MAPEM geometry, and a friendlier label would claim knowledge that is not there.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SignalCard(
|
||||
signal: SpatIntersection,
|
||||
additionalCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// The countdown has to advance on its own clock. SPATEM repeats at about 2 Hz, so
|
||||
// recomposition would roughly keep pace while the RSU is transmitting, but the moment it
|
||||
// stops, a frozen "3 s" would go on claiming the light is about to change.
|
||||
val nowMs = remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
nowMs.longValue = System.currentTimeMillis()
|
||||
delay(500L)
|
||||
}
|
||||
}
|
||||
val now = nowMs.longValue
|
||||
|
||||
val leading = signal.state.movements.minByOrNull { movement ->
|
||||
movement.current?.secondsUntil(now)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
|
||||
}
|
||||
val phase = leading?.current?.phase
|
||||
val tint = phaseTint(phase)
|
||||
val countdown = leading?.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
|
||||
val hiddenGroups = signal.state.movements.size - DASH_MAX_SIGNAL_GROUPS
|
||||
val footer = listOfNotNull(
|
||||
if (hiddenGroups > 0) stringResource(R.string.dash_more_count, hiddenGroups) else null,
|
||||
if (additionalCount > 0) stringResource(R.string.dash_signal_more, additionalCount) else null,
|
||||
).joinToString(" · ")
|
||||
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onClick() },
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Traffic, null, tint = tint, modifier = Modifier.size(28.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.dash_signal_title, signal.state.key),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = tint,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = countdown
|
||||
?.let { stringResource(R.string.dash_signal_countdown, phaseLabel(phase), it) }
|
||||
?: phaseLabel(phase),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Wraps rather than scrolls: a horizontal scroller inside a scrolling dashboard
|
||||
// is awkward to drive one-handed, and the chip row is short by construction.
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
signal.state.movements.take(DASH_MAX_SIGNAL_GROUPS).forEach { movement ->
|
||||
val groupTint = phaseTint(movement.current?.phase)
|
||||
val groupCountdown =
|
||||
movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_spat_group, movement.signalGroup) +
|
||||
(groupCountdown?.let { " " + stringResource(R.string.v2x_spat_countdown, it) } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = groupTint,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 2.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(4.dp))
|
||||
.background(groupTint.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (footer.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
footer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Traffic-light colour for a phase: go is green, stop is red, anything in between is amber. */
|
||||
@Composable
|
||||
private fun phaseTint(phase: SignalPhase?): Color = when {
|
||||
phase == null -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
phase.isGo -> SignalGreen
|
||||
phase.isStop -> HazardRed
|
||||
phase.isTransition -> SignalAmber
|
||||
else -> SignalGray
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun phaseLabel(phase: SignalPhase?): String = when {
|
||||
phase == null -> stringResource(R.string.dash_signal_phase_unknown)
|
||||
phase.isGo -> stringResource(R.string.dash_signal_phase_go)
|
||||
phase.isStop -> stringResource(R.string.dash_signal_phase_stop)
|
||||
phase.isTransition -> stringResource(R.string.dash_signal_phase_changing)
|
||||
phase == SignalPhase.DARK -> stringResource(R.string.dash_signal_phase_dark)
|
||||
else -> stringResource(R.string.dash_signal_phase_unknown)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.LinkOff
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.VerticalAlignBottom
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
@@ -69,7 +70,7 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamParser
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
@@ -87,11 +88,15 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST), the raw MQTT topic list
|
||||
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path), or the V2X live map
|
||||
* (MAP, Section 13).
|
||||
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST) or the raw MQTT topic list
|
||||
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path).
|
||||
*
|
||||
* The live map used to be a third mode here. It is now its own full-screen destination
|
||||
* ([V2xMapScreen]), reached from the map button in this screen's header: sharing the screen with
|
||||
* the alert panel and the TX cards left the map about a third of a phone display tall, which is
|
||||
* not enough to see where anything is relative to anything else.
|
||||
*/
|
||||
private enum class TopicViewMode { LIST, TOPICS, MAP }
|
||||
private enum class TopicViewMode { LIST, TOPICS }
|
||||
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
@@ -116,6 +121,7 @@ private val WarningRedBg = Color(0xFF3A0A0A)
|
||||
@Composable
|
||||
fun MqttTopicViewerScreen(
|
||||
viewModel: MqttViewModel = hiltViewModel(),
|
||||
onOpenMap: () -> Unit = {},
|
||||
) {
|
||||
val connectionState by viewModel.connectionState.collectAsState()
|
||||
val topicMessages by viewModel.topicMessages.collectAsState()
|
||||
@@ -130,10 +136,11 @@ fun MqttTopicViewerScreen(
|
||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||
// Engine road users PLUS roadside units - the engine deliberately does not track RSUs.
|
||||
val remoteCamPositions by viewModel.stationsInRange.collectAsState()
|
||||
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||
val esp32LinkState by viewModel.esp32LinkState.collectAsState()
|
||||
val camPingerActive by viewModel.camPingerActive.collectAsState()
|
||||
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
|
||||
val camPingerHasFix by viewModel.camPingerHasFix.collectAsState()
|
||||
val ownTxLoopback by viewModel.ownTxLoopback.collectAsState()
|
||||
val camSendFailures by viewModel.camSendFailures.collectAsState()
|
||||
val espLinkStatus by viewModel.espLinkStatus.collectAsState()
|
||||
val denmEvents by viewModel.denmEvents.collectAsState()
|
||||
@@ -154,7 +161,7 @@ fun MqttTopicViewerScreen(
|
||||
// DISCONNECTED and using it here made the screen report "offline" while CAMs streamed in over
|
||||
// serial. Everything on this screen that means "is the OBU link up?" follows the serial link
|
||||
// instead when that hardware is selected.
|
||||
val effectiveState = if (isEsp32) usbSerialState.asConnectionState() else connectionState
|
||||
val effectiveState = if (isEsp32) esp32LinkState.asConnectionState() else connectionState
|
||||
val isConnected = effectiveState == MqttConnectionState.CONNECTED
|
||||
val isConnecting = effectiveState == MqttConnectionState.CONNECTING
|
||||
|
||||
@@ -192,14 +199,25 @@ fun MqttTopicViewerScreen(
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// Full-screen live map. In the header rather than in the view-mode row below, so it
|
||||
// is reachable from the message detail pane too and does not move around as the
|
||||
// available view modes change with the selected hardware.
|
||||
IconButton(onClick = onOpenMap) {
|
||||
Icon(
|
||||
Icons.Default.Map,
|
||||
contentDescription = stringResource(R.string.v2x_map_title),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
ConnectionChip(effectiveState)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Route to whichever transport this hardware actually uses.
|
||||
if (isEsp32) {
|
||||
if (isConnected || isConnecting) viewModel.disconnectUsbSerial()
|
||||
else viewModel.connectUsbSerial()
|
||||
if (isConnected || isConnecting) viewModel.disconnectEsp32()
|
||||
else viewModel.connectEsp32()
|
||||
} else {
|
||||
if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect()
|
||||
}
|
||||
@@ -235,10 +253,11 @@ fun MqttTopicViewerScreen(
|
||||
isEsp32 = isEsp32,
|
||||
denmEvents = denmEvents,
|
||||
spatIntersections = spatIntersections,
|
||||
usbSerialState = usbSerialState,
|
||||
esp32LinkState = esp32LinkState,
|
||||
camPingerActive = camPingerActive,
|
||||
camPingerSentCount = camPingerSentCount,
|
||||
camPingerHasFix = camPingerHasFix,
|
||||
ownTxLoopback = ownTxLoopback,
|
||||
camSendFailures = camSendFailures,
|
||||
espLinkStatus = espLinkStatus,
|
||||
ownCamPosition = ownCamPosition,
|
||||
@@ -275,10 +294,11 @@ private fun TopicListPane(
|
||||
isEsp32: Boolean = false,
|
||||
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
|
||||
spatIntersections: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection> = emptyList(),
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
||||
camPingerActive: Boolean = false,
|
||||
camPingerSentCount: Int = 0,
|
||||
camPingerHasFix: Boolean = false,
|
||||
ownTxLoopback: com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback? = null,
|
||||
camSendFailures: Int = 0,
|
||||
espLinkStatus: EspLinkStatus? = null,
|
||||
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
|
||||
@@ -318,10 +338,11 @@ private fun TopicListPane(
|
||||
// ── CAM Pinger card — ESP32-C5-only manual bench test, mirrors the DENM card above ──
|
||||
if (showCamPinger) {
|
||||
CamPingerCard(
|
||||
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
|
||||
usbConnected = esp32LinkState == Esp32LinkState.CONNECTED,
|
||||
pingerActive = camPingerActive,
|
||||
sentCount = camPingerSentCount,
|
||||
hasFix = camPingerHasFix,
|
||||
loopback = ownTxLoopback,
|
||||
sendFailures = camSendFailures,
|
||||
linkStatus = espLinkStatus,
|
||||
onStart = onStartCamPinger,
|
||||
@@ -331,49 +352,38 @@ private fun TopicListPane(
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
|
||||
// ── List / Topics / Map toggle ────────────────────────────────────────────────────
|
||||
// ── List / Topics toggle ──────────────────────────────────────────────
|
||||
// Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
|
||||
// the road users and hazards, not the transport that carried them. The raw MQTT topic
|
||||
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
|
||||
// to", not instead of, the topic list). It is hidden on the ESP32-C5 path, where there is
|
||||
// no broker and `topics` is permanently empty.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_list),
|
||||
selected = viewMode == TopicViewMode.LIST,
|
||||
) { viewMode = TopicViewMode.LIST }
|
||||
// to", not instead of, the topic list).
|
||||
//
|
||||
// The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is
|
||||
// permanently empty, and a toggle offering a single choice is just noise.
|
||||
if (!isEsp32) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_list),
|
||||
selected = viewMode == TopicViewMode.LIST,
|
||||
) { viewMode = TopicViewMode.LIST }
|
||||
|
||||
if (!isEsp32) {
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_topics),
|
||||
selected = viewMode == TopicViewMode.TOPICS,
|
||||
) { viewMode = TopicViewMode.TOPICS }
|
||||
}
|
||||
|
||||
ViewModeButton(
|
||||
label = stringResource(R.string.mqtt_view_map),
|
||||
selected = viewMode == TopicViewMode.MAP,
|
||||
) { viewMode = TopicViewMode.MAP }
|
||||
}
|
||||
|
||||
// ── Decoded traffic / raw topics / live map ───────────────────────────
|
||||
// ── Decoded traffic / raw topics ──────────────────────────────────────
|
||||
// TOPICS can still be the saved selection from a CiT One session after switching hardware
|
||||
// to the ESP32-C5, where that button no longer exists - fall back to the decoded list
|
||||
// rather than stranding the user on a pane they can't navigate away from.
|
||||
val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
|
||||
|
||||
if (shownMode == TopicViewMode.MAP) {
|
||||
V2xLiveMapView(
|
||||
own = ownCamPosition,
|
||||
remotes = remoteCamPositions,
|
||||
alerts = useCaseAlerts,
|
||||
denms = denmEvents,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else if (shownMode == TopicViewMode.LIST) {
|
||||
if (shownMode == TopicViewMode.LIST) {
|
||||
ReceivedCamPane(
|
||||
own = ownCamPosition,
|
||||
remotes = remoteCamPositions,
|
||||
@@ -1108,6 +1118,7 @@ private fun CamPingerCard(
|
||||
pingerActive: Boolean,
|
||||
sentCount: Int,
|
||||
hasFix: Boolean,
|
||||
loopback: com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback?,
|
||||
sendFailures: Int,
|
||||
linkStatus: EspLinkStatus?,
|
||||
onStart: () -> Unit,
|
||||
@@ -1176,8 +1187,24 @@ private fun CamPingerCard(
|
||||
|
||||
// ── Link diagnostics ──────────────────────────────────────────────
|
||||
// "Sent: 240" is meaningless on its own if all 240 writes failed, or if the ESP32
|
||||
// accepted them and the radio rejected every one. These two lines are the difference
|
||||
// accepted them and the radio rejected every one. These lines are the difference
|
||||
// between a bench session that tells you something and one that doesn't.
|
||||
|
||||
// The round trip closing: sent over serial, transmitted, and heard again by the same
|
||||
// radio. Compared against Sent above, a shortfall separates "nothing is going out"
|
||||
// from "it goes out but is not coming back".
|
||||
loopback?.takeIf { it.frames > 0 }?.let { lb ->
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = lb.lastRssiDbm?.let {
|
||||
stringResource(R.string.mqtt_cam_pinger_loopback, lb.frames, it)
|
||||
} ?: stringResource(R.string.mqtt_cam_pinger_loopback_no_rssi, lb.frames),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ConnectedGreen,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
if (sendFailures > 0) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
@@ -1192,11 +1219,12 @@ private fun CamPingerCard(
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.mqtt_cam_pinger_fw_counters,
|
||||
s.txFailures, s.oversizeDrops, s.rxCrcErrors,
|
||||
s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.rxQueueDrops,
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0)
|
||||
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0 ||
|
||||
s.rxQueueDrops > 0
|
||||
) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
@@ -1445,10 +1473,10 @@ private fun prettyPrintJson(raw: String): String {
|
||||
* connection UI on this screen already speaks, so one indicator can serve both transports rather
|
||||
* than duplicating the chip and its colours per hardware type.
|
||||
*/
|
||||
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
UsbSerialState.DEVICE_ATTACHED,
|
||||
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
UsbSerialState.ERROR -> MqttConnectionState.ERROR
|
||||
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
|
||||
Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||
Esp32LinkState.DEVICE_ATTACHED,
|
||||
Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||
Esp32LinkState.ERROR -> MqttConnectionState.ERROR
|
||||
Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
@@ -141,46 +141,6 @@ fun RecordingScreen(
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// ── Event Detection Counters ─────────────────────────────────────────
|
||||
if (state.isRecording || tripServiceState.isRecording) {
|
||||
Text(
|
||||
stringResource(R.string.rec_events_detected),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
EventCountBadge(
|
||||
label = stringResource(R.string.rec_event_braking),
|
||||
count = tripServiceState.brakingCount,
|
||||
color = Color(0xFFFF5252),
|
||||
)
|
||||
EventCountBadge(
|
||||
label = stringResource(R.string.rec_event_turning),
|
||||
count = tripServiceState.turningCount,
|
||||
color = Color(0xFFFFB300),
|
||||
)
|
||||
EventCountBadge(
|
||||
label = stringResource(R.string.rec_event_stopping),
|
||||
count = tripServiceState.stoppingCount,
|
||||
color = Color(0xFF42A5F5),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// ── CSV Session Log shortcut ─────────────────────────────────────────
|
||||
if (!state.isRecording) {
|
||||
OutlinedButton(
|
||||
@@ -228,25 +188,6 @@ fun RecordingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventCountBadge(label: String, count: Int, color: Color) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = color,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamRow(label: String, active: Boolean) {
|
||||
Row(
|
||||
|
||||
@@ -47,7 +47,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
@@ -165,13 +167,19 @@ fun ConnectionSettingsScreen(
|
||||
onMqttPrefsChange: (MqttPrefs) -> Unit,
|
||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||
onObuHardwareChange: (ObuHardware) -> Unit = {},
|
||||
esp32Transport: Esp32Transport = Esp32Transport.USB,
|
||||
onEsp32TransportChange: (Esp32Transport) -> Unit = {},
|
||||
outgoingMessage: OutgoingMessage = OutgoingMessage.CAM,
|
||||
onOutgoingMessageChange: (OutgoingMessage) -> Unit = {},
|
||||
signOutgoing: Boolean = true,
|
||||
onSignOutgoingChange: (Boolean) -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
SubScreen(stringResource(R.string.settings_connection), onBack) {
|
||||
SectionCard {
|
||||
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). Everything
|
||||
// below (transport, USB-C options) only really applies to CiT One; ESP32-C5 uses
|
||||
// USB Serial exclusively and has no transport choice to make here.
|
||||
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). The transport
|
||||
// cards below apply to the CiT One; the ESP32-C5 has its own card (USB-C or BLE,
|
||||
// CAM or VAM, signing).
|
||||
Text(
|
||||
stringResource(R.string.settings_obu_hardware),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -212,6 +220,48 @@ fun ConnectionSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (obuHardware == ObuHardware.ESP32_C5) {
|
||||
SectionCard {
|
||||
TwoWayChoice(
|
||||
label = stringResource(R.string.settings_esp32_link),
|
||||
first = stringResource(R.string.settings_transport_usbc),
|
||||
second = stringResource(R.string.settings_esp32_link_ble),
|
||||
firstSelected = esp32Transport == Esp32Transport.USB,
|
||||
onFirst = { onEsp32TransportChange(Esp32Transport.USB) },
|
||||
onSecond = { onEsp32TransportChange(Esp32Transport.BLE) },
|
||||
)
|
||||
if (esp32Transport == Esp32Transport.BLE) {
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_link_ble_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
TwoWayChoice(
|
||||
label = stringResource(R.string.settings_esp32_message),
|
||||
first = stringResource(R.string.settings_esp32_message_cam),
|
||||
second = stringResource(R.string.settings_esp32_message_vam),
|
||||
firstSelected = outgoingMessage == OutgoingMessage.CAM,
|
||||
onFirst = { onOutgoingMessageChange(OutgoingMessage.CAM) },
|
||||
onSecond = { onOutgoingMessageChange(OutgoingMessage.VAM) },
|
||||
)
|
||||
Divider()
|
||||
SettingToggleRow(
|
||||
label = stringResource(R.string.settings_esp32_sign),
|
||||
checked = signOutgoing,
|
||||
onCheckedChange = onSignOutgoingChange,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_sign_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (obuHardware == ObuHardware.CIT_ONE) {
|
||||
SectionCard {
|
||||
// Active transport selector
|
||||
@@ -611,6 +661,40 @@ private fun LanguageSection() {
|
||||
}
|
||||
}
|
||||
|
||||
/** A labelled pair of outlined buttons, the selected one filled — the style of the OBU hardware picker. */
|
||||
@Composable
|
||||
private fun TwoWayChoice(
|
||||
label: String,
|
||||
first: String,
|
||||
second: String,
|
||||
firstSelected: Boolean,
|
||||
onFirst: () -> Unit,
|
||||
onSecond: () -> Unit,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for ((text, selected, onClick) in listOf(Triple(first, firstSelected, onFirst), Triple(second, !firstSelected, onSecond))) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(text, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
||||
|
||||
|
||||
@@ -145,7 +145,6 @@ private fun TripCard(
|
||||
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
|
||||
TripStatChip("⏱ ${formatDuration(durationSec)}")
|
||||
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
|
||||
TripStatChip("🚨 ${trip.eventCount} events")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpen) {
|
||||
|
||||
@@ -42,9 +42,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
@@ -61,7 +59,6 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun TripReviewScreen(
|
||||
trip: RecordedTripEntity,
|
||||
viewModel: TripRecordingViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -70,15 +67,8 @@ fun TripReviewScreen(
|
||||
// provider is ready before MapView is constructed in the factory block.
|
||||
initOsmReview(context)
|
||||
|
||||
LaunchedEffect(trip.id) { viewModel.loadTripEvents(trip.id) }
|
||||
val events by viewModel.selectedTripEvents.collectAsState()
|
||||
|
||||
val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) }
|
||||
|
||||
var selectedEvent by remember { mutableStateOf<DetectedEventEntity?>(null) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
@@ -108,13 +98,6 @@ fun TripReviewScreen(
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"🚨 ${events.count { it.type == "BRAKING" }} " +
|
||||
"🔄 ${events.count { it.type == "TURNING" }} " +
|
||||
"🛑 ${events.count { it.type == "STOPPING" }}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Map ───────────────────────────────────────────────────────────────
|
||||
@@ -141,33 +124,6 @@ fun TripReviewScreen(
|
||||
mv.overlays.add(polyline)
|
||||
}
|
||||
|
||||
// Event pins
|
||||
events.forEach { event ->
|
||||
val pinColor = when (event.type) {
|
||||
"BRAKING" -> Color(0xFFFF5252)
|
||||
"TURNING" -> Color(0xFFFFB300)
|
||||
"STOPPING" -> Color(0xFF42A5F5)
|
||||
else -> Color.Gray
|
||||
}
|
||||
val marker = Marker(mv).apply {
|
||||
position = GeoPoint(event.latitude, event.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
title = "${event.type} (${event.confidence})"
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
selectedEvent = event
|
||||
scope.launch { sheetState.show() }
|
||||
true
|
||||
}
|
||||
// Solid-circle pin in the event color
|
||||
icon = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(pinColor.toArgb())
|
||||
setSize(32, 32)
|
||||
}
|
||||
}
|
||||
mv.overlays.add(marker)
|
||||
}
|
||||
|
||||
// Auto-fit the camera to the track — deferred via post() so the
|
||||
// MapView has been measured before zoomToBoundingBox is called.
|
||||
// Calling it with width/height == 0 (before first layout) crashes osmdroid.
|
||||
@@ -193,82 +149,6 @@ fun TripReviewScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Event detail bottom sheet ─────────────────────────────────────────────
|
||||
val ev = selectedEvent
|
||||
if (ev != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { selectedEvent = null },
|
||||
sheetState = sheetState,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle() },
|
||||
) {
|
||||
EventDetailSheet(event = ev, onDismiss = {
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion { selectedEvent = null }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event detail sheet content ────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun EventDetailSheet(event: DetectedEventEntity, onDismiss: () -> Unit) {
|
||||
// Created here (not as a top-level static field) so it always uses the
|
||||
// current locale even if the user changes it while the app is running.
|
||||
val sdf = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
||||
|
||||
val accentColor = when (event.type) {
|
||||
"BRAKING" -> Color(0xFFFF5252)
|
||||
"TURNING" -> Color(0xFFFFB300)
|
||||
"STOPPING" -> Color(0xFF42A5F5)
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
event.type.replaceFirstChar { it.titlecase() },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = accentColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Confidence: ${event.confidence}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
sdf.format(Date(event.timestamp)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
EventDetailRow("Speed", "%.1f m/s".format(event.speedMps))
|
||||
EventDetailRow("Peak accel", "%.2f m/s²".format(event.peakAccelMagnitude))
|
||||
EventDetailRow("Peak gyro", "%.3f rad/s".format(event.peakGyroMagnitude))
|
||||
EventDetailRow("Duration", "${event.durationMs} ms")
|
||||
EventDetailRow("Location", "%.5f°, %.5f°".format(event.latitude, event.longitude))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventDetailRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GPS track parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.hawhamburg.micr0bu.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.GpsOff
|
||||
@@ -30,25 +31,37 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
|
||||
/**
|
||||
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus
|
||||
* every currently-tracked remote road user's last-known CAM position, in addition to (not
|
||||
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as
|
||||
* [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here
|
||||
* always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]
|
||||
* currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc).
|
||||
* V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the
|
||||
* ego bike's own position, every currently-tracked remote road user's last-known CAM position,
|
||||
* every live hazard (DENM) and every signalised intersection heard over SPATEM.
|
||||
*
|
||||
* Remote markers are colored by that station's most severe active alert level, if any, so a
|
||||
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same
|
||||
* severity coloring already used by [UseCaseAlertPanel].
|
||||
* Marker vocabulary, one shape per message type so the map reads without a legend:
|
||||
* - CAM — teardrop pin, tinted by that station's most severe active alert level
|
||||
* - DENM — hazard warning triangle
|
||||
* - SPATEM — traffic light, with the lamp for the intersection's leading phase lit
|
||||
*
|
||||
* Unlike [MapScreen] this has no phone-GNSS-only fallback: [own] always reflects whichever ego
|
||||
* source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] currently trusts (obu_gnss /
|
||||
* phone GNSS / CAM-topic-own — see that class's KDoc).
|
||||
*
|
||||
* **Markers are reused across updates, not rebuilt.** CAMs arrive at up to 10 Hz per station, and
|
||||
* every arrival recomposes this view; the previous version cleared the overlay list and rebuilt
|
||||
* every Marker — decoding and mutating a fresh Drawable per marker per update — which is what
|
||||
* made panning stutter under live traffic. Drawables are now loaded once per level/phase and
|
||||
* shared (osmdroid sets the icon's bounds on each draw, so sharing one instance across markers is
|
||||
* safe), and Marker objects are cached by key. The overlay list is still reordered each update,
|
||||
* which costs nothing: it moves existing references, it does not allocate.
|
||||
*/
|
||||
@Composable
|
||||
fun V2xLiveMapView(
|
||||
@@ -56,6 +69,9 @@ fun V2xLiveMapView(
|
||||
remotes: Map<Long, Cam>,
|
||||
alerts: List<UseCaseAlert>,
|
||||
denms: List<DenmEvent> = emptyList(),
|
||||
spats: List<SpatIntersection> = emptyList(),
|
||||
followOwn: Boolean = true,
|
||||
onUserPanned: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -71,8 +87,17 @@ fun V2xLiveMapView(
|
||||
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
|
||||
}
|
||||
|
||||
// Loaded once and shared by every marker that needs them. mutate() on the remote pin is still
|
||||
// essential: without it all four tinted copies would share one ConstantState and the last
|
||||
// tint applied would recolour every pin on the map.
|
||||
val icons = remember(context) { MapIcons(context) }
|
||||
val markers = remember { mutableMapOf<String, Marker>() }
|
||||
|
||||
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// Tracks whether the last update already recentred for this follow session, so re-enabling
|
||||
// follow animates once instead of fighting the rider's own panning on every frame.
|
||||
val wasFollowing = remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
@@ -89,106 +114,236 @@ fun V2xLiveMapView(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
initOsmForV2xMap(ctx)
|
||||
MapView(ctx).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
controller.setZoom(17.0)
|
||||
controller.setCenter(ownGeoPoint)
|
||||
mapViewRef.value = this
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
initOsmForV2xMap(ctx)
|
||||
MapView(ctx).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
// Raster tiles are authored for ~160 dpi; without this they are upscaled by the
|
||||
// display density and labels come out soft on a modern phone.
|
||||
isTilesScaledToDpi = true
|
||||
// The floating +/- buttons sit exactly where the rider's thumb lands and
|
||||
// duplicate pinch-zoom. Pinch and double-tap still work.
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
setMinZoomLevel(4.0)
|
||||
setMaxZoomLevel(20.0)
|
||||
controller.setZoom(17.0)
|
||||
controller.setCenter(ownGeoPoint)
|
||||
// Any touch means the rider is driving the map; follow-own hands over to them
|
||||
// until they ask for it back. false: the MapView's own gesture handling still
|
||||
// runs, this only observes.
|
||||
setOnTouchListener { _, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) onUserPanned()
|
||||
false
|
||||
}
|
||||
},
|
||||
update = { mv ->
|
||||
mv.overlays.clear()
|
||||
mapViewRef.value = this
|
||||
}
|
||||
},
|
||||
update = { mv ->
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// Own position: a centred "you are here" dot, not a pin. Own position is a fact
|
||||
// about the viewer rather than one of the tracked objects, and when both used
|
||||
// osmdroid's identical default pin the two were indistinguishable at a glance.
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = ownGeoPoint
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||
title = context.getString(R.string.v2x_map_own_label)
|
||||
}
|
||||
)
|
||||
// Intersections we can actually place: SPATEM carries signal state but no geometry
|
||||
// (that is MAPEM's job), so the only position available is the sending RSU's own CAM.
|
||||
val locatedSpats = spats.mapNotNull { spat ->
|
||||
remotes[spat.stationId]?.let { rsu -> spat to rsu }
|
||||
}
|
||||
// An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it:
|
||||
// two markers on one point, the lower one unreachable.
|
||||
val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet()
|
||||
|
||||
remotes.forEach { (stationId, cam) ->
|
||||
val level = alertByStation[stationId]
|
||||
val label = when (level) {
|
||||
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
||||
AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId)
|
||||
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
||||
}
|
||||
// Teardrop pin anchored at its tip, tinted by severity. Now that these are
|
||||
// custom drawables, per-instance tinting is possible - severity no longer
|
||||
// depends on tapping the marker to read its label. mutate() is essential:
|
||||
// without it every marker shares one ConstantState and the last tint applied
|
||||
// would recolour all of them.
|
||||
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
||||
?.mutate()
|
||||
?.apply { setTint(level.toMarkerColor()) }
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = GeoPoint(cam.latitude, cam.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = pin
|
||||
title = label
|
||||
}
|
||||
val live = mutableSetOf<String>()
|
||||
|
||||
// Own position: a centred "you are here" dot, not a pin. Own position is a fact about
|
||||
// the viewer rather than one of the tracked objects, and when both used osmdroid's
|
||||
// identical default pin the two were indistinguishable at a glance.
|
||||
markers.marker(mv, KEY_OWN, live).apply {
|
||||
position = ownGeoPoint
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = icons.own
|
||||
title = context.getString(R.string.v2x_map_own_label)
|
||||
}
|
||||
|
||||
remotes.forEach { (stationId, cam) ->
|
||||
if (stationId in spatStationIds) return@forEach
|
||||
val level = alertByStation[stationId]
|
||||
val label = when (level) {
|
||||
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
||||
AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId)
|
||||
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
||||
}
|
||||
// Teardrop pin anchored at its tip, tinted by severity, so severity no longer
|
||||
// depends on tapping the marker to read its label.
|
||||
markers.marker(mv, "$KEY_CAM$stationId", live).apply {
|
||||
position = GeoPoint(cam.latitude, cam.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = icons.remotePin(level)
|
||||
title = label
|
||||
}
|
||||
}
|
||||
|
||||
// Hazards and signals are added after the vehicle pins, so they draw on top: a hazard
|
||||
// hidden behind a CAM pin defeats the point of showing it.
|
||||
denms.forEach { denm ->
|
||||
markers.marker(mv, "$KEY_DENM${denm.dedupKey}", live).apply {
|
||||
position = GeoPoint(denm.latitude, denm.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = icons.denm
|
||||
title = denm.causeCode?.let {
|
||||
context.getString(
|
||||
R.string.v2x_map_denm_labeled,
|
||||
it,
|
||||
denm.subCauseCode ?: 0,
|
||||
denm.stationId,
|
||||
)
|
||||
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
||||
}
|
||||
}
|
||||
|
||||
locatedSpats.forEach { (spat, rsu) ->
|
||||
val phase = spat.leadingPhase(now)
|
||||
markers.marker(mv, "$KEY_SPAT${spat.key}", live).apply {
|
||||
position = GeoPoint(rsu.latitude, rsu.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = icons.signal(phase)
|
||||
title = context.getString(
|
||||
R.string.v2x_spat_rx_title, spat.state.key, spat.stationId,
|
||||
)
|
||||
snippet = spat.state.movements.joinToString(" · ") { movement ->
|
||||
val seconds = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
|
||||
context.getString(R.string.v2x_spat_group, movement.signalGroup) +
|
||||
(seconds?.let { " " + context.getString(R.string.v2x_spat_countdown, it) } ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard
|
||||
// hidden behind a CAM pin defeats the point of showing it.
|
||||
denms.forEach { denm ->
|
||||
mv.overlays.add(
|
||||
Marker(mv).apply {
|
||||
position = GeoPoint(denm.latitude, denm.longitude)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||
title = denm.causeCode?.let {
|
||||
context.getString(
|
||||
R.string.v2x_map_denm_labeled,
|
||||
it,
|
||||
denm.subCauseCode ?: 0,
|
||||
denm.stationId,
|
||||
)
|
||||
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
|
||||
}
|
||||
)
|
||||
}
|
||||
// Drop markers for stations, hazards and intersections that have expired, then rebuild
|
||||
// the overlay list in draw order from the cached Markers. Reordering moves references;
|
||||
// nothing here allocates a Marker or decodes a Drawable.
|
||||
markers.keys.retainAll { key ->
|
||||
(key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() }
|
||||
}
|
||||
mv.overlays.clear()
|
||||
markers.entries
|
||||
.sortedBy { (key, _) -> key.drawOrder() }
|
||||
.forEach { (_, marker) -> mv.overlays.add(marker) }
|
||||
|
||||
mv.controller.animateTo(ownGeoPoint)
|
||||
mv.invalidate()
|
||||
},
|
||||
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
||||
// MapViewRepository holds a null MapView, so constructing a Marker against it throws
|
||||
// NullPointerException from deep inside InfoWindow's constructor.
|
||||
//
|
||||
// This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect
|
||||
// is keyed on the lifecycle owner and disposes independently of this AndroidView, so
|
||||
// the update block above could still run against an already-detached MapView and
|
||||
// rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving,
|
||||
// because every incoming message recomposes this view and there are far more updates
|
||||
// to land in that window than there used to be.
|
||||
//
|
||||
// onRelease is the callback that actually means "this View is gone": Compose
|
||||
// guarantees no further update after it.
|
||||
onRelease = { it.onDetach() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// setCenter, not animateTo: an animation restarted on every CAM never finishes, which
|
||||
// is exactly the judder this used to show under live traffic. The one animated move is
|
||||
// the rider re-enabling follow, where the travel is worth seeing.
|
||||
if (followOwn) {
|
||||
if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint)
|
||||
else mv.controller.animateTo(ownGeoPoint)
|
||||
}
|
||||
wasFollowing.value = followOwn
|
||||
|
||||
mv.invalidate()
|
||||
},
|
||||
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
||||
// MapViewRepository holds a null MapView, so constructing a Marker against it throws
|
||||
// NullPointerException from deep inside InfoWindow's constructor.
|
||||
//
|
||||
// This used to run in the DisposableEffect's onDispose, which is NOT safe: that effect
|
||||
// is keyed on the lifecycle owner and disposes independently of this AndroidView, so
|
||||
// the update block above could still run against an already-detached MapView and
|
||||
// rebuild its markers. It crashed the app on 2026-08-17 once DENMs started arriving,
|
||||
// because every incoming message recomposes this view and there are far more updates
|
||||
// to land in that window than there used to be.
|
||||
//
|
||||
// onRelease is the callback that actually means "this View is gone": Compose
|
||||
// guarantees no further update after it.
|
||||
onRelease = {
|
||||
markers.clear()
|
||||
it.onDetach()
|
||||
},
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Marker cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
private const val KEY_OWN = "own"
|
||||
private const val KEY_CAM = "cam:"
|
||||
private const val KEY_DENM = "denm:"
|
||||
private const val KEY_SPAT = "spat:"
|
||||
|
||||
/** Draw order: own dot at the bottom, then vehicles, with hazards and signals on top. */
|
||||
private fun String.drawOrder(): Int = when {
|
||||
this == KEY_OWN -> 0
|
||||
startsWith(KEY_CAM) -> 1
|
||||
startsWith(KEY_DENM) -> 2
|
||||
else -> 3
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached [Marker] for [key], created against [mv] on first use, recording the key in [live]
|
||||
* so the caller can drop whatever it did not ask for this update.
|
||||
*/
|
||||
private fun MutableMap<String, Marker>.marker(
|
||||
mv: MapView,
|
||||
key: String,
|
||||
live: MutableSet<String>,
|
||||
): Marker {
|
||||
live += key
|
||||
return getOrPut(key) { Marker(mv) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker artwork, loaded once per composition rather than per update.
|
||||
*
|
||||
* The remote pin is drawn white and tinted per severity here; [mutate] is what keeps the four
|
||||
* tinted copies independent, since without it they would share one ConstantState and the last
|
||||
* tint applied would recolour all of them.
|
||||
*/
|
||||
private class MapIcons(context: Context) {
|
||||
val own: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||
val denm: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||
|
||||
private val pins: Map<AlertLevel?, Drawable?> =
|
||||
(listOf(null) + AlertLevel.entries).associateWith { level ->
|
||||
ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
|
||||
?.mutate()
|
||||
?.apply { setTint(level.toMarkerColor()) }
|
||||
}
|
||||
|
||||
private val signals: Map<SignalLamp, Drawable?> = SignalLamp.entries.associateWith { lamp ->
|
||||
ContextCompat.getDrawable(context, lamp.drawableRes)
|
||||
}
|
||||
|
||||
fun remotePin(level: AlertLevel?): Drawable? = pins[level]
|
||||
fun signal(lamp: SignalLamp): Drawable? = signals[lamp]
|
||||
}
|
||||
|
||||
// ── Signal phase → lamp ───────────────────────────────────────────────────────
|
||||
|
||||
/** Which lamp of the traffic-light marker is lit. */
|
||||
private enum class SignalLamp(val drawableRes: Int) {
|
||||
RED(R.drawable.ic_map_spat_red),
|
||||
AMBER(R.drawable.ic_map_spat_amber),
|
||||
GREEN(R.drawable.ic_map_spat_green),
|
||||
DARK(R.drawable.ic_map_spat_dark),
|
||||
}
|
||||
|
||||
/**
|
||||
* The lamp to light for this intersection.
|
||||
*
|
||||
* Without MAPEM there is no lane geometry, so there is no way to know which of an intersection's
|
||||
* signal groups applies to the rider's own approach. This follows the rule the Dashboard's
|
||||
* SignalCard already uses — the group changing soonest speaks for the intersection — so the same
|
||||
* intersection reads the same way in both places rather than inventing a second convention.
|
||||
*/
|
||||
private fun SpatIntersection.leadingPhase(nowMs: Long): SignalLamp {
|
||||
val leading = state.movements.minByOrNull { movement ->
|
||||
movement.current?.secondsUntil(nowMs)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
|
||||
}
|
||||
val phase = leading?.current?.phase
|
||||
return when {
|
||||
phase == null -> SignalLamp.DARK
|
||||
phase.isGo -> SignalLamp.GREEN
|
||||
phase.isStop -> SignalLamp.RED
|
||||
phase.isTransition -> SignalLamp.AMBER
|
||||
else -> SignalLamp.DARK // UNAVAILABLE / DARK / caution
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,5 +383,10 @@ private fun initOsmForV2xMap(context: Context) {
|
||||
Configuration.getInstance().apply {
|
||||
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
|
||||
userAgentValue = context.packageName
|
||||
// Panning off the edge of the cache is what makes a raster map feel slow: the default
|
||||
// 600 MB cap is plenty, but the default 2 download threads are not when a pan exposes a
|
||||
// screenful of new tiles at once.
|
||||
tileDownloadThreads = 6.toShort()
|
||||
tileFileSystemThreads = 6.toShort()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.hawhamburg.micr0bu.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Traffic
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||
|
||||
/**
|
||||
* Full-screen V2X live map — the map and nothing else, reached from the map button on the V2X
|
||||
* Monitor screen.
|
||||
*
|
||||
* Identical on both hardware paths. Everything drawn here comes from
|
||||
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which already merges the CiT One's MQTT
|
||||
* feed and the ESP32-C5's serial feed into one set of flows, so this screen never has to know
|
||||
* which OBU is connected.
|
||||
*
|
||||
* The chrome is deliberately minimal and floats over the map rather than boxing it in: a back
|
||||
* button, a live count per message type, and a recentre button. The counts double as the map's
|
||||
* legend — each one carries the same icon family as the marker it counts.
|
||||
*/
|
||||
@Composable
|
||||
fun V2xMapScreen(
|
||||
viewModel: MqttViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||
// Road users from the detection engine PLUS roadside units, which it deliberately does not
|
||||
// track - an RSU is what carries the traffic lights below.
|
||||
val stations by viewModel.stationsInRange.collectAsState()
|
||||
val alerts by viewModel.useCaseAlerts.collectAsState()
|
||||
val denms by viewModel.denmEvents.collectAsState()
|
||||
val spats by viewModel.spatIntersections.collectAsState()
|
||||
|
||||
// Follow is on until the rider touches the map, and comes back when they ask for it. Without
|
||||
// the hand-over, every incoming CAM would drag the viewport back to the ego position and the
|
||||
// map could not be panned at all while traffic is flowing.
|
||||
var followOwn by remember { mutableStateOf(true) }
|
||||
|
||||
// SPATEM carries no geometry of its own, so an intersection can only be placed if its RSU has
|
||||
// also been heard over CAM. Saying so is better than silently dropping it: "the map shows two
|
||||
// of the three lights I can see in the list" is otherwise an unexplained discrepancy.
|
||||
val unlocatedSpats = spats.count { it.stationId !in stations.keys }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
V2xLiveMapView(
|
||||
own = ownCamPosition,
|
||||
remotes = stations,
|
||||
alerts = alerts,
|
||||
denms = denms,
|
||||
spats = spats,
|
||||
followOwn = followOwn,
|
||||
onUserPanned = { followOwn = false },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
// ── Floating header: back + live counts, which double as the legend ──
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MapChrome {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.v2x_map_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
MapChrome {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MapCount(
|
||||
icon = Icons.Default.Place,
|
||||
tint = CamPinBlue,
|
||||
count = stations.size,
|
||||
label = stringResource(R.string.v2x_map_legend_cam),
|
||||
)
|
||||
MapCount(
|
||||
icon = Icons.Default.Warning,
|
||||
tint = HazardAmber,
|
||||
count = denms.size,
|
||||
label = stringResource(R.string.v2x_map_legend_denm),
|
||||
)
|
||||
MapCount(
|
||||
icon = Icons.Default.Traffic,
|
||||
tint = SignalGreenDot,
|
||||
count = spats.size,
|
||||
label = stringResource(R.string.v2x_map_legend_spat),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unlocatedSpats > 0) {
|
||||
MapChrome(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.v2x_map_spat_unlocated, unlocatedSpats),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recentre: lit while following, so the button also reports which mode the map is in.
|
||||
FloatingActionButton(
|
||||
onClick = { followOwn = true },
|
||||
containerColor = if (followOwn) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MyLocation,
|
||||
contentDescription = stringResource(R.string.v2x_map_follow),
|
||||
tint = if (followOwn) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A translucent pill for anything floating over the map, so chrome stays readable over tiles. */
|
||||
@Composable
|
||||
private fun MapChrome(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = modifier,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapCount(icon: ImageVector, tint: Color, count: Int, label: String) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Legend tints, matching the marker artwork rather than the theme: these name the drawables on
|
||||
// the map, so they must not shift with light/dark mode the way theme colours do.
|
||||
private val CamPinBlue = Color(0xFF78909C)
|
||||
private val HazardAmber = Color(0xFFFFC107)
|
||||
private val SignalGreenDot = Color(0xFF4CAF50)
|
||||
@@ -13,8 +13,11 @@ import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
||||
import com.hawhamburg.micr0bu.data.transport.StationStatus
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||
@@ -45,7 +48,7 @@ class MqttViewModel @Inject constructor(
|
||||
private val usbDetector: UsbNetworkDetector,
|
||||
private val camUseCaseRepository: CamUseCaseRepository,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val esp32Link: Esp32Link,
|
||||
private val camPinger: CamPinger,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -80,14 +83,42 @@ class MqttViewModel @Inject constructor(
|
||||
/** Auto-detected OBU gateway IP on the USB interface. */
|
||||
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
|
||||
|
||||
/** ESP32-C5 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */
|
||||
val usbSerialState: StateFlow<UsbSerialState> = usbSerialTransport.state
|
||||
/** ESP32-C5 link state, over USB or BLE per [esp32Transport] — see [Esp32Link]. */
|
||||
val esp32LinkState: StateFlow<Esp32LinkState> = esp32Link.state
|
||||
|
||||
/** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */
|
||||
val espLinkStatus: StateFlow<EspLinkStatus?> = usbSerialTransport.linkStatus
|
||||
val espLinkStatus: StateFlow<EspLinkStatus?> = esp32Link.linkStatus
|
||||
|
||||
/** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */
|
||||
val camSendFailures: StateFlow<Int> = usbSerialTransport.consecutiveWriteFailures
|
||||
/** Non-zero means CAMs are being built and dropped — see [Esp32Link.send]. */
|
||||
val camSendFailures: StateFlow<Int> = esp32Link.consecutiveWriteFailures
|
||||
|
||||
/** Signing and radio counters of the current obu-firmware; null with the previous firmware. */
|
||||
val stationStatus: StateFlow<StationStatus?> = esp32Link.stationStatus
|
||||
|
||||
/** One line about the link session (pairing passkey, provisioning, refusals); null when quiet. */
|
||||
val esp32Detail: StateFlow<String?> = esp32Link.detail
|
||||
|
||||
// ── ESP32-C5 settings ─────────────────────────────────────────────────────
|
||||
|
||||
val esp32Transport: StateFlow<Esp32Transport> = esp32Link.transport
|
||||
|
||||
fun setEsp32Transport(transport: Esp32Transport) {
|
||||
viewModelScope.launch { obuHardwarePrefs.setEsp32Transport(transport) }
|
||||
}
|
||||
|
||||
val outgoingMessage: StateFlow<OutgoingMessage> = obuHardwarePrefs.outgoingMessageFlow
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, OutgoingMessage.CAM)
|
||||
|
||||
fun setOutgoingMessage(message: OutgoingMessage) {
|
||||
viewModelScope.launch { obuHardwarePrefs.setOutgoingMessage(message) }
|
||||
}
|
||||
|
||||
val signOutgoing: StateFlow<Boolean> = obuHardwarePrefs.signOutgoingFlow
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setSignOutgoing(sign: Boolean) {
|
||||
viewModelScope.launch { obuHardwarePrefs.setSignOutgoing(sign) }
|
||||
}
|
||||
|
||||
// ── ESP32-C5 CAM pinger (manual bench test, Phase 03) ─────────────────────
|
||||
// The ESP32-C5-path equivalent of the CiT One's manual DENM trigger below — a fixed-
|
||||
@@ -101,7 +132,24 @@ class MqttViewModel @Inject constructor(
|
||||
/** False while the pinger runs without a GNSS fix — it has no position to build a CAM from. */
|
||||
val camPingerHasFix: StateFlow<Boolean> = camPinger.hasFix
|
||||
|
||||
fun startCamPinger() = camPinger.start()
|
||||
/**
|
||||
* Own transmissions heard back off the air, null until one is.
|
||||
*
|
||||
* This is the pinger's actual proof of life. [camPingerSentCount] only says frames were
|
||||
* handed to the ESP32; this says they went out and came back, which is the round trip the
|
||||
* bench test is there to demonstrate. See
|
||||
* [com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback].
|
||||
*/
|
||||
val ownTxLoopback: StateFlow<com.hawhamburg.micr0bu.domain.cam.OwnTxLoopback?> =
|
||||
camUseCaseRepository.ownTxLoopback
|
||||
|
||||
fun startCamPinger() {
|
||||
// Reset first, so the tally counts this run rather than accumulating across runs and
|
||||
// making the comparison against sent count meaningless.
|
||||
camUseCaseRepository.resetOwnTxLoopback()
|
||||
camPinger.start()
|
||||
}
|
||||
|
||||
fun stopCamPinger() = camPinger.stop()
|
||||
|
||||
// ── Prefs ─────────────────────────────────────────────────────────────────
|
||||
@@ -127,13 +175,27 @@ class MqttViewModel @Inject constructor(
|
||||
val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow()
|
||||
|
||||
/**
|
||||
* True when the OBU has reported a stationType other than 2 (cyclist).
|
||||
* True when the CiT One has reported a stationType other than 2 (cyclist).
|
||||
* Triggers a persistent warning banner — an incorrect stationType means this OBU will
|
||||
* not be detected as a VRU at equipped intersections.
|
||||
*
|
||||
* Suppressed in ESP32-C5 mode. The value behind it comes from the CiT One's
|
||||
* `v2x/rx/obu_gnss` topic, which the ESP32-C5 does not publish, so a warning raised before a
|
||||
* mode switch would otherwise stay on screen reporting on an OBU that is no longer in use.
|
||||
* There is nothing for it to warn about on that path either: the phone builds its own CAM
|
||||
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]), which sets stationType to cyclist
|
||||
* locally rather than reading it back from an OBU.
|
||||
*
|
||||
* The underlying [obuStationType] is deliberately not cleared on the switch. It remains the
|
||||
* last thing that OBU actually said, and obu_gnss refreshes it at ~4 Hz on returning to the
|
||||
* CiT One path, so the warning re-evaluates against fresh data within a fraction of a second.
|
||||
*/
|
||||
val obuStationTypeWarning: StateFlow<Boolean> = _obuStationType
|
||||
.map { it != null && it != 2 }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
val obuStationTypeWarning: StateFlow<Boolean> = combine(
|
||||
_obuStationType,
|
||||
repo.obuHardware,
|
||||
) { stationType, hardware ->
|
||||
hardware == ObuHardware.CIT_ONE && stationType != null && stationType != 2
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
// ── DENM reception (live map hazard pins) ─────────────────────────────────
|
||||
|
||||
@@ -141,11 +203,16 @@ class MqttViewModel @Inject constructor(
|
||||
* Hazards received from other stations, newest first, deduped by [DenmEvent.dedupKey] so a
|
||||
* repeating DENM about the same hazard stays one pin instead of stacking up.
|
||||
*
|
||||
* Two sources, merged: the CiT One path's `v2x-uca/output/json/denm` MQTT topic (parsed by
|
||||
* [DenmParser]), and the ESP32-C5 path's over-the-air DENMs (GeoBroadcast, BTP port 2002,
|
||||
* decoded by [com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec]). Only one is ever active at a
|
||||
* time since the hardware selection decides the transport, so merging costs nothing and keeps
|
||||
* the UI transport-agnostic.
|
||||
* Two sources, merged: the CiT One Use Case app's `v2x-uca/output/json/denm` MQTT topic
|
||||
* (parsed by [DenmParser]), and UPER decoded by
|
||||
* [com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec] from whichever raw path is live, the
|
||||
* ESP32-C5 serial link or the CiT One's `v2x/rx/denm` protobuf topic.
|
||||
*
|
||||
* Where both describe the same hazard, the decoded one wins. Both key on ETSI's actionID, so
|
||||
* the `associateBy` below collapses them to one entry, and the decoded list is concatenated
|
||||
* second so it is the one that survives. That is the intended preference: the Use Case app
|
||||
* rate-limits and drops messages, and reduces what it does publish to the fields it cared
|
||||
* about, so it can only ever be a lossier account of the same event.
|
||||
*
|
||||
* Events carrying `termination` are filtered out rather than shown — the hazard is over.
|
||||
*/
|
||||
@@ -156,7 +223,7 @@ class MqttViewModel @Inject constructor(
|
||||
},
|
||||
// Air DENMs accumulate here rather than being a snapshot: the serial path delivers one
|
||||
// event at a time, so runningFold keeps the set of hazards heard so far.
|
||||
camUseCaseRepository.airDenm
|
||||
camUseCaseRepository.decodedDenm
|
||||
.runningFold(emptyMap<String, DenmEvent>()) { acc, denm -> acc + (denm.dedupKey to denm) }
|
||||
.map { it.values.toList() },
|
||||
// Expiry has to be driven by a clock, not by arrivals. Both upstream flows only re-emit
|
||||
@@ -164,11 +231,11 @@ class MqttViewModel @Inject constructor(
|
||||
// power, leaves range - would otherwise leave its hazard on the map forever: there is no
|
||||
// further emission to recompute the list. This tick is what makes a hazard fade.
|
||||
tickerFlow(DENM_EXPIRY_TICK_MS),
|
||||
) { fromMqtt, fromAir, _ ->
|
||||
) { fromUseCaseApp, fromDecoder, _ ->
|
||||
val now = System.currentTimeMillis()
|
||||
(fromMqtt + fromAir)
|
||||
(fromUseCaseApp + fromDecoder)
|
||||
.filterNot { it.isTermination } // the hazard is over - stop drawing it
|
||||
.associateBy { it.dedupKey } // last write wins = most recent per hazard
|
||||
.associateBy { it.dedupKey } // last write wins, so the decoded one is kept
|
||||
.values
|
||||
// Not heard from in DENM_TTL_MS: treat as gone. DENMs repeat at roughly 1 Hz, so a
|
||||
// full minute of silence is ~60 missed repetitions - well past "we briefly lost one".
|
||||
@@ -179,15 +246,16 @@ class MqttViewModel @Inject constructor(
|
||||
/**
|
||||
* Live signal state per intersection, newest first, keyed by [IntersectionSignalState.key].
|
||||
*
|
||||
* ESP32-C5 path only: SPATEM arrives over the air on BTP port 2004. The CiT One path publishes
|
||||
* SPATEM on its own MQTT topic in a different (protobuf-wrapped) shape, which is not wired up.
|
||||
* Both hardware paths: SPATEM arrives over the air on BTP port 2004 via the ESP32-C5 serial
|
||||
* link, or on the CiT One's `v2x/rx/spatem` protobuf topic. The CiT One's processed
|
||||
* `v2x-uca/output/json/spat` topic is not used, since the raw topic carries every repetition.
|
||||
*
|
||||
* One entry per intersection, not per message: SPATEM repeats at ~2 Hz per RSU, so a log would
|
||||
* grow without telling anyone anything. Entries expire like DENMs do - an intersection left
|
||||
* behind stops transmitting, and the same clock-driven argument applies.
|
||||
*/
|
||||
val spatIntersections: StateFlow<List<SpatIntersection>> = combine(
|
||||
camUseCaseRepository.airSpat
|
||||
camUseCaseRepository.decodedSpat
|
||||
.runningFold(emptyMap<String, SpatIntersection>()) { acc, spat ->
|
||||
acc + spat.intersections.associate { i ->
|
||||
i.key to SpatIntersection(i, spat.stationId, spat.rssiDbm, spat.timestamp)
|
||||
@@ -315,10 +383,10 @@ class MqttViewModel @Inject constructor(
|
||||
fun connect() = repo.connect()
|
||||
fun disconnect() = repo.disconnect()
|
||||
|
||||
/** Connect/disconnect the ESP32-C5 USB-serial link — separate from [connect]/[disconnect],
|
||||
* which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. See [ConnectionSetupScreen]. */
|
||||
fun connectUsbSerial() = usbSerialTransport.connect()
|
||||
fun disconnectUsbSerial() = usbSerialTransport.disconnect()
|
||||
/** Connect/disconnect the ESP32-C5 link (USB or BLE per [esp32Transport]) — separate from
|
||||
* [connect]/[disconnect], which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. */
|
||||
fun connectEsp32() = esp32Link.connect()
|
||||
fun disconnectEsp32() = esp32Link.disconnect()
|
||||
|
||||
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
|
||||
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
|
||||
@@ -352,7 +420,7 @@ class MqttViewModel @Inject constructor(
|
||||
super.onCleared()
|
||||
repo.disconnect()
|
||||
camPinger.stop()
|
||||
// Deliberately NOT usbSerialTransport.disconnect(): the transport is an app-scoped
|
||||
// Deliberately NOT esp32Link.disconnect(): the link is an app-scoped
|
||||
// @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop).
|
||||
// Closing it here would tear the port down when the Activity goes away — e.g. swiping
|
||||
// the app from Recents mid-recording — leaving the still-running service beaconing into
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.hawhamburg.micr0bu.data.TripRepository
|
||||
import com.hawhamburg.micr0bu.data.shareTripCsv
|
||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||
import com.hawhamburg.micr0bu.service.TripRecordingService
|
||||
import com.hawhamburg.micr0bu.service.TripServiceBus
|
||||
@@ -66,20 +65,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
||||
/** All recorded trips, newest first. */
|
||||
val trips: Flow<List<RecordedTripEntity>> = repository.getAllTrips()
|
||||
|
||||
// ── Trip review ───────────────────────────────────────────────────────────
|
||||
|
||||
private val _selectedTripEvents = MutableStateFlow<List<DetectedEventEntity>>(emptyList())
|
||||
val selectedTripEvents: StateFlow<List<DetectedEventEntity>> = _selectedTripEvents.asStateFlow()
|
||||
|
||||
/** Load events for [tripId] into [selectedTripEvents]. */
|
||||
fun loadTripEvents(tripId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.getEventsForTrip(tripId).collect { events ->
|
||||
_selectedTripEvents.value = events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recording control ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -132,7 +117,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
||||
shareTripCsv(
|
||||
context = context,
|
||||
trip = trip,
|
||||
events = repository.getEventsForTripOnce(tripId),
|
||||
v2xMessages = repository.getV2xMessagesForTripOnce(tripId),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -31,6 +31,19 @@
|
||||
<string name="dash_samples">Messwerte</string>
|
||||
<string name="dash_start_driving_session">Fahrsitzung starten</string>
|
||||
<string name="dash_initialising">Wird initialisiert…</string>
|
||||
|
||||
<!-- Dashboard live V2X panel: nearest hazard and nearest signalised intersection -->
|
||||
<string name="dash_hazard_warning">Gefahrenwarnung</string>
|
||||
<string name="dash_hazard_station">Station %1$d</string>
|
||||
<string name="dash_more_count">+%1$d weitere</string>
|
||||
<string name="dash_signal_title">Ampel %1$s</string>
|
||||
<string name="dash_signal_more">+%1$d weitere Kreuzung(en)</string>
|
||||
<string name="dash_signal_phase_go">Grün</string>
|
||||
<string name="dash_signal_phase_stop">Rot</string>
|
||||
<string name="dash_signal_phase_changing">Wechselt</string>
|
||||
<string name="dash_signal_phase_dark">Dunkel</string>
|
||||
<string name="dash_signal_phase_unknown">Unbekannt</string>
|
||||
<string name="dash_signal_countdown">%1$s · %2$.0f s</string>
|
||||
<string name="stat_pressure">Luftdruck</string>
|
||||
<string name="stat_altitude">Höhe</string>
|
||||
<string name="stat_heading">Richtung</string>
|
||||
@@ -129,12 +142,18 @@
|
||||
<string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
|
||||
<string name="map_title">Standortkarte</string>
|
||||
<string name="map_location_label">Aktueller Standort</string>
|
||||
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
|
||||
<string name="v2x_map_own_label">Eigen (Ego)</string>
|
||||
<string name="v2x_map_remote_plain">Extern #%1$d</string>
|
||||
<string name="v2x_map_remote_info">Extern #%1$d · Info</string>
|
||||
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
|
||||
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
|
||||
<string name="v2x_map_title">V2X-Live-Karte</string>
|
||||
<string name="v2x_map_back">Zurück</string>
|
||||
<string name="v2x_map_follow">Auf eigene Position zentrieren</string>
|
||||
<string name="v2x_map_legend_cam">Verkehrsteilnehmer (CAM)</string>
|
||||
<string name="v2x_map_legend_denm">Gefahren (DENM)</string>
|
||||
<string name="v2x_map_legend_spat">Signale (SPATEM)</string>
|
||||
<string name="v2x_map_spat_unlocated">%1$d Signal(e) nicht dargestellt - Senderposition unbekannt</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
@@ -170,7 +189,7 @@
|
||||
<string name="settings_obu_hardware">OBU-Hardware</string>
|
||||
<string name="settings_obu_hardware_cit_one">CiT One</string>
|
||||
<string name="settings_obu_hardware_esp32">ESP32-C5</string>
|
||||
<string name="settings_obu_hardware_esp32_note">Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
|
||||
<string name="settings_obu_hardware_esp32_note">Das Smartphone erstellt CAM oder VAM und übergibt sie per USB-C oder Bluetooth an den ESP32-C5; der ESP32 ergänzt GeoNetworking, signiert (optional) und sendet über ITS-G5. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
|
||||
<string name="settings_usb_transport">Aktiver Transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">WLAN</string>
|
||||
@@ -184,7 +203,6 @@
|
||||
<string name="mqtt_no_topics">Noch keine Nachrichten</string>
|
||||
<string name="mqtt_view_list">Liste</string>
|
||||
<string name="mqtt_view_topics">Topics</string>
|
||||
<string name="mqtt_view_map">Karte</string>
|
||||
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
|
||||
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
|
||||
|
||||
@@ -234,6 +252,8 @@
|
||||
<string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string>
|
||||
<string name="mqtt_cam_pinger_send_failures">Schreibfehler: %1$d in Folge - CAMs erreichen den ESP32 nicht</string>
|
||||
<string name="mqtt_cam_pinger_fw_counters">ESP32: TX-Fehler %1$d · zu groß %2$d · CRC-Fehler %3$d</string>
|
||||
<string name="mqtt_cam_pinger_loopback">Eigene Sendung empfangen: %1$d Frames · %2$d dBm</string>
|
||||
<string name="mqtt_cam_pinger_loopback_no_rssi">Eigene Sendung empfangen: %1$d Frames</string>
|
||||
<string name="mqtt_start_pinger">Pinger starten</string>
|
||||
<string name="mqtt_stop_pinger">Pinger stoppen</string>
|
||||
|
||||
@@ -277,10 +297,6 @@
|
||||
<string name="nav_trips">Fahrten</string>
|
||||
|
||||
<!-- Phase A: Recording screen event counters -->
|
||||
<string name="rec_events_detected">Erkannte Ereignisse</string>
|
||||
<string name="rec_event_braking">Bremsen</string>
|
||||
<string name="rec_event_turning">Abbiegen</string>
|
||||
<string name="rec_event_stopping">Anhalten</string>
|
||||
<string name="rec_stream_event_detection">Ereigniserkennung</string>
|
||||
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
||||
|
||||
@@ -302,4 +318,18 @@
|
||||
<string name="v2x_spat_rx_title">Kreuzung %1$s · Station %2$d</string>
|
||||
<string name="v2x_spat_group">SG%1$d</string>
|
||||
<string name="v2x_spat_countdown">%1$.0f s</string>
|
||||
<string name="settings_esp32_link">ESP32-C5-Verbindung</string>
|
||||
<string name="settings_esp32_link_ble">Bluetooth</string>
|
||||
<string name="settings_esp32_link_ble_note">Beim ersten Verbinden wird das Koppeln mit micrOBU-XXXX angefragt: Passkey 123456 eingeben. Die Platine wirbt nur, solange ihr USB-C-Port nicht benutzt wird. BLE teilt sich das Funk-Frontend mit ITS-G5; der Einfluss auf den 5,9-GHz-Empfang ist noch nicht gemessen.</string>
|
||||
<string name="settings_esp32_message">Senden während der Aufzeichnung</string>
|
||||
<string name="settings_esp32_message_cam">CAM</string>
|
||||
<string name="settings_esp32_message_vam">VAM</string>
|
||||
<string name="settings_esp32_sign">Ausgehende Nachrichten signieren</string>
|
||||
<string name="settings_esp32_sign_note">Signiert mit einer Demo-PKI, nicht der EU-Vertrauensliste: Empfänger, die dagegen prüfen, verwerfen diese Nachrichten. Aus sendet sie wie bisher unsigniert.</string>
|
||||
<string name="conn_esp32_via_usb">über USB-C (nativer Port)</string>
|
||||
<string name="conn_esp32_via_ble">über Bluetooth (Passkey 123456 beim ersten Koppeln)</string>
|
||||
<string name="conn_esp32_cancel">Abbrechen</string>
|
||||
<string name="conn_esp32_signing">Signieren %1$s · Tickets %2$d · signiert %3$d · abgelehnt %4$d · gesendet %5$d</string>
|
||||
<string name="conn_esp32_signing_on">an</string>
|
||||
<string name="conn_esp32_signing_off">aus</string>
|
||||
</resources>
|
||||
|
||||
@@ -32,6 +32,19 @@
|
||||
<string name="dash_samples">samples</string>
|
||||
<string name="dash_start_driving_session">Start Driving Session</string>
|
||||
<string name="dash_initialising">Initialising…</string>
|
||||
|
||||
<!-- Dashboard live V2X panel: nearest hazard and nearest signalised intersection -->
|
||||
<string name="dash_hazard_warning">Hazard warning</string>
|
||||
<string name="dash_hazard_station">station %1$d</string>
|
||||
<string name="dash_more_count">+%1$d more</string>
|
||||
<string name="dash_signal_title">Traffic light %1$s</string>
|
||||
<string name="dash_signal_more">+%1$d more intersection(s)</string>
|
||||
<string name="dash_signal_phase_go">Green</string>
|
||||
<string name="dash_signal_phase_stop">Red</string>
|
||||
<string name="dash_signal_phase_changing">Changing</string>
|
||||
<string name="dash_signal_phase_dark">Dark</string>
|
||||
<string name="dash_signal_phase_unknown">Unknown</string>
|
||||
<string name="dash_signal_countdown">%1$s · %2$.0f s</string>
|
||||
<string name="stat_pressure">Pressure</string>
|
||||
<string name="stat_altitude">Altitude</string>
|
||||
<string name="stat_heading">Heading</string>
|
||||
@@ -130,12 +143,18 @@
|
||||
<string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
|
||||
<string name="map_title">Location Map</string>
|
||||
<string name="map_location_label">Current Location</string>
|
||||
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
|
||||
<string name="v2x_map_own_label">Own (ego)</string>
|
||||
<string name="v2x_map_remote_plain">Remote #%1$d</string>
|
||||
<string name="v2x_map_remote_info">Remote #%1$d · Info</string>
|
||||
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
|
||||
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
|
||||
<string name="v2x_map_title">V2X Live Map</string>
|
||||
<string name="v2x_map_back">Back</string>
|
||||
<string name="v2x_map_follow">Centre on own position</string>
|
||||
<string name="v2x_map_legend_cam">Road users (CAM)</string>
|
||||
<string name="v2x_map_legend_denm">Hazards (DENM)</string>
|
||||
<string name="v2x_map_legend_spat">Signals (SPATEM)</string>
|
||||
<string name="v2x_map_spat_unlocated">%1$d signal(s) not shown - sender position unknown</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
@@ -171,7 +190,7 @@
|
||||
<string name="settings_obu_hardware">OBU Hardware</string>
|
||||
<string name="settings_obu_hardware_cit_one">CiT One</string>
|
||||
<string name="settings_obu_hardware_esp32">ESP32-C5</string>
|
||||
<string name="settings_obu_hardware_esp32_note">ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
|
||||
<string name="settings_obu_hardware_esp32_note">The phone builds CAM or VAM and hands it to the ESP32-C5 over USB-C or Bluetooth; the ESP32 adds GeoNetworking, signs it (optional) and broadcasts it over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
|
||||
<string name="settings_usb_transport">Active transport</string>
|
||||
<string name="settings_transport_usbc">USB-C</string>
|
||||
<string name="settings_transport_wifi">Wi-Fi</string>
|
||||
@@ -185,7 +204,6 @@
|
||||
<string name="mqtt_no_topics">No messages yet</string>
|
||||
<string name="mqtt_view_list">List</string>
|
||||
<string name="mqtt_view_topics">Topics</string>
|
||||
<string name="mqtt_view_map">Map</string>
|
||||
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
|
||||
<string name="mqtt_no_messages">No messages on this topic yet</string>
|
||||
|
||||
@@ -246,7 +264,9 @@
|
||||
<string name="mqtt_cam_pinger_active">Pinging - 1 CAM/s over the serial link</string>
|
||||
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
|
||||
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string>
|
||||
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d</string>
|
||||
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d · rx queue drop %4$d</string>
|
||||
<string name="mqtt_cam_pinger_loopback">Own TX heard back: %1$d frames · %2$d dBm</string>
|
||||
<string name="mqtt_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string>
|
||||
<string name="mqtt_start_pinger">Start Pinger</string>
|
||||
<string name="mqtt_stop_pinger">Stop Pinger</string>
|
||||
|
||||
@@ -290,10 +310,6 @@
|
||||
<string name="nav_trips">Trips</string>
|
||||
|
||||
<!-- Phase A: Recording screen event counters -->
|
||||
<string name="rec_events_detected">Detected Events</string>
|
||||
<string name="rec_event_braking">Braking</string>
|
||||
<string name="rec_event_turning">Turning</string>
|
||||
<string name="rec_event_stopping">Stopping</string>
|
||||
<string name="rec_stream_event_detection">Event Detection</string>
|
||||
<string name="rec_open_session_log">CSV Session Log</string>
|
||||
|
||||
@@ -309,4 +325,18 @@
|
||||
|
||||
<!-- Phase A: Trip Review screen -->
|
||||
<string name="trip_review_title">Trip Review</string>
|
||||
<string name="settings_esp32_link">ESP32-C5 link</string>
|
||||
<string name="settings_esp32_link_ble">Bluetooth</string>
|
||||
<string name="settings_esp32_link_ble_note">The first connection asks to pair with micrOBU-XXXX: enter passkey 123456. The board only advertises while nothing uses its USB-C port. BLE shares the radio front end with ITS-G5; its effect on 5.9 GHz reception has not been measured yet.</string>
|
||||
<string name="settings_esp32_message">Transmit while recording</string>
|
||||
<string name="settings_esp32_message_cam">CAM</string>
|
||||
<string name="settings_esp32_message_vam">VAM</string>
|
||||
<string name="settings_esp32_sign">Sign outgoing messages</string>
|
||||
<string name="settings_esp32_sign_note">Signed with a demo PKI, not the EU trust list: receivers that verify against it will drop these messages. Off sends them unsigned, as before.</string>
|
||||
<string name="conn_esp32_via_usb">via USB-C (native port)</string>
|
||||
<string name="conn_esp32_via_ble">via Bluetooth (passkey 123456 on first pairing)</string>
|
||||
<string name="conn_esp32_cancel">Cancel</string>
|
||||
<string name="conn_esp32_signing">Signing %1$s · tickets %2$d · signed %3$d · refused %4$d · on air %5$d</string>
|
||||
<string name="conn_esp32_signing_on">on</string>
|
||||
<string name="conn_esp32_signing_off">off</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone side of SERIAL_MSG_CAM_TX_PV: the 24-byte prefix the ESP32 turns into the
|
||||
* GeoNetworking Source Position Vector, and the heartbeat capability bit that decides whether the
|
||||
* phone may send that message at all.
|
||||
*
|
||||
* ## Where the expected bytes come from
|
||||
* Not from this code. They were produced with Python's `struct.pack("<IiihH", ...)` from the
|
||||
* layout documented at SERIAL_MSG_CAM_TX_PV in `serial_link.h`, independently of this encoder, so
|
||||
* an agreement here is not an encoder agreeing with itself.
|
||||
*
|
||||
* That same `struct.pack` call is what the bench harness used on 2026-09-10 to drive an
|
||||
* ESP32-C5 over its native USB port with this message. The CiT One OBU, an independent
|
||||
* GeoNetworking stack, decoded every Source Position Vector field of the resulting
|
||||
* transmissions (station type, PAI, latitude, longitude, speed, heading and timestamp) back to
|
||||
* the values sent. These are bytes a third-party receiver has accepted on air, not only bytes
|
||||
* this app agrees with.
|
||||
*/
|
||||
class CamTxPvSerialTest {
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
private fun ByteArray.u32le(at: Int): Long =
|
||||
(0 until 4).fold(0L) { acc, i -> acc or ((this[at + i].toLong() and 0xFF) shl (8 * i)) }
|
||||
|
||||
// ---- the wire layout -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `encodes the prefix byte for byte`() {
|
||||
val pv = GnPositionVector(
|
||||
mac = "024d49435230".hexToBytes(),
|
||||
stationType = 2,
|
||||
pai = true,
|
||||
tstMs = 0x12345678L,
|
||||
latTenMicroDeg = 535_543_026,
|
||||
lonTenMicroDeg = 100_226_476,
|
||||
speedCms = 543,
|
||||
headingDeciDeg = 1234,
|
||||
)
|
||||
// 024d49435230 | 02 | 01 | 78563412 | f2bceb1f | ac55f905 | 1f02 | d204
|
||||
assertEquals("024d49435230020178563412f2bceb1fac55f9051f02d204", pv.toSerialPrefix().toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encodes negative, extreme and flag-clear values`() {
|
||||
// Southern and western hemisphere, full reverse speed, heading at its maximum, PAI clear:
|
||||
// the sign handling that a northern-hemisphere bench test never exercises.
|
||||
val pv = GnPositionVector(
|
||||
mac = "020000000001".hexToBytes(),
|
||||
stationType = 2,
|
||||
pai = false,
|
||||
tstMs = 0xFFFF_FFFFL,
|
||||
latTenMicroDeg = -335_543_026,
|
||||
lonTenMicroDeg = -100_226_476,
|
||||
speedCms = -16384,
|
||||
headingDeciDeg = 3599,
|
||||
)
|
||||
assertEquals("0200000000010200ffffffff0e0500ec54aa06fa00c00f0e", pv.toSerialPrefix().toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the timestamp is reduced modulo 2^32 on the wire`() {
|
||||
// TimestampIts passed 2^32 ms about 49.7 days after its 2004 epoch, so every real value
|
||||
// today is wider than 32 bits and the reduction is the normal case, not an edge case.
|
||||
val pv = vectorAt(tstMs = 716_121_572_779L)
|
||||
assertEquals(3_157_001_643L, pv.toSerialPrefix().u32le(8))
|
||||
}
|
||||
|
||||
// ---- building it from a CAM ------------------------------------------------------------
|
||||
|
||||
private val cam = Cam(
|
||||
stationId = 1_234_567_890L,
|
||||
stationType = StationType.CYCLIST,
|
||||
latitude = 53.5543026,
|
||||
longitude = 10.0226476,
|
||||
speedMps = 5.43,
|
||||
headingDeg = 123.4,
|
||||
yawRateDps = null,
|
||||
accelerationMps2 = null,
|
||||
timestamp = 1_789_036_772_779L,
|
||||
isOwn = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fromCam takes the same values the CAM payload carries`() {
|
||||
val pv = GnPositionVector.fromCam(cam, accuracyM = 5f, mac = "024d49435230".hexToBytes())
|
||||
assertEquals(2, pv.stationType)
|
||||
assertEquals(535_543_026, pv.latTenMicroDeg)
|
||||
assertEquals(100_226_476, pv.lonTenMicroDeg)
|
||||
assertEquals(543, pv.speedCms)
|
||||
assertEquals(1234, pv.headingDeciDeg)
|
||||
assertTrue(pv.pai)
|
||||
// The GN TST and the CAM's generationDeltaTime must follow one time rule.
|
||||
assertEquals(ItsTime.timestampIts(cam.timestamp), pv.tstMs)
|
||||
assertEquals(716_121_572_779L, pv.tstMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `speed is clamped to the 15-bit field, never wrapped`() {
|
||||
// A wrapped 15-bit speed flips its sign bit and reads as reversing at speed.
|
||||
assertEquals(16383, GnPositionVector.fromCam(cam.copy(speedMps = 400.0), 5f, mac).speedCms)
|
||||
assertEquals(-16384, GnPositionVector.fromCam(cam.copy(speedMps = -400.0), 5f, mac).speedCms)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `heading wraps into 0 to 3599`() {
|
||||
assertEquals(0, GnPositionVector.fromCam(cam.copy(headingDeg = 360.0), 5f, mac).headingDeciDeg)
|
||||
assertEquals(50, GnPositionVector.fromCam(cam.copy(headingDeg = 725.0), 5f, mac).headingDeciDeg)
|
||||
assertEquals(3590, GnPositionVector.fromCam(cam.copy(headingDeg = -1.0), 5f, mac).headingDeciDeg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-finite speed or heading does not throw`() {
|
||||
val pv = GnPositionVector.fromCam(
|
||||
cam.copy(speedMps = Double.NaN, headingDeg = Double.POSITIVE_INFINITY), 5f, mac,
|
||||
)
|
||||
assertEquals(0, pv.speedCms)
|
||||
assertEquals(0, pv.headingDeciDeg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PAI follows the horizontal accuracy`() {
|
||||
assertTrue(GnPositionVector.fromCam(cam, GnPositionVector.PAI_MAX_ACCURACY_M, mac).pai)
|
||||
assertFalse(GnPositionVector.fromCam(cam, 25f, mac).pai)
|
||||
// Android reports 0 when it has no accuracy estimate: unknown is not accurate.
|
||||
assertFalse(GnPositionVector.fromCam(cam, 0f, mac).pai)
|
||||
assertFalse(GnPositionVector.fromCam(cam, null, mac).pai)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `an address that is not six bytes is rejected`() {
|
||||
GnPositionVector.fromCam(cam, 5f, ByteArray(5))
|
||||
}
|
||||
|
||||
// ---- capability negotiation ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `firmware that predates the capability byte advertises nothing`() {
|
||||
// Old firmware sends a 7-byte heartbeat. Reading that as "no CAM_TX_PV" is what keeps a
|
||||
// new app on the legacy message, which that firmware still understands.
|
||||
val status = EspLinkStatus.parse("00000000000000".hexToBytes())!!
|
||||
assertEquals(0, status.capabilities)
|
||||
assertFalse(status.supportsCamTxPv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firmware that advertises CAM_TX_PV is recognised`() {
|
||||
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
|
||||
assertTrue(status.supportsCamTxPv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a capability byte without the CAM_TX_PV bit does not enable it`() {
|
||||
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firmware that predates the rx queue drop counter reports zero`() {
|
||||
// 8-byte heartbeat (status + counters + capabilities, no rx queue drops tail).
|
||||
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
|
||||
assertEquals(0, status.rxQueueDrops)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rx queue drops are read little-endian from the 10-byte payload`() {
|
||||
// status=0, oversize=0, txFail=0, rxCrc=0, capabilities=0x01, rxQueueDrops=0x0102 (LE: 02 01)
|
||||
val status = EspLinkStatus.parse("00000000000000010201".hexToBytes())!!
|
||||
assertEquals(0x0102, status.rxQueueDrops)
|
||||
assertTrue(status.supportsCamTxPv)
|
||||
}
|
||||
|
||||
private val mac = "024d49435230".hexToBytes()
|
||||
|
||||
private fun vectorAt(tstMs: Long) = GnPositionVector(
|
||||
mac = mac, stationType = 2, pai = false, tstMs = tstMs,
|
||||
latTenMicroDeg = 0, lonTenMicroDeg = 0, speedCms = 0, headingDeciDeg = 0,
|
||||
)
|
||||
|
||||
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -21,34 +21,34 @@ import kotlin.math.sqrt
|
||||
*
|
||||
* No Android emulator required — all production classes have zero Android imports.
|
||||
*
|
||||
* The test [config] uses a smaller window and fewer sustained frames than the
|
||||
* production defaults so tests run in milliseconds without generating thousands
|
||||
* of synthetic samples.
|
||||
* The test [config] shortens only the window and the sustained-frame counts, so
|
||||
* tests run in milliseconds instead of generating thousands of synthetic
|
||||
* samples. Every *signal* threshold is inherited from [DetectionConfig]'s
|
||||
* defaults, which are the values the app actually runs — the two cannot drift
|
||||
* apart, which they previously did: the service overrode nine of the twelve
|
||||
* parameters and these tests validated the un-overridden ones.
|
||||
*
|
||||
* Accel-std-dev notes
|
||||
* -------------------
|
||||
* A production threshold of 1.2 m/s² requires genuine variability in the window.
|
||||
* In the "hard brake" tests we alternate between high and low accel values
|
||||
* (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window.
|
||||
* The braking accel-std-dev threshold of 1.8 m/s² requires genuine variability
|
||||
* in the window. In the "hard brake" tests we alternate between high and low
|
||||
* accel values (4.5 / 0.5), which yields a population std dev of |hi − lo| / 2
|
||||
* = 2.0 in a full window — above the threshold with margin.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EventDetectorTest {
|
||||
|
||||
/** Tighter config so fewer frames are needed to trigger each event. */
|
||||
/**
|
||||
* Shortens the window and the sustained-frame counts so fewer synthetic frames are
|
||||
* needed per test. Every signal threshold is deliberately left at its default, so
|
||||
* these tests exercise the thresholds the app ships with. Do not restate a signal
|
||||
* threshold here — that is exactly how the two configurations drifted apart before.
|
||||
*/
|
||||
private val config = DetectionConfig(
|
||||
windowSize = 10,
|
||||
brakingSustainedFrames = 5,
|
||||
turningSustainedFrames = 8,
|
||||
stoppingFrames = 20,
|
||||
// Keep production thresholds for all signal values:
|
||||
brakingSpeedDropThreshold = 0.5,
|
||||
brakingAccelStdDevThreshold = 1.2,
|
||||
brakingHighConfidenceRate = 1.5,
|
||||
turningGyroMeanThreshold = 0.4,
|
||||
turningBearingChangeThreshold = 10.0,
|
||||
turningMinSpeedThreshold = 2.0,
|
||||
stoppingSpeedThreshold = 0.5,
|
||||
stoppingAccelStdDevThreshold = 0.15,
|
||||
)
|
||||
|
||||
private lateinit var detector: EventDetector
|
||||
@@ -71,12 +71,12 @@ class EventDetectorTest {
|
||||
|
||||
/**
|
||||
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
||||
* giving a population std dev of |hi - lo| / 2, which exceeds the production
|
||||
* threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5).
|
||||
* giving a population std dev of |hi - lo| / 2, which exceeds the shipping
|
||||
* threshold of 1.8 m/s² when hi=4.5 and lo=0.5 (std dev = 2.0).
|
||||
*/
|
||||
private fun alternatingAccelFrames(
|
||||
n: Int,
|
||||
hi: Double = 3.5,
|
||||
hi: Double = 4.5,
|
||||
lo: Double = 0.5,
|
||||
speedMps: Double = 10.0,
|
||||
bearingChangeDps: Double = 0.0,
|
||||
@@ -139,12 +139,12 @@ class EventDetectorTest {
|
||||
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
||||
// Variability established before the drop - see the note in the test above.
|
||||
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
||||
// Drop of 8 m/s > brakingHighConfidenceRate (1.5)
|
||||
// Drop of 8 m/s > brakingHighConfidencePeakDrop (1.5)
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
hi = 3.5,
|
||||
hi = 4.5,
|
||||
lo = 0.5,
|
||||
speedMps = 2.0, // drop from 10 → 8 m/s
|
||||
speedMps = 2.0, // drop from 10 → 2 m/s
|
||||
timeOffset = config.windowSize,
|
||||
)
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
@@ -159,12 +159,16 @@ class EventDetectorTest {
|
||||
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
||||
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
||||
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
||||
// Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5)
|
||||
// Drop of 1.2 m/s — above the speed-drop threshold (1.0) but below the
|
||||
// high-confidence peak drop (1.5), so this must land as MEDIUM. The window
|
||||
// between those two values is narrow at the shipping thresholds, which is
|
||||
// itself worth knowing: MEDIUM braking is only emitted for drops in
|
||||
// (1.0, 1.5] m/s.
|
||||
alternatingAccelFrames(
|
||||
n = config.brakingSustainedFrames + 5,
|
||||
hi = 3.5,
|
||||
hi = 4.5,
|
||||
lo = 0.5,
|
||||
speedMps = 2.2, // drop = 0.8 m/s
|
||||
speedMps = 1.8, // drop = 1.2 m/s
|
||||
timeOffset = config.windowSize,
|
||||
)
|
||||
val braking = events.filter { it.type == EventType.BRAKING }
|
||||
@@ -179,9 +183,9 @@ class EventDetectorTest {
|
||||
repeat(total) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.3,
|
||||
gyroMagnitude = 0.8, // mean → well above 0.4 threshold
|
||||
gyroMagnitude = 0.8, // mean → above the 0.6 threshold
|
||||
speedMps = 4.0, // above 2 m/s → bearing also checked
|
||||
bearingChangeDegPerSec = 15.0, // above 10 °/s → both signals agree
|
||||
bearingChangeDegPerSec = 20.0, // above 15 °/s → both signals agree
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
@@ -193,7 +197,7 @@ class EventDetectorTest {
|
||||
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
||||
val total = config.windowSize + config.turningSustainedFrames + 4
|
||||
repeat(total) { i ->
|
||||
detector.processSample(0.3, 0.8, 4.0, 15.0, 53.5, 10.0, i * 20L)
|
||||
detector.processSample(0.3, 0.8, 4.0, 20.0, 53.5, 10.0, i * 20L)
|
||||
}
|
||||
val turning = events.filter { it.type == EventType.TURNING }
|
||||
assertTrue(turning.isNotEmpty())
|
||||
@@ -205,9 +209,9 @@ class EventDetectorTest {
|
||||
repeat(total) { i ->
|
||||
detector.processSample(
|
||||
accelMagnitude = 0.2,
|
||||
gyroMagnitude = 0.6, // above gyro threshold
|
||||
gyroMagnitude = 0.9, // above the 0.6 gyro threshold
|
||||
speedMps = 1.0, // below 2 m/s → bearing not enforced
|
||||
bearingChangeDegPerSec = 3.0, // below bearing threshold
|
||||
bearingChangeDegPerSec = 3.0, // below the 15 °/s bearing threshold
|
||||
latitude = 53.5,
|
||||
longitude = 10.0,
|
||||
timestamp = i * 20L,
|
||||
@@ -253,7 +257,7 @@ class EventDetectorTest {
|
||||
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
||||
repeat(50) { i ->
|
||||
val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold
|
||||
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.4
|
||||
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.6
|
||||
detector.processSample(
|
||||
accelMagnitude = accel,
|
||||
gyroMagnitude = gyro,
|
||||
@@ -265,7 +269,7 @@ class EventDetectorTest {
|
||||
)
|
||||
}
|
||||
// speed = 0 → no speed drop possible → no BRAKING
|
||||
// gyro mean stays below 0.4 (only 1/7 frames spike to 0.35) → no TURNING
|
||||
// gyro mean stays below 0.6 (only 1/7 frames spike to 0.35) → no TURNING
|
||||
val unwanted = events.filter { it.type == EventType.BRAKING || it.type == EventType.TURNING }
|
||||
assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty())
|
||||
}
|
||||
@@ -299,13 +303,12 @@ class EventDetectorTest {
|
||||
}
|
||||
// Second stop episode. Deliberately longer than the first: stopping also requires the
|
||||
// accel std dev to be BELOW a threshold, and the rolling window still holds the five
|
||||
// moving samples above. It takes 8 further frames for those to drain out far enough for
|
||||
// the std dev to fall under 0.15, and only then does the counter start. The first episode
|
||||
// needs no such allowance because the window begins empty.
|
||||
//
|
||||
// stoppingFrames + 5 was not enough - the second episode reached 17 of the 21 frames it
|
||||
// needs and silently emitted nothing, which is what made this test fail.
|
||||
repeat(config.stoppingFrames + 10) {
|
||||
// moving samples above. At the shipping threshold of 0.10 m/s² even a single 0.5 sample
|
||||
// left in a 10-sample window gives a std dev of ~0.14, so ALL five have to be evicted
|
||||
// before the counter can start - that is a full windowSize of stationary frames. Only
|
||||
// then do the 21 qualifying frames the event needs begin to accumulate. The first
|
||||
// episode needs no such allowance because the window begins empty.
|
||||
repeat(config.stoppingFrames + 20) {
|
||||
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the arithmetic that moves a transmit timestamp from the phone's wall clock onto GNSS time.
|
||||
*
|
||||
* The cases come from the 2026-09-10 bench session. The sending phone's clock was 1456 s fast
|
||||
* because it had no automatic time source, and every CAM it sent was stamped 24 minutes in the
|
||||
* future. After a manual correction it was 6 s slow. Both have to come out on GNSS time.
|
||||
*/
|
||||
class ItsTimeTest {
|
||||
|
||||
private val gnssNow = 1_789_038_922_000L
|
||||
|
||||
@Test
|
||||
fun `without a GNSS reading the wall-clock time is used unchanged`() {
|
||||
assertEquals(1_000L, ItsTime.onGnssTime(systemMs = 1_000L, gnssNowMs = null, systemNowMs = 5_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone clock running fast is pulled back onto GNSS time`() {
|
||||
val systemNow = gnssNow + 1_456_000L
|
||||
// A fix the wall clock stamped 0.8 s ago. It must still be 0.8 s old afterwards.
|
||||
val fix = systemNow - 800L
|
||||
assertEquals(gnssNow - 800L, ItsTime.onGnssTime(fix, gnssNow, systemNow))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone clock running slow is pushed forward onto GNSS time`() {
|
||||
val systemNow = gnssNow - 6_000L
|
||||
assertEquals(gnssNow - 250L, ItsTime.onGnssTime(systemNow - 250L, gnssNow, systemNow))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an accurate phone clock is left where it is`() {
|
||||
assertEquals(gnssNow - 40L, ItsTime.onGnssTime(gnssNow - 40L, gnssNow, gnssNow))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds.BENCH_PING_GRACE_MS
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the rule that decides whether a received CAM is one this phone sent.
|
||||
*
|
||||
* ## The bugs this exists to prevent
|
||||
* Getting it wrong fails in two opposite directions, and each has happened:
|
||||
*
|
||||
* - **Too narrow.** An own frame that is not recognised comes back as a remote road user sitting
|
||||
* exactly on the ego position, and is fed to the detection engine as a collision partner for
|
||||
* itself. That happened with the bench pinger's separate ID, and pseudonym rotation creates the
|
||||
* same risk for an ID that has just been retired.
|
||||
* - **Too wide.** On 2026-09-10 the bench ID counted as ours on every phone, so a phone watching
|
||||
* through the CiT One silently discarded another phone's pings as its own, although it had sent
|
||||
* none. Nothing appeared on its V2X screen while the broker was full of them.
|
||||
*/
|
||||
class OwnStationIdsTest {
|
||||
|
||||
private val current = 1_691_338_363L
|
||||
private val retired = 2_222_222_222L
|
||||
private val ours = setOf(current, retired)
|
||||
|
||||
@Test
|
||||
fun `recognises the current transmit id`() {
|
||||
assertTrue(OwnStationIds.isOwn(current, ours, benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognises a recently retired id, so a frame sent just before a rotation is still ours`() {
|
||||
assertTrue(OwnStationIds.isOwn(retired, ours, benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another phone's bench ping is shown, not swallowed as our own`() {
|
||||
// The 2026-09-10 regression: this phone is not pinging, so 999999 is someone else.
|
||||
assertFalse(OwnStationIds.isOwn(BENCH_PING, ours, benchPingIsOurs = false))
|
||||
assertFalse(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `our own bench ping is recognised while we are pinging, even before any transmit id loads`() {
|
||||
assertTrue(OwnStationIds.isOwn(BENCH_PING, emptySet(), benchPingIsOurs = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `treats a genuine remote station as remote`() {
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, ours, benchPingIsOurs = true))
|
||||
assertFalse(OwnStationIds.isOwn(2_741_041_966L, emptySet(), benchPingIsOurs = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `station id zero is never ours`() {
|
||||
// 0 is the "not resolved yet" placeholder for the ego identity. Matching on it would
|
||||
// swallow real traffic from any station that reported 0.
|
||||
assertFalse(OwnStationIds.isOwn(0L, setOf(0L), benchPingIsOurs = true))
|
||||
}
|
||||
|
||||
// ---- when the bench id is ours ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the bench id is ours while the pinger runs`() {
|
||||
assertTrue(OwnStationIds.benchPingIsOurs(pingerActive = true, pingerStoppedAtMs = null, nowMs = 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bench id is not ours on a phone that never pinged`() {
|
||||
assertFalse(OwnStationIds.benchPingIsOurs(pingerActive = false, pingerStoppedAtMs = null, nowMs = 50_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bench id stays ours for the grace window after Stop, and not a moment longer`() {
|
||||
val stop = 100_000L
|
||||
assertTrue(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS))
|
||||
assertFalse(OwnStationIds.benchPingIsOurs(false, stop, stop + BENCH_PING_GRACE_MS + 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock reading before the stop time does not claim the bench id`() {
|
||||
assertFalse(OwnStationIds.benchPingIsOurs(false, pingerStoppedAtMs = 100_000L, nowMs = 99_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bench MAC is a locally administered unicast address`() {
|
||||
// Bit 1 set, bit 0 clear. A source address must never be a group address.
|
||||
assertEquals(0x02, OwnStationIds.BENCH_PING_MAC[0].toInt() and 0x03)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Pins what a transmit pseudonym is allowed to look like, and when it rotates.
|
||||
*
|
||||
* The address rules matter on air, not just in the app: the ESP32 writes this MAC straight into
|
||||
* the 802.11 source address. A group (multicast) source address is invalid, and a random address
|
||||
* without the locally-administered bit claims to belong to a real hardware vendor.
|
||||
*/
|
||||
class PseudonymTest {
|
||||
|
||||
@Test
|
||||
fun `rotates every ten minutes`() {
|
||||
assertEquals(10 * 60_000L, Pseudonym.ROTATION_INTERVAL_MS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expires exactly at the rotation interval, not a millisecond before`() {
|
||||
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||||
assertFalse(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS - 1))
|
||||
assertTrue(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock that moved back past the creation time forces a rotation`() {
|
||||
// Otherwise a creation time now lying in the future would pin one identity until the
|
||||
// clock caught up, which after a large correction could be hours.
|
||||
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||||
assertTrue(p.isExpired(999L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generated addresses are locally administered unicast, whatever the random bytes`() {
|
||||
repeat(500) { seed ->
|
||||
val first = Pseudonym.generate(0L, Random(seed)).mac[0].toInt()
|
||||
assertEquals("seed $seed: bit 1 set, bit 0 clear", 0x02, first and 0x03)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generated station ids stay in range`() {
|
||||
repeat(500) { seed ->
|
||||
val id = Pseudonym.generate(0L, Random(seed)).stationId
|
||||
assertTrue("seed $seed: $id", id in 1L until 0xFFFF_FFFEL)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never generates the bench pinger's identity`() {
|
||||
// Scripted so the exclusion loops actually run: the first draw of each is the bench
|
||||
// value, which must be rejected in favour of the second.
|
||||
val random = ScriptedRandom(
|
||||
longs = ArrayDeque(listOf(OwnStationIds.BENCH_PING, 42L)),
|
||||
bytes = ArrayDeque(listOf(OwnStationIds.BENCH_PING_MAC, byteArrayOf(0x13, 1, 2, 3, 4, 5))),
|
||||
)
|
||||
val p = Pseudonym.generate(0L, random)
|
||||
assertEquals(42L, p.stationId)
|
||||
assertEquals("0x13 with the group bit cleared and the local bit set", 0x12, p.mac[0].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rotation replaces the station id and the address together`() {
|
||||
val a = Pseudonym.generate(0L, Random(1))
|
||||
val b = Pseudonym.generate(Pseudonym.ROTATION_INTERVAL_MS, Random(2))
|
||||
assertNotEquals(a.stationId, b.stationId)
|
||||
assertFalse(a.mac.contentEquals(b.mac))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equality compares the address bytes, not the array instance`() {
|
||||
assertEquals(
|
||||
Pseudonym(7L, mac(0x02), 5L),
|
||||
Pseudonym(7L, mac(0x02), 5L),
|
||||
)
|
||||
}
|
||||
|
||||
private fun mac(first: Int) = byteArrayOf(first.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55)
|
||||
|
||||
private class ScriptedRandom(
|
||||
private val longs: ArrayDeque<Long>,
|
||||
private val bytes: ArrayDeque<ByteArray>,
|
||||
) : Random() {
|
||||
override fun nextBits(bitCount: Int): Int = error("not used by Pseudonym.generate")
|
||||
override fun nextLong(from: Long, until: Long): Long = longs.removeFirst()
|
||||
override fun nextBytes(size: Int): ByteArray = bytes.removeFirst().copyOf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.mqtt.RecvV2xMessage
|
||||
import com.hawhamburg.micr0bu.domain.asn1.CamUperCodec
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins [RecvV2xMessage] to the protobuf wire format of consider it's `RecvV2XMessage`
|
||||
* (`v2x_interface.proto`, V2X RX protocol v2.4.2), the envelope the CiT One publishes on its raw
|
||||
* `v2x/rx` topics.
|
||||
*
|
||||
* ## Where the fixtures come from
|
||||
* The envelope bytes are written out here by hand from the protobuf encoding rules and the field
|
||||
* numbers in that `.proto`, with the derivation in the comments, so a reviewer can check them
|
||||
* without running anything. They are deliberately **not** produced by an encoder in this
|
||||
* repository: a fixture generated by our own code would agree with our own reader no matter how
|
||||
* wrong both were, which is exactly the failure mode the ASN.1 work in this project ran into
|
||||
* three times.
|
||||
*
|
||||
* The CAM payload inside is the golden UPER frame from [CamEncodeGoldenTest], itself verified
|
||||
* against `asn1tools` and the real ETSI modules in `asn1/`.
|
||||
*
|
||||
* ## Why this matters
|
||||
* Field numbers are wire-format constants with no self-describing names on the wire. Reading
|
||||
* field 2 where the schema says field 3 does not fail loudly, it silently yields a plausible
|
||||
* looking byte string that decodes to nothing. These tests are what should fail if the constants
|
||||
* in [RecvV2xMessage] are ever "tidied".
|
||||
*/
|
||||
class RecvV2xMessageTest {
|
||||
|
||||
/**
|
||||
* The golden CAM UPER, 43 bytes, from [CamEncodeGoldenTest]. Its ItsPduHeader reads
|
||||
* protocolVersion 2, messageID 2 (CAM), stationID 0x000f423f = 999999.
|
||||
*/
|
||||
private val goldenCam =
|
||||
"0202000f423f3700402ab215af6e286477dffffffc23b7743e0027ffc0d0fe0118329337feebfff6000000"
|
||||
|
||||
/**
|
||||
* A complete `RecvV2XMessage` carrying [goldenCam], byte by byte:
|
||||
*
|
||||
* ```
|
||||
* 0a 05 field 1 (btpHeader), length-delimited, 5 bytes
|
||||
* 08 02 field 1 (type) varint = 2, CAM
|
||||
* 10 d1 0f field 2 (destinationPort) varint = 2001
|
||||
* 12 07 field 2 (gnHeader), length-delimited, 7 bytes
|
||||
* 42 05 field 8 (dest), length-delimited, 5 bytes
|
||||
* 0a 03 field 1 (area), length-delimited, 3 bytes
|
||||
* 18 f4 03 field 3 (distA) varint = 500 metres
|
||||
* 1a 2b field 3 (payload), length-delimited, 0x2b = 43 bytes
|
||||
* ```
|
||||
*/
|
||||
private val camEnvelope = "0a05080210d10f120742050a0318f4031a2b" + goldenCam
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
// ---- the happy path --------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `parses btp header, geo radius and payload from a full envelope`() {
|
||||
val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes())
|
||||
assertNotNull("envelope should parse", msg)
|
||||
msg!!
|
||||
|
||||
assertEquals("btpHeader.type: CAM", 2, msg.pduType)
|
||||
assertEquals("btpHeader.destinationPort", 2001, msg.destinationPort)
|
||||
assertEquals("gnHeader.dest.area.distA, metres", 500, msg.destAreaRadiusM)
|
||||
assertTrue(
|
||||
"payload must be the CAM UPER byte for byte",
|
||||
msg.payload.contentEquals(goldenCam.hexToBytes()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extracted payload is decodable UPER, not a mangled copy`() {
|
||||
val msg = RecvV2xMessage.parse(camEnvelope.hexToBytes())!!
|
||||
// The whole point of carrying bytes rather than a String through the MQTT layer: a UTF-8
|
||||
// round trip would replace most of these bytes and this decode would fail.
|
||||
val cam = CamUperCodec.decode(msg.payload, receivedAtEpochMs = 1_787_100_000_000L)
|
||||
assertNotNull("payload should decode as a CAM", cam)
|
||||
assertEquals("stationID from the ItsPduHeader", 999_999L, cam!!.stationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reads a DENM envelope's relevance radius`() {
|
||||
// Same shape, DENM values: type 1, port 2002, distA 1000 m, a 2-byte stand-in payload.
|
||||
// 0a 05 08 01 10 d2 0f | 12 07 42 05 0a 03 18 e8 07 | 1a 02 02 01
|
||||
val msg = RecvV2xMessage.parse("0a05080110d20f120742050a0318e8071a020201".hexToBytes())
|
||||
assertNotNull(msg)
|
||||
assertEquals(1, msg!!.pduType)
|
||||
assertEquals(2002, msg.destinationPort)
|
||||
assertEquals(1000, msg.destAreaRadiusM)
|
||||
}
|
||||
|
||||
// ---- forward compatibility -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `skips unknown fields and does not depend on field order`() {
|
||||
// payload first, then an unknown varint (field 7) and an unknown fixed32 (field 6) that
|
||||
// this schema revision does not define, then the btpHeader. Protobuf permits all three,
|
||||
// and a reader that assumed order or choked on unknowns would break the first time
|
||||
// consider it added a field.
|
||||
val bytes = ("1a2b" + goldenCam + "38b96035deadbeef0a05080210d10f").hexToBytes()
|
||||
val msg = RecvV2xMessage.parse(bytes)
|
||||
assertNotNull(msg)
|
||||
assertEquals(2, msg!!.pduType)
|
||||
assertEquals(2001, msg.destinationPort)
|
||||
assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts an envelope carrying nothing but a payload`() {
|
||||
val msg = RecvV2xMessage.parse(("1a2b" + goldenCam).hexToBytes())
|
||||
assertNotNull(msg)
|
||||
assertNull("no btpHeader was sent", msg!!.pduType)
|
||||
assertNull("no gnHeader was sent", msg.destAreaRadiusM)
|
||||
assertTrue(msg.payload.contentEquals(goldenCam.hexToBytes()))
|
||||
}
|
||||
|
||||
// ---- malformed input -------------------------------------------------------------------
|
||||
// These arrive off a network topic. A reader that throws takes the MQTT callback thread with
|
||||
// it, so every one of these must return null instead.
|
||||
|
||||
@Test
|
||||
fun `returns null for a truncated envelope`() {
|
||||
val full = camEnvelope.hexToBytes()
|
||||
assertNull(RecvV2xMessage.parse(full.copyOfRange(0, full.size / 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when the payload field is present but empty`() {
|
||||
assertNull(RecvV2xMessage.parse("1a00".hexToBytes()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when there is no payload field at all`() {
|
||||
assertNull(RecvV2xMessage.parse("0a05080210d10f".hexToBytes()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for empty input and for bytes that are not protobuf`() {
|
||||
assertNull(RecvV2xMessage.parse(ByteArray(0)))
|
||||
// A run of continuation bytes: a varint that never terminates, which is what would walk
|
||||
// an unguarded reader off the end of the buffer.
|
||||
assertNull(RecvV2xMessage.parse(ByteArray(24) { 0xFF.toByte() }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.data.transport.BtpDataRequest
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkMessage
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkOpcode
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkResult
|
||||
import com.hawhamburg.micr0bu.data.transport.LinkSecurityProfile
|
||||
import com.hawhamburg.micr0bu.data.transport.PotiUpdate
|
||||
import com.hawhamburg.micr0bu.data.transport.StationConfigure
|
||||
import com.hawhamburg.micr0bu.data.transport.StationInfo
|
||||
import com.hawhamburg.micr0bu.data.transport.StationStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.credentialsSegment
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the phone side of the station-link message layer.
|
||||
*
|
||||
* ## Where the expected bytes come from
|
||||
* The colleague's Python implementation of the same protocol, microbu-esp32c5
|
||||
* station-link/python/microbu_link/messages.py (`encode_message` over each body's `encode`),
|
||||
* run with the same field values. That module is what their phone emulator drives the firmware
|
||||
* with, so an agreement here is agreement with a second, independent implementation.
|
||||
*/
|
||||
class StationLinkTest {
|
||||
|
||||
private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
@Test
|
||||
fun `STATION_CONFIGURE matches the Python encoder`() {
|
||||
val body = StationConfigure(stationType = 2, mid = "021122334455".hexToBytes()).encode()
|
||||
assertEquals(
|
||||
"0100070002010002112233445500b40014020205",
|
||||
LinkMessage(LinkOpcode.STATION_CONFIGURE, 7, body).encode().hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `POTI_UPDATE matches the Python encoder`() {
|
||||
val body = PotiUpdate(
|
||||
timestampMs = 717_254_800_123L, latTenMicroDeg = 535_546_667, lonTenMicroDeg = 100_223_889,
|
||||
semiMajorCm = 486, semiMinorCm = 486, speedCms = 543, headingDeciDeg = 1234, pai = true,
|
||||
).encode()
|
||||
assertEquals(
|
||||
"02000800fbb2b7ffa60000002bcbeb1f914bf905e601e60100000e000000001f02d204",
|
||||
LinkMessage(LinkOpcode.POTI_UPDATE, 8, body).encode().hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BTP_DATA_REQUEST for a signed CAM matches the Python encoder`() {
|
||||
val body = BtpDataRequest(
|
||||
destinationPort = 2001, itsAid = 36, securityProfile = LinkSecurityProfile.SECURED,
|
||||
permissions = "010000".hexToBytes(), flSdu = "0102030405".hexToBytes(),
|
||||
).encode()
|
||||
assertEquals(
|
||||
"0300090001d1070000010102ffff000000000024000000030100000005000102030405",
|
||||
LinkMessage(LinkOpcode.BTP_DATA_REQUEST, 9, body).encode().hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CREDENTIALS_PROVISION segment matches the Python encoder`() {
|
||||
val body = credentialsSegment(totalLength = 695, offset = 240, segment = ByteArray(3) { 0xAB.toByte() })
|
||||
assertEquals("04000a00b702f00003ababab", LinkMessage(LinkOpcode.CREDENTIALS_PROVISION, 10, body).encode().hex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STATUS from the Python encoder decodes field by field`() {
|
||||
val message = LinkMessage.decode(
|
||||
("8400341240e20100010800021122334455b80b49387a4c12eb00010b0000000c0000000d0000000e000000" +
|
||||
"0f000000100000001100000012000000130000001400000015000000160000001700000018000000" +
|
||||
"190000001a0000001b0000001c000000fbb2b7ffa6000000").hexToBytes(),
|
||||
)
|
||||
assertNotNull(message)
|
||||
assertEquals(LinkOpcode.STATUS, message!!.opcode)
|
||||
assertEquals(0x1234, message.sequence)
|
||||
val status = StationStatus.decode(message.body)!!
|
||||
assertEquals(123_456L, status.uptimeMs)
|
||||
assertTrue(status.configured)
|
||||
assertArrayEquals("b80b49387a4c12eb".hexToBytes(), status.identifier)
|
||||
assertEquals(1, status.tickets)
|
||||
assertEquals(11L, status.signedMessages)
|
||||
assertEquals(12L, status.refusedNoTicket)
|
||||
assertEquals(13L, status.refusedChangePending)
|
||||
assertEquals(14L, status.refusedPermission)
|
||||
assertEquals(15L, status.signFailed)
|
||||
assertEquals(16L, status.verified)
|
||||
assertEquals(17L, status.rejected)
|
||||
assertEquals(18L, status.requestsAccepted)
|
||||
assertEquals(19L, status.requestsRefused)
|
||||
assertEquals(21L, status.radioSubmitted)
|
||||
assertEquals(22L, status.radioFailed)
|
||||
assertEquals(23L, status.radioReceived)
|
||||
assertEquals(24L, status.radioDropped)
|
||||
assertEquals(26L, status.linkCrcErrors)
|
||||
assertEquals(27L, status.linkMalformed)
|
||||
assertEquals(28L, status.potiUpdates)
|
||||
assertEquals(717_254_800_123L, status.itsTimeMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a STATUS of the wrong length is refused, as the Python decoder does`() {
|
||||
assertNull(StationStatus.decode(ByteArray(StationStatus.SIZE + 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RESULT of STATION_CONFIGURE carries the credential state`() {
|
||||
val message = LinkMessage.decode("8000070000120800021122334455b80b49387a4c12eb0101".hexToBytes())!!
|
||||
val result = LinkResult.decode(message.body)!!
|
||||
assertTrue(result.accepted)
|
||||
assertEquals(StationInfo(credentialsLoaded = true, tickets = 1), StationInfo.decode(result.detail))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
|
||||
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules.Kinematics
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** TS 103 300-3 clause 6.4 items 1 to 4, with the Table 16/17 values. */
|
||||
class VamGenerationRulesTest {
|
||||
|
||||
private val here = Kinematics(53.5546667, 10.0223889, speedMps = 3.0, headingDeg = 90.0)
|
||||
|
||||
private fun sentAt(ms: Long, k: Kinematics = here) = VamGenerationRules().apply { onSent(ms, k, withLowFrequency = true) }
|
||||
|
||||
@Test
|
||||
fun `the first VAM is always due`() {
|
||||
assertTrue(VamGenerationRules().due(0, here))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is due within T_GenVamMin even after a big jump`() {
|
||||
assertFalse(sentAt(1_000).due(1_050, here.copy(latitude = here.latitude + 0.001)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stationary VRU gets one VAM every T_GenVamMax`() {
|
||||
val rules = sentAt(1_000)
|
||||
assertFalse(rules.due(5_900, here))
|
||||
assertTrue(rules.due(6_001, here))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `position, speed and heading changes trigger past their thresholds only`() {
|
||||
val rules = sentAt(1_000)
|
||||
// ~3.3 m north: under 4 m. ~5.6 m: over.
|
||||
assertFalse(rules.due(2_000, here.copy(latitude = here.latitude + 0.00003)))
|
||||
assertTrue(rules.due(2_000, here.copy(latitude = here.latitude + 0.00005)))
|
||||
assertFalse(rules.due(2_000, here.copy(speedMps = 3.4)))
|
||||
assertTrue(rules.due(2_000, here.copy(speedMps = 3.6)))
|
||||
assertFalse(rules.due(2_000, here.copy(headingDeg = 93.0)))
|
||||
assertTrue(rules.due(2_000, here.copy(headingDeg = 95.0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `heading change is measured the short way round north`() {
|
||||
val rules = sentAt(1_000, here.copy(headingDeg = 358.0))
|
||||
assertFalse(rules.due(2_000, here.copy(headingDeg = 1.0))) // 3 degrees across north
|
||||
assertTrue(rules.due(2_000, here.copy(headingDeg = 3.0))) // 5 degrees
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the low-frequency container goes with the first VAM, then every T_GenVamLFMin`() {
|
||||
val rules = VamGenerationRules()
|
||||
assertTrue(rules.includeLowFrequency(0))
|
||||
rules.onSent(0, here, withLowFrequency = true)
|
||||
assertFalse(rules.includeLowFrequency(1_999))
|
||||
assertTrue(rules.includeLowFrequency(2_000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.hawhamburg.micr0bu
|
||||
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamContent
|
||||
import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the VAM encoder against bytes this code did not produce.
|
||||
*
|
||||
* ## Where the expected bytes come from
|
||||
* asn1tools 0.167, compiling the ETSI modules vanetza-idf ships (asn1/release2:
|
||||
* TS102894-2v241-CDD.asn, TS103300-3v231/VAM-PDU-Descriptions.asn and its motorcyclist container),
|
||||
* encoding the same values as a Python dict: the same toolchain the colleague's reference VBS
|
||||
* (microbu-esp32c5/station-link/python/microbu_link/vbs.py) builds its VAMs with. Station
|
||||
* 0x12345678, fix at Unix ms 1790170000123 (generationDeltaTime 45819), 53.5546667 N
|
||||
* 10.0223889 E, 5.43 m/s, heading 123.4 deg, -1.26 m/s^2; every confidence and the altitude
|
||||
* unavailable, as the encoder sends them.
|
||||
*/
|
||||
class VamUperCodecTest {
|
||||
|
||||
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun content(includeLowFrequency: Boolean, accuracyM: Float? = 3.0f) = VamContent(
|
||||
stationId = 0x12345678,
|
||||
timestamp = 1_790_170_000_123L,
|
||||
latitude = 53.5546667,
|
||||
longitude = 10.0223889,
|
||||
accuracyM = accuracyM,
|
||||
speedMps = 5.43,
|
||||
headingDeg = 123.4,
|
||||
accelerationMps2 = -1.26,
|
||||
includeLowFrequency = includeLowFrequency,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `VAM with the low-frequency container matches asn1tools`() {
|
||||
assertEquals(
|
||||
"031012345678b2fb400aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f322220",
|
||||
VamUperCodec.encode(content(includeLowFrequency = true)).hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VAM without the low-frequency container matches asn1tools`() {
|
||||
assertEquals(
|
||||
"031012345678b2fb000aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f30",
|
||||
VamUperCodec.encode(content(includeLowFrequency = false)).hex(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown accuracy encodes the confidence ellipse as unavailable`() {
|
||||
assertEquals(
|
||||
"031012345678b2fb000aac85a15b8a18ec8fffffff08eddd0f8002697e087ff24f30",
|
||||
VamUperCodec.encode(content(includeLowFrequency = false, accuracyM = null)).hex(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,8 +139,8 @@ share no code. The requirement is satisfied twice, by different means.
|
||||
| 11.1 | Orientation-Independent Sensor Strategy | **Done** | `SensorRepository.kt` (magnitude-based) | — |
|
||||
| 11.2 | Running Standard Deviation Event Detector | **Done** | `EventDetector.kt`, `RunningStats.kt` | **18 unit tests, 0 failures** |
|
||||
| 11.3 | Trip Recording Architecture | **Done** | `TripRepository.kt`, `TripRecordingService.kt` *(cited)* | — |
|
||||
| 11.4 | Data Model | **Done** | `data/db/` Room entities *(cited)* | — |
|
||||
| 11.5 | New UI Elements for Phase A | **Done** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | — |
|
||||
| 11.4 | Data Model | **Partial — scope reduced** | `data/db/` Room entities *(cited)* | `detected_events` dropped in schema v5, see scope note |
|
||||
| 11.5 | New UI Elements for Phase A | **Partial — scope reduced** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | event pins/counters removed by decision, see note |
|
||||
| 11.6 | Phase A Success Criteria | **Partial** | — | needs a real ride; see Open Items |
|
||||
|
||||
**Correction note (11.2).** Four `EventDetectorTest` cases had been failing since the initial commit.
|
||||
@@ -149,6 +149,26 @@ described stimuli the detector cannot physically see, because they ignored the s
|
||||
rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is
|
||||
worth reporting — it is a finding about test design, not a defect.
|
||||
|
||||
**Scope note (11.5).** The event-detection UI — the live per-type counters on the recording screen,
|
||||
the coloured event pins and detail sheet on the trip review map, and the event count on the trip
|
||||
history card — was removed deliberately. A count of the rider's own braking events is not a goal of
|
||||
this project. The detector itself still runs: it is the input to the CAM transmit-rate policy
|
||||
(§ 13), which raises the beacon rate from 1 Hz to the elevated rate for five seconds after a
|
||||
detected manoeuvre. That is now its only effect: the `detected_events` table was dropped in schema
|
||||
v5 and the per-event rows removed from the trip CSV, so a detected manoeuvre is consumed and
|
||||
discarded. `trips.eventCount` is kept as a single integer per ride, since dropping a SQLite column
|
||||
means recreating the table.
|
||||
|
||||
**Defect note (11.2).** Two defects found while documenting the detector were fixed on 2026-09-07.
|
||||
The nine threshold overrides in `TripRecordingService`'s constructor were promoted to
|
||||
`DetectionConfig`'s defaults and the override deleted, so there is one configuration and
|
||||
`EventDetectorTest` exercises the shipping thresholds rather than the superseded Phase A ones;
|
||||
detector sensitivity is unchanged, and the synthetic stimuli were re-derived because several no
|
||||
longer cleared the stricter real thresholds. `brakingHighConfidenceRate` was renamed
|
||||
`brakingHighConfidencePeakDrop`: it was documented as a rate but has always been compared against
|
||||
the peak cumulative speed drop. The name was corrected rather than the comparison, so detector
|
||||
output is unchanged and the confidence assertions remain valid evidence.
|
||||
|
||||
## 12. Future Architecture & Open Design Questions
|
||||
|
||||
| § | Title | Status | Notes |
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# 06 – Signed ITS messages, VAM and the BLE link (2026-09-23)
|
||||
|
||||
What changed when the ESP32-C5 OBU moved onto the colleague's vanetza-idf station, how the pieces
|
||||
fit together, how it was verified, and what is still open. Hardware checks still to do are in
|
||||
`TODO.md` ("Signed-TX firmware ...").
|
||||
|
||||
## Summary
|
||||
|
||||
- **Signing lives on the ESP32-C5.** The authorization ticket's private key is in the board's NVS;
|
||||
vanetza-idf's security entity signs every secured message there (IEEE 1609.2 / ETSI TS 103 097,
|
||||
ECDSA NIST P-256). The phone never holds a key and never signs.
|
||||
- **The phone decides what to send and when.** It builds CAM or VAM (UPER) from its own GNSS/IMU,
|
||||
hands each message to the board with the flag "signed" or "unsigned", and keeps the board's clock
|
||||
and position current.
|
||||
- **Two links, one protocol.** USB-C (native USB Serial/JTAG) or Bluetooth LE, chosen in Settings.
|
||||
Both carry the colleague's station-link protocol v1 plus one MicrOBU extension for reception.
|
||||
- **Reception is unchanged for the app.** Every ITS message heard on air reaches the phone, signed
|
||||
or not, verifiable or not, exactly as with the previous firmware.
|
||||
- **Demo PKI, not the EU trust list.** Signed messages carry a throwaway chain. Receivers that
|
||||
verify against the EU trust list drop them; unsigned sending remains available.
|
||||
|
||||
## Who does what
|
||||
|
||||
| | Phone (app) | ESP32-C5 (obu-firmware) |
|
||||
|---|---|---|
|
||||
| CAM / VAM content and UPER encoding | yes | – |
|
||||
| Send cadence (CAM 1 Hz baseline; VAM per TS 103 300-3 clause 6.4) | yes | – |
|
||||
| Pseudonym (station ID + MAC, rotated together) | yes | uses the MAC it is configured with |
|
||||
| Time and position (PoTi) | yes, per new GNSS fix | keeps an ITS clock from it |
|
||||
| GeoNetworking + BTP headers | – | yes |
|
||||
| Signing (TS 103 097), certificate handling | – | yes |
|
||||
| Credentials | ships the demo bundle, provisions it once | stores it in NVS |
|
||||
| 802.11p radio at 5 900 MHz | – | yes |
|
||||
| Reception: unwrap GN/BTP, forward | decodes CAM / DENM / SPATEM | yes (all frames) |
|
||||
|
||||
## Firmware (obu-firmware)
|
||||
|
||||
obu-firmware is now a port of `microbu-esp32c5/firmware`, from the colleague's own repository
|
||||
(not part of this one; nothing is pushed there). The C-ITS library it needs is copied into this
|
||||
repository as `obu-firmware/external/vanetza-idf` (their commit cf4b99f, unchanged), so
|
||||
obu-firmware builds from a plain clone. It builds
|
||||
with **ESP-IDF 6.0.2 only**: the raw-TX path uses private Wi-Fi driver structures that vanetza-idf
|
||||
pins to that version. The previous C firmware (IDF 6.1) is backed up as a full flash image in
|
||||
`firmware-backups/` (gitignored, restore command in its README.txt); its sources stay on disk,
|
||||
unbuilt. Setup and flashing: `obu-firmware/FLASHING.md`. Design notes and every deviation from the
|
||||
colleague's code (`MicrOBU:` in the sources): `obu-firmware/NOTES.md`.
|
||||
|
||||
Main changes against the colleague's firmware:
|
||||
|
||||
- **Raw receive path kept.** vanetza-idf decapsulates strictly and would drop unsigned frames (the
|
||||
bench car) and anything not signed under the demo root (every RSU). Every captured frame also
|
||||
goes through the previous firmware's `gn_unwrap.c` and reaches the phone as link opcode
|
||||
`V2X_RX` (0x85), whose body is the old `SERIAL_MSG_V2X_RX` payload.
|
||||
- **Unsigned sending kept.** The colleague's station refuses unsecured requests; here they go out
|
||||
with the previous firmware's `geonet.c` header.
|
||||
- **Console on UART0** (CH343, COM3 on the bench); the native USB port carries only link frames.
|
||||
- **BLE pauses advertising while USB is in use** (BLE and ITS-G5 share one RF front end).
|
||||
- **NVS 80 KB instead of 24 KB**, app at 0x20000. At 24 KB the BLE bond could not be stored and the
|
||||
phone had to pair on every connection.
|
||||
- Fixes found on the bench: radio queue drained before the first PoTi (no RX, ~177 queue drops
|
||||
before); station loop waited 0 ticks at 100 Hz and starved the idle task; 2.4 KB RX buffer moved
|
||||
off the Wi-Fi task stack; no silent truncation of BLE notifications; ATT MTU 517; serial writes
|
||||
skipped when no USB host is present.
|
||||
|
||||
## Link protocol
|
||||
|
||||
Station-link v1 (colleague's repository, `station-link/README.md`): `[opcode][flags][sequence LE][body]`,
|
||||
little-endian, at most 512 octets. Over USB each message is one `0xAA55` frame of type `0x10`
|
||||
(the old framing and CRC). Over BLE each message is one GATT value on service
|
||||
`0000C175-BA5E-4C17-8000-00805F9B34FB` (the README describes a different, Nordic-UART layout; the
|
||||
firmware is what counts).
|
||||
|
||||
| Direction | Message | Used for |
|
||||
|---|---|---|
|
||||
| phone → board | `STATION_CONFIGURE` | pseudonym MAC, station type, channel 180, 20 dBm; starts the radio |
|
||||
| phone → board | `CREDENTIALS_PROVISION` | the demo bundle, once, when the board reports no ticket |
|
||||
| phone → board | `POTI_UPDATE` | position and ITS time, once per new GNSS fix |
|
||||
| phone → board | `BTP_DATA_REQUEST` | one CAM (port 2001, psid 36) or VAM (port 2018, psid 638), signed or not |
|
||||
| board → phone | `RESULT` | answer to a request |
|
||||
| board → phone | `STATUS` | every second: counters, tickets, signed/refused counts |
|
||||
| board → phone | `V2X_RX` (0x85, MicrOBU) | every ITS message heard on air |
|
||||
|
||||
The app side is `Esp32Link.kt` (session), `StationLink.kt` (codec, pinned by unit tests to bytes
|
||||
from the colleague's Python implementation), `UsbSerialTransport.kt` and `BleLinkTransport.kt`.
|
||||
A board still on the previous firmware is recognised by its old heartbeat and keeps working for
|
||||
CAM over USB.
|
||||
|
||||
## Redundancy and recovery
|
||||
|
||||
| Situation | What notices | What happens |
|
||||
|---|---|---|
|
||||
| USB link dead (board hung, cable) | app watchdog: no frame for 3.5 s (the board's `STATUS` comes every second) | link marked ERROR on the card |
|
||||
| USB unplugged | Android detach broadcast | port closed; Connect again after re-plugging |
|
||||
| BLE link lost | BLE supervision timeout (4 s) | app reconnects by itself: 1 s after a drop, then backing off to 30 s if attempts fail |
|
||||
| Board reset / power cycle | first `STATUS` says "not configured" | app reconfigures (and re-provisions if needed) without user action |
|
||||
| App closed and reopened | new session | app configures the board again; BLE reconnects with the stored bond, no passkey (confirmed) |
|
||||
| Phone clock or GNSS time jumping | app tracks the board's clock | PoTi never moves it backwards (except a real correction of ≥ 60 s), so the board does not restart its stack |
|
||||
| Board firmware wedged | ESP task watchdog (30 s, logs on COM3) | the phone sees it as a dead link (above) |
|
||||
|
||||
## App changes
|
||||
|
||||
- Settings > Connection > ESP32-C5: **link** USB-C / Bluetooth, **transmit** CAM / VAM, **sign
|
||||
outgoing messages** (on by default). The connection card, top bar and dashboard show the link in
|
||||
use, the pairing passkey when needed, and signing counters.
|
||||
- VAM encoder (`VamUperCodec.kt`, TS 103 300-3 V2.3.1, checked against asn1tools) and the VAM
|
||||
generation rules (`VamGenerationRules.kt`).
|
||||
- `GnssTimeSource` keeps the last measured phone-clock error while GNSS time drops out indoors. The
|
||||
bench phone's clock was 14 minutes fast; falling back to it made every transmitted timestamp
|
||||
jump by 14 minutes.
|
||||
- Bluetooth permissions (Android 12+) requested at start-up.
|
||||
|
||||
## Credentials (demo PKI)
|
||||
|
||||
`app/src/main/assets/demo-chain.vcr`, generated 2026-09-23 with the colleague's `vidf_issue`: root
|
||||
`6E7D0374FB021901` → AA `B3312F29844299E0` → AT `B80B49387A4C12EB` (two years; psid 36 SSP `010000`,
|
||||
psid 638 SSP `01`). It is throwaway and not EU-registered; its private key ships with the app on
|
||||
purpose. The colleague's own demo chain only grants psid 638 and cannot sign CAMs.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Unit tests** (103): VAM bytes against asn1tools, station-link messages against the colleague's
|
||||
Python encoder, VAM generation rules.
|
||||
- **Signatures on air**: `obu-firmware/test/verify_signed_pcap.py` checks a pcap with asn1tools
|
||||
and OpenSSL, sharing no code with the firmware. Pinger capture of 2026-09-23: 12/12 signed CAMs,
|
||||
psid 36, signer the demo AT, all signatures valid, chain valid.
|
||||
- **Third-party stack**: the CiT One receives the signed CAMs (~1 Hz on `v2x/rx/cam`), so its
|
||||
stack unwraps our envelope. Its MQTT interface exposes no security information, and it forwards
|
||||
unsigned and unknown-root messages alike, so it cannot tell whether it verified the signature.
|
||||
- **V2X2MAP (COM10)**: now the colleague's v2x2map 0.3.0 bridge from source with a new
|
||||
`verify.py` and `--trust demo-chain.vcr`; it shows "signature verified", "SIGNATURE INVALID" or
|
||||
"not verified" per packet. Signed CAMs and signed VAMs verified live; a one-bit change in a
|
||||
signed CAM comes out invalid. Launcher: `micrOBU_workspace/v2x-obu-esp32c5/start-v2x2map-signed.bat`.
|
||||
|
||||
## Open
|
||||
|
||||
- BLE/ITS-G5 coexistence is not measured: does an active BLE connection cost 5.9 GHz reception?
|
||||
- `time_regression` standing still indoors for several minutes, and board reset recovery over BLE,
|
||||
after the last fixes.
|
||||
- The signature's generationTime follows the app's UTC-based `ItsTime`; the colleague's VBS adds
|
||||
the 5 leap seconds (TAI). Which is right is the open question in `ItsTime.kt`.
|
||||
- Real EU PKI enrolment/authorisation (TS 102 941) instead of the demo chain.
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 660 KiB |
@@ -0,0 +1,508 @@
|
||||
<mxfile host="Electron" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/22.1.2 Chrome/114.0.5735.289 Electron/25.9.4 Safari/537.36" modified="2026-09-10T12:53:19.876Z" etag="qSD0sUq7Dcw_rxL1Y7Bl" version="22.1.2" type="device">
|
||||
<diagram id="90a13364-a465-7bf4-72fc-28e22215d7a0" name="Seite-1">
|
||||
<mxGraphModel dx="1678" dy="1125" grid="1" gridSize="10" guides="1" tooltips="1" connect="0" arrows="1" fold="1" page="1" pageScale="1.5" pageWidth="1169" pageHeight="826" background="none" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" style=";html=1;" />
|
||||
<mxCell id="1" style=";html=1;" parent="0" />
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-92" value="" style="rounded=0;whiteSpace=wrap;html=1;dashed=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="950" y="960" width="70" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="3a17f1ce550125da-2" value="Mobile Phone" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="290" y="280" width="650" height="780" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="3a17f1ce550125da-10" value="ESP32-C5" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1040" y="540" width="580" height="520" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-4" value="<font style="">ESPAR<br>Antenna</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1">
|
||||
<mxGeometry x="1150" y="320" width="110" height="90" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;exitX=0.499;exitY=0.954;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-10" target="3a17f1ce550125da-2" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-30" value="static map data" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="Iy5nDPde1Y9wQlhL-YeU-11" connectable="0" vertex="1">
|
||||
<mxGeometry x="-0.2209" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="-8" y="3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-10" value="<font style="font-size: 18px;">GeoServer</font>" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;align=center;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="540" y="130" width="150" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-12" value="<font style="">PoTi</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="780" y="670" width="150" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-13" value="<font style="">GNSS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="793" y="700" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-14" value="<font style="">IMU</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="863" y="700" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-15" value="<font style="">Time Source</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="793" y="740" width="132" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-16" value="<font style="">HMI Support</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="440" y="670" width="171" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-17" value="<font style="">Display</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="450" y="700" width="70" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-18" value="Haptic" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="530" y="700" width="70" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-19" value="<font style="">Speaker</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="489.5" y="740" width="78" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-84" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-21" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="700" y="770" />
|
||||
<mxPoint x="650" y="770" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-85" value="encoded<br>VAM" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-84" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.01" y="1" relative="1" as="geometry">
|
||||
<mxPoint y="-12" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-21" value="<font style="">VBS</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="440" y="500" width="410" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-22" value="<font style="">Encode</font><div><font style="">VAM</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="651.75" y="600" width="68.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-23" value="<font style="">Transmission</font><div><font style="">Management</font></div>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="600" width="111.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-24" value="GeoServer to<br>area-risk mapping" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="527" y="370" width="149" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-29" value="<font style="font-size: 20px;">VRU-Tx ITS-S Architecture of micrOBU</font>" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;labelBorderColor=default;" parent="1" vertex="1">
|
||||
<mxGeometry x="850" y="154" width="223" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-31" value="VRU Basic Service Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="567.5" y="530" width="203" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-32" value="<font style="">GeoNetworking</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="947" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-33" value="<font style="">BTP-B</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="606.5" y="807" width="77" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.75;entryY=1;entryDx=0;entryDy=0;endArrow=classic;endFill=1;startArrow=classic;startFill=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-34" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1270" y="480" />
|
||||
<mxPoint x="1232" y="480" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-147" value="RF Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-53" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.4593" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="9" y="27" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-34" value="<font style="">ITS-G5 radio<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1230" y="568" width="140" height="32" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.25;entryY=1;entryDx=0;entryDy=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-39" target="Iy5nDPde1Y9wQlhL-YeU-4" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1100" y="480" />
|
||||
<mxPoint x="1178" y="480" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-41" value="Steering Signal" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-38" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.2737" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="39" y="8" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-39" value="<font style="">Antenna<br>Steering<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1060" y="746" width="80" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="Iy5nDPde1Y9wQlhL-YeU-40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;innerLoopWaypoints=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" parent="1" source="3a17f1ce550125da-10" target="3a17f1ce550125da-10" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-1" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="430" y="790" as="sourcePoint" />
|
||||
<mxPoint x="940" y="790" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-3" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="520" y="900" width="330" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-7" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="927" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-8" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="968" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-9" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="685" y="1010" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-10" value="<strong data-end="7298" data-start="7253">≤512 B GATT attribute/application message</strong><br data-end="7301" data-start="7298"><br/><em data-end="7372" data-start="7303">L2CAP / Link Layer segmentation and reassembly handled by BLE stack</em>" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="530" y="930" width="140" height="110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-32" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1215" y="890" />
|
||||
<mxPoint x="1215" y="890" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-151" value="TX Message" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-34" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.2929" y="-2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-116" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-15" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-118" value="BLE packet" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-116" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.0818" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="6" y="1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-15" value="<font style="">BLE</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1130" y="900" width="180" height="150" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-16" value="<font style="">ATT/ GATT</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="930" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-17" value="<font style="">L2CAP</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="970" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-18" value="<font style="">BLE Link Layer</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1145" y="1010" width="150" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-9" target="ynnYWYYo9pkcd0MtkCud-18" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1127" y="1025" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-42" value="segmented<br>BLE packets" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-22" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.5098" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="74" y="-1" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-30" value="<font style="">GeoNetworking SHB</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="531" y="846" width="177" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-90" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="710" y="861" />
|
||||
<mxPoint x="710" y="861" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-31" value="<font style="">synced transmission dir.</font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="720.25" y="846" width="210" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-144" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-141" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1185" y="830" />
|
||||
<mxPoint x="1185" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-150" value="Transmission<br>Power" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-144" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.3161" relative="1" as="geometry">
|
||||
<mxPoint y="-3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-145" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="Iy5nDPde1Y9wQlhL-YeU-39" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1100" y="830" />
|
||||
<mxPoint x="1100" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-149" value="Transmission<br>Direction" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-145" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.3251" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="1" y="-3" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-32" value="<font style="">Message Splitter<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1060" y="837" width="310" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-44" target="ynnYWYYo9pkcd0MtkCud-45" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-129" value="MPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-48" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.4731" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="2" y="-4" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-44" value="<font style="">MAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1229" y="698" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-130" value="PPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-49" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.5724" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="-1" y="-7" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-127" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-45" target="ynnYWYYo9pkcd0MtkCud-126" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1439" y="640" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-45" value="<font style="">PHY<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1229" y="630" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-50" value="" style="verticalLabelPosition=bottom;shadow=0;dashed=0;align=center;html=1;verticalAlign=top;shape=mxgraph.electrical.radio.aerial_-_antenna_1;" parent="1" vertex="1">
|
||||
<mxGeometry x="1187" y="280" width="30" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-56" value="802.11p @ 5.9GHz" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="1" vertex="1" connectable="0">
|
||||
<mxGeometry x="1250.0006451612903" y="310" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-57" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.75;entryDx=0;entryDy=0;" parent="1" source="3a17f1ce550125da-2" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="590" y="790" as="sourcePoint" />
|
||||
<mxPoint x="940" y="790" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-68" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="490" as="sourcePoint" />
|
||||
<mxPoint x="941" y="490" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-70" value="if rx enabled" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="720" y="320" width="205" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-71" value="GLOSA" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="355" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-72" value="Collision Warning" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="396" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-73" value="Recorder/ Logger" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="730" y="435" width="180" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-79" value="Decode<br>VAM" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="451.5" y="600" width="68.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-80" value="Reception<br>Management" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="531" y="600" width="111.5" height="52" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-82" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.71;entryY=1.005;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="850" y="610" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="731" y="725" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-83" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.559;entryY=1.01;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-16" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="670" y="725" />
|
||||
<mxPoint x="670" y="680" />
|
||||
<mxPoint x="669" y="680" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-86" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-33" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="596" y="840" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="570" y="822" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-93" value="encrypted" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" parent="1" vertex="1">
|
||||
<mxGeometry x="946" y="935" width="80" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-95" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1500" y="580" width="110" height="470" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-99" value="<font style="">Secure<br>Boot<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="837" width="90" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-102" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-100" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-153" value="BLE Credentials" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-102" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.5722" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="52" y="9" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-100" value="<font style="">BLE<br>credentials<br>config<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="965" width="90" height="73" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-101" value="<font style="">Debug<br>lockdown<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1510" y="902" width="90" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-106" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-103" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="430" y="580" />
|
||||
<mxPoint x="430" y="580" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-103" value="<font style="">Platform<br>Security<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="300" y="300" width="120" height="740" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-105" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-104" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-104" value="<font style="">BLE<br>credentials<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="982" width="100" height="48" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-108" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1.008;entryY=0.559;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="Iy5nDPde1Y9wQlhL-YeU-12" target="Iy5nDPde1Y9wQlhL-YeU-33" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="850" y="824" />
|
||||
<mxPoint x="684" y="824" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-109" value="PCI" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-108" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.0782" y="2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-110" value="<font style="">LLC/SNAP<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1230" y="760" width="140" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-111" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-32" target="ynnYWYYo9pkcd0MtkCud-110" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1300" y="810" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="1300" y="830" />
|
||||
<mxPoint x="1300" y="830" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-112" value="GNPDU" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-111" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.1526" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-113" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.502;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-110" target="ynnYWYYo9pkcd0MtkCud-44" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-114" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.297;entryY=-0.004;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" source="ynnYWYYo9pkcd0MtkCud-30" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="618" y="890" />
|
||||
<mxPoint x="618" y="890" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-115" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-31" target="ynnYWYYo9pkcd0MtkCud-3" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="810" y="896" />
|
||||
<mxPoint x="810" y="896" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-119" value="<font style="">Certificate<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="720" width="100" height="33" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-122" value="<font style="">Private Key Handling<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="920" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-123" value="<font style="">Signer Selection<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="770" width="100" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-125" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-124" target="ynnYWYYo9pkcd0MtkCud-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="470" y="865" />
|
||||
<mxPoint x="470" y="865" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-124" value="<font style="">Secured<br>Message<br>Creation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="310" y="837" width="100" height="70" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-126" value="<font style="">DCC-ACC Calculation<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;fontColor=#B3B3B3;" parent="1" vertex="1">
|
||||
<mxGeometry x="1390" y="655" width="97" height="55" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-128" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;jumpStyle=arc;fontColor=#B3B3B3;strokeColor=#B3B3B3;" parent="1" source="ynnYWYYo9pkcd0MtkCud-126" target="ynnYWYYo9pkcd0MtkCud-15" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1129" y="930" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="1440" y="930" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-152" value="DCC Feedback" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontColor=#B3B3B3;" parent="ynnYWYYo9pkcd0MtkCud-128" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.7303" relative="1" as="geometry">
|
||||
<mxPoint x="13" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-131" value="" style="endArrow=none;html=1;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="430" y="793" as="sourcePoint" />
|
||||
<mxPoint x="940" y="793" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-133" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="847" y="787" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-136" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="883.78" as="sourcePoint" />
|
||||
<mxPoint x="941" y="883.78" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-137" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="647" y="787" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-140" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-139" target="Iy5nDPde1Y9wQlhL-YeU-21" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="895" y="580" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-139" value="<font style="">DCC-<br>FAC<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="865" y="500" width="60" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-142" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="ynnYWYYo9pkcd0MtkCud-141" target="Iy5nDPde1Y9wQlhL-YeU-34" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1185" y="584" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-148" value="TX power <br>config" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="ynnYWYYo9pkcd0MtkCud-142" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.4822" y="1" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-141" value="<font style="">Radio<br>Control<br></font>" style="whiteSpace=wrap;html=1;shadow=1;fontSize=18;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" parent="1" vertex="1">
|
||||
<mxGeometry x="1150" y="746" width="70" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-155" value="" style="endArrow=none;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="1" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="431" y="887" as="sourcePoint" />
|
||||
<mxPoint x="941" y="887" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-154" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="615" y="881" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ynnYWYYo9pkcd0MtkCud-157" value="" style="rounded=0;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="807" y="881" width="6" height="9" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||
|
||||
Started. See `docs/04-transmit-setup.md` in the project root for build/flash
|
||||
steps and how to validate this against your own sniffer.
|
||||
Build and flash steps are under "Build and flash" below. (`docs/04-transmit-setup.md`,
|
||||
referenced here and in the sources, is not in the repo.)
|
||||
|
||||
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4)
|
||||
|
||||
@@ -29,6 +29,123 @@ referenced an older source path under `micrOBU_workspace/v2x-obu-esp32c5/`,
|
||||
which makes `idf.py fullclean` refuse to run). If that error reappears, delete
|
||||
`build/` manually rather than fighting it.
|
||||
|
||||
## Build and flash
|
||||
|
||||
The firmware needs no button, phone or serial connection to start. On every power-up or reset,
|
||||
`app_main` sets up the radio and starts `tx_task`, which starts driving the simulated route and
|
||||
sending CAMs on 5900 MHz. A board flashed with this image starts beaconing on its own as soon as it
|
||||
gets power.
|
||||
|
||||
In a fresh PowerShell window:
|
||||
|
||||
```powershell
|
||||
$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null
|
||||
. C:\Espressif\frameworks\esp-idf-v5.5.4\export.ps1
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||
```
|
||||
|
||||
1. **Check what the board is running before you flash it.** Both this project and `obu-firmware`
|
||||
name their image `obu_firmware.bin`, so the file name tells you nothing. Read the app
|
||||
descriptor instead (replace `COMx` with the board's port):
|
||||
|
||||
```powershell
|
||||
python -m esptool --chip esp32c5 -p COMx read_flash 0x10000 0x100 $env:TEMP\desc.bin
|
||||
$b = [IO.File]::ReadAllBytes("$env:TEMP\desc.bin")
|
||||
function S($o,$n){ [Text.Encoding]::ASCII.GetString($b,$o,$n).Trim([char]0) }
|
||||
"time=" + (S 0x70 16) + " date=" + (S 0x80 16) + " idf=" + (S 0x90 32)
|
||||
```
|
||||
|
||||
`idf=v6.1...` means the board runs the production OBU (`obu-firmware`). Don't flash this beacon
|
||||
over it. `idf=v5.5.4` means this transmitter, or another bench image.
|
||||
|
||||
2. **Build:**
|
||||
|
||||
```powershell
|
||||
idf.py build
|
||||
```
|
||||
|
||||
A rebuild after small changes takes about 1-2 minutes. The output is
|
||||
`build\obu_firmware.bin`.
|
||||
|
||||
3. **Flash** (the board must be on its UART bridge port or its native USB port):
|
||||
|
||||
```powershell
|
||||
idf.py -p COMx flash
|
||||
```
|
||||
|
||||
The flash is good when esptool prints `Hash of data verified.`, then resets the board.
|
||||
|
||||
4. **Check that it's transmitting:**
|
||||
|
||||
```powershell
|
||||
idf.py -p COMx monitor
|
||||
```
|
||||
|
||||
(Exit with `Ctrl+]`.) About 1.4 s after reset you should see:
|
||||
|
||||
```
|
||||
W obu-tx: OCB @ 5900 MHz - CAM beacon armed, driving a 103-point street loop
|
||||
I obu-tx: CAM sent (119 bytes) @ 5900 MHz genDeltaT=1087 pos=53.5531770,10.0220980 50.0 km/h heading 77.0 pt1
|
||||
```
|
||||
|
||||
After that, a `CAM sent` line appears about 3 times a second, with the position, speed and
|
||||
heading changing. These lines only mean each frame was
|
||||
handed to the radio. To confirm the frames actually went out, capture them with a second
|
||||
ESP32-C5 running the receiver firmware.
|
||||
|
||||
Don't open the console of the production OBU (COM3) while the phone is attached. Opening the port
|
||||
resets that board and drops the phone's USB link. The beacon boards have no phone attached, so
|
||||
this doesn't apply to them.
|
||||
|
||||
## Simulated drive
|
||||
|
||||
The beacon pretends to be a car driving a loop through St. Georg / Berliner Tor in Hamburg, on
|
||||
the real streets. The route comes from six waypoints, which you set in `tools/make_route.py`.
|
||||
|
||||
**Changing the route:** edit `WAYPOINTS` in `tools/make_route.py`, then run
|
||||
|
||||
```powershell
|
||||
py -3.11 tools/make_route.py
|
||||
```
|
||||
|
||||
The script asks the OSRM demo server (router.project-osrm.org) for a legal driving route through the
|
||||
waypoints in order and back to the first, then writes three files:
|
||||
|
||||
- `main/route_points.h`: the street geometry, thinned to points no more than 1.5 m off the line
|
||||
(currently 103 points).
|
||||
- `tools/route_osrm.json`: OSRM's raw answer. `--offline` rebuilds the header from it without the
|
||||
network.
|
||||
- `tools/route_map.html`: the route on an OpenStreetMap map. Open it in a browser and check it
|
||||
before building.
|
||||
|
||||
Then build and flash as above. Route data (c) OpenStreetMap contributors, ODbL; routing by OSRM.
|
||||
|
||||
**Things to know about the routing:**
|
||||
- OSRM follows one-way streets and turn bans, so the loop can be longer than the waypoints suggest.
|
||||
The current one is 5.2 km, with two turn-round detours: a loop via Borgfelder Straße and
|
||||
Anckelmannsplatz between wp2 and wp3, and one round Nagelsweg, Norderstraße and Repsoldstraße
|
||||
between wp5 and wp6. To avoid a detour, move the waypoint on either side of it.
|
||||
- Each waypoint is sent with the direction towards the next one. Without it, points on divided
|
||||
roads (Beim Strohhause, for example) snap to the carriageway going the other way, and the loop
|
||||
grows to 8.4 km of U-turns.
|
||||
|
||||
**How the car drives** (`main/route.c`):
|
||||
- **Speed:** it cruises at 50 km/h (`CRUISE_MPS` in `main/main.c`). Each bend gets a speed limit
|
||||
from its radius, keeping sideways acceleration at 2 m/s², so a 90° junction turn is taken at
|
||||
about 15 km/h and a gentle curve barely slows the car. It never drops below 10 km/h
|
||||
(`MIN_CORNER_MPS`). It brakes at 2 m/s² and accelerates at 1.5 m/s², planning braking across as
|
||||
many points as a bend needs. A lap takes about 7.7 min, averaging 40 km/h.
|
||||
- **Heading:** the compass bearing of the current straight piece. It changes gradually through
|
||||
curves but jumps at sharp junction turns.
|
||||
- **When CAMs are sent:** following ETSI EN 302 637-2, the state is checked every 100 ms. A CAM goes
|
||||
out when the heading changed by more than 4°, the position by more than 4 m, or the speed by more
|
||||
than 0.5 m/s since the last one, and at least once a second. That's about 3 CAMs a second at
|
||||
50 km/h.
|
||||
- **What's filled in:** position, speed and heading go into both the CAM and the GeoNetworking
|
||||
source position vector. `genDeltaT` is milliseconds since boot.
|
||||
- **Testing:** `route.c` only uses standard headers, so you can compile it on the PC with MSYS2 gcc
|
||||
and simulate a lap.
|
||||
|
||||
## CAM encoding
|
||||
|
||||
`main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a
|
||||
@@ -44,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,...)`
|
||||
register hack, GPIO polling, TX loop
|
||||
- `main/denm.c` / `.h` - ASN.1 UPER encoding of a minimal DENM
|
||||
- `main/route.c` / `.h` - simulated drive round the route loop
|
||||
- `main/route_points.h` - the route, generated by `tools/make_route.py`
|
||||
- `main/geonet.c` / `.h` - GeoNetworking Basic/Common/SHB headers + BTP-B
|
||||
- `main/dot11p.c` / `.h` - 802.11 OCB (QoS Data, broadcast) frame + LLC/SNAP
|
||||
|
||||
Known gaps, tracked as TODOs in the source: no real GNSS (lat/long hardcoded
|
||||
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||
Known gaps, tracked as TODOs in the source: no real GNSS (the CAM position comes
|
||||
from the simulated drive above), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
||||
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# wifi_patches.c is intentionally NOT in this list anymore - superseded by
|
||||
# tx_custom.c (see that file for why). Left on disk, unused, for history.
|
||||
idf_component_register(
|
||||
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c"
|
||||
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c" "route.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES esp_event esp_netif nvs_flash driver esp_phy
|
||||
REQUIRES esp_event esp_timer esp_netif nvs_flash driver esp_phy
|
||||
PRIV_REQUIRES esp_wifi
|
||||
)
|
||||
|
||||
@@ -117,9 +117,13 @@ int cam_encode(const cam_fields_t *f, uint8_t *buf, size_t buf_len)
|
||||
bw_put_bits(&bw, 0, 1); // extension bit: value is in the root list
|
||||
bw_put_bits(&bw, 2, 2); // unavailable(2)
|
||||
// YawRate: YawRateValue(-32766..32767)->16 (offset from -32766),
|
||||
// YawRateConfidence ENUM 8 values -> 3 bits
|
||||
// YawRateConfidence ENUM with NINE values, degSec-000-01(0) .. unavailable(8)
|
||||
// (cdd_1_3_1_1.asn) -> 4 bits. This wrote 3 bits with value 7, one bit short and the
|
||||
// wrong symbol (7 is outOfRange), so every field after it shifted for any
|
||||
// standards-compliant receiver. The app's CamUperCodec.kt fixed the same line on
|
||||
// 2026-08-20; this copy was missed until 2026-09-11.
|
||||
bw_put_bits(&bw, 32767 - (uint32_t)(-32766), 16); // yawRateValue: unavailable(32767)
|
||||
bw_put_bits(&bw, 7, 3); // yawRateConfidence: unavailable(7)
|
||||
bw_put_bits(&bw, 8, 4); // yawRateConfidence: unavailable(8)
|
||||
|
||||
// ---- LowFrequencyContainer ---- CHOICE { basicVehicleContainerLowFrequency,
|
||||
// ... } - EXTENSIBLE, 1 root alternative (index needs 0 bits).
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
uint16_t speed_cm_s, uint16_t heading_ddeg,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len)
|
||||
{
|
||||
@@ -19,7 +20,10 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
// ---- GN Basic Header (4 bytes) ---- (EN 302 636-4-1 clause 9.6)
|
||||
*p++ = (uint8_t)((1 << 4) | 1); // version=1, NextHeader=1 (Common Header, unsecured)
|
||||
*p++ = 0x00; // reserved
|
||||
*p++ = 0x83; // lifetime (~60s in the base/multiplier encoding) - tune if needed
|
||||
// Lifetime: multiplier in the upper 6 bits, base in the lower 2 (0 = 50 ms, 1 = 1 s, 2 = 10 s,
|
||||
// 3 = 100 s). 0x05 = 1 x 1 s, what real stations send their CAMs with. Was 0x83, commented as
|
||||
// ~60 s but decoding to 32 x 100 s = 3200 s. See obu-firmware's geonet.c.
|
||||
*p++ = 0x05;
|
||||
*p++ = 1; // remaining hop limit = 1 (SHB single-hop; matches CAM in the Rust reference)
|
||||
|
||||
// ---- GN Common Header (8 bytes) ---- (clause 9.7)
|
||||
@@ -65,12 +69,12 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
||||
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
||||
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
||||
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false,
|
||||
// speed 0 - which is actually correct semantics for a STATIONARY
|
||||
// vehicle beacon, not just a placeholder.
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable
|
||||
*p++ = 0x00; *p++ = 0x00;
|
||||
// PAI(1 bit) + Speed(15 bits, signed, 0.01 m/s), packed into 2 bytes. PAI stays 0: the
|
||||
// position has no accuracy estimate behind it.
|
||||
uint16_t spd = speed_cm_s > 0x7FFF ? 0x7FFF : speed_cm_s;
|
||||
*p++ = (uint8_t)(spd >> 8); *p++ = (uint8_t)(spd & 0xFF);
|
||||
// Heading (16 bits, 0.1 degree units, clockwise from north)
|
||||
*p++ = (uint8_t)(heading_ddeg >> 8); *p++ = (uint8_t)(heading_ddeg & 0xFF);
|
||||
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
||||
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
||||
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
// working (same extended header shape as CAM). Fine for a single-vehicle
|
||||
// beacon; revisit if you need real multi-hop forwarding later.
|
||||
//
|
||||
// `speed_cm_s` (0.01 m/s) and `heading_ddeg` (0.1 deg) also go into the Source Long Position
|
||||
// Vector; pass the same values as the CAM's high-frequency container. Speed is a 15-bit field, so
|
||||
// values above 32767 are clamped.
|
||||
//
|
||||
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
||||
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
||||
//
|
||||
@@ -38,6 +42,7 @@
|
||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||
const uint8_t mac[6], uint8_t station_type,
|
||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||
uint16_t speed_cm_s, uint16_t heading_ddeg,
|
||||
uint16_t btp_dest_port,
|
||||
uint8_t *out, size_t out_len);
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include <math.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_timer.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_event.h"
|
||||
@@ -14,13 +18,17 @@
|
||||
#include "geonet.h"
|
||||
#include "dot11p.h"
|
||||
#include "tx_custom.h"
|
||||
#include "route.h"
|
||||
#include "route_points.h"
|
||||
|
||||
static const char *TAG = "obu-tx";
|
||||
|
||||
// CAM beacon: transmit a Cooperative Awareness Message every TX_INTERVAL_MS,
|
||||
// unconditionally (no hazard-light gating - CAM is a continuous beacon, unlike
|
||||
// the event-triggered DENM). Matches the working Rust reference
|
||||
// (esp32-c_its-companion, feat/tx-cam), which beacons CAM on 5900 MHz.
|
||||
// CAM beacon for a simulated car driving round a block in Hamburg (see route.c). The CAM
|
||||
// generation rules follow ETSI EN 302 637-2 clause 6.1.3: every CHECK_INTERVAL_MS the car's state
|
||||
// is compared with the last CAM sent, and a new CAM goes out when the heading changed by more than
|
||||
// 4 degrees, the position by more than 4 m, the speed by more than 0.5 m/s, or 1 s has passed.
|
||||
// There is no hazard-light gating - CAM is a continuous beacon, unlike the event-triggered DENM.
|
||||
// Transmits on 5900 MHz like the working Rust reference (esp32-c_its-companion, feat/tx-cam).
|
||||
|
||||
// ISOLATION TEST for whether tx_custom.c is the blocker.
|
||||
// 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a
|
||||
@@ -55,15 +63,19 @@ static const char *TAG = "obu-tx";
|
||||
#define VEHICLE_LENGTH_DM 40 // VehicleLengthValue, 10cm steps (4.0 m)
|
||||
#define VEHICLE_WIDTH_DM 18 // VehicleWidth, 10cm steps (1.8 m)
|
||||
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
||||
#define TX_INTERVAL_MS 1000 // CAM beacon period (1 Hz; ITS allows 1-10 Hz)
|
||||
#define CHECK_INTERVAL_MS 100 // T_CheckCamGen: how often the generation rules are evaluated
|
||||
#define CAM_MAX_INTERVAL_MS 1000 // T_GenCamMax: a CAM goes out at least this often
|
||||
#define CAM_HEADING_DDEG 40 // > 4 degrees heading change triggers a CAM
|
||||
#define CAM_POSITION_M 4.0 // > 4 m position change triggers a CAM
|
||||
#define CAM_SPEED_CM_S 50 // > 0.5 m/s speed change triggers a CAM
|
||||
|
||||
// Bench location, hardcoded since there's no GNSS module wired in yet and
|
||||
// the unit is genuinely stationary here: 53°33'16.8"N 10°01'20.6"E, in
|
||||
// 1/10-microdegree units (decimal_degrees * 10,000,000). Replace with real
|
||||
// GNSS output once you have a fix source; until then this beats 0/0
|
||||
// ("Null Island"), which is an obvious placeholder-tell on any map.
|
||||
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
||||
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
||||
// ---- Simulated drive ----
|
||||
// route_points (main/route_points.h) is the street geometry of a driving loop through six waypoints
|
||||
// in St. Georg, generated by tools/make_route.py from OpenStreetMap via OSRM. To change the route,
|
||||
// edit WAYPOINTS in that script and rerun it. No GNSS is wired in; replace with real fixes once
|
||||
// there is one.
|
||||
#define CRUISE_MPS (50.0 / 3.6) // 50 km/h, the urban limit
|
||||
#define MIN_CORNER_MPS (10.0 / 3.6) // slowest the car goes, for hairpins and U-turns
|
||||
|
||||
// Single source of truth for the pseudonym/link-layer address: used both as
|
||||
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
|
||||
@@ -78,33 +90,28 @@ static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
||||
extern void phy_11p_set(int enable, int unused);
|
||||
extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, int unused);
|
||||
|
||||
static void send_cam(void)
|
||||
static void send_cam(const route_state_t *car, uint16_t gen_delta)
|
||||
{
|
||||
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No RTC/GNSS time here,
|
||||
// so use a free-running ms counter that advances one beacon-interval per
|
||||
// send. It wraps at 65536, which is exactly the field's defined behaviour.
|
||||
static uint16_t gen_delta = 0;
|
||||
|
||||
uint8_t frame[300];
|
||||
cam_fields_t fields = {
|
||||
.station_id = STATION_ID,
|
||||
.station_type = STATION_TYPE,
|
||||
.generation_delta_time = gen_delta,
|
||||
.latitude_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG,
|
||||
.longitude_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG,
|
||||
.speed_cm_s = 0, // stationary
|
||||
.heading_ddeg = 3601, // HeadingValue unavailable (no heading source)
|
||||
.latitude_tenmicrodeg = car->latitude_tenmicrodeg,
|
||||
.longitude_tenmicrodeg = car->longitude_tenmicrodeg,
|
||||
.speed_cm_s = car->speed_cm_s,
|
||||
.heading_ddeg = car->heading_ddeg,
|
||||
.vehicle_length_dm = VEHICLE_LENGTH_DM,
|
||||
.vehicle_width_dm = VEHICLE_WIDTH_DM,
|
||||
};
|
||||
gen_delta += TX_INTERVAL_MS;
|
||||
|
||||
uint8_t cam_payload[96];
|
||||
int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload));
|
||||
|
||||
uint8_t gn_payload[160];
|
||||
int gn_len = geonet_wrap_shb(cam_payload, cam_len, pseudonym_mac, STATION_TYPE,
|
||||
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
||||
car->latitude_tenmicrodeg, car->longitude_tenmicrodeg,
|
||||
car->speed_cm_s, car->heading_ddeg,
|
||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||
|
||||
// qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS
|
||||
@@ -127,7 +134,10 @@ static void send_cam(void)
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "CAM sent via STANDARD tx (%d bytes) @ %d MHz genDeltaT=%u", frame_len, TX_FREQ_MHZ, gen_delta);
|
||||
ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz genDeltaT=%u pos=%.7f,%.7f %.1f km/h heading %.1f pt%d",
|
||||
frame_len, TX_FREQ_MHZ, gen_delta,
|
||||
car->latitude_tenmicrodeg / 1e7, car->longitude_tenmicrodeg / 1e7,
|
||||
car->speed_cm_s * 0.036, car->heading_ddeg / 10.0, car->segment + 1);
|
||||
}
|
||||
#else
|
||||
// tx_custom path: submits to the driver's internal HMAC TX path,
|
||||
@@ -151,12 +161,49 @@ static void send_cam(void)
|
||||
}
|
||||
}
|
||||
|
||||
static bool cam_due(const route_state_t *car, const route_state_t *last, int64_t since_last_ms)
|
||||
{
|
||||
if (since_last_ms >= CAM_MAX_INTERVAL_MS) {
|
||||
return true;
|
||||
}
|
||||
int dh = abs((int)car->heading_ddeg - (int)last->heading_ddeg);
|
||||
if (dh > 1800) {
|
||||
dh = 3600 - dh;
|
||||
}
|
||||
if (dh > CAM_HEADING_DDEG) {
|
||||
return true;
|
||||
}
|
||||
if (abs((int)car->speed_cm_s - (int)last->speed_cm_s) > CAM_SPEED_CM_S) {
|
||||
return true;
|
||||
}
|
||||
// Flat-earth distance is plenty for a 4 m threshold.
|
||||
double north_m = (car->latitude_tenmicrodeg - last->latitude_tenmicrodeg) * 0.0111194930;
|
||||
double east_m = (car->longitude_tenmicrodeg - last->longitude_tenmicrodeg) * 0.0111194930
|
||||
* cos(car->latitude_tenmicrodeg / 1e7 * M_PI / 180.0);
|
||||
return north_m * north_m + east_m * east_m > CAM_POSITION_M * CAM_POSITION_M;
|
||||
}
|
||||
|
||||
static void tx_task(void *arg)
|
||||
{
|
||||
route_state_t car;
|
||||
route_state_t last_sent;
|
||||
int64_t last_sent_ms = 0;
|
||||
bool sent_any = false;
|
||||
TickType_t wake = xTaskGetTickCount();
|
||||
|
||||
route_step(0.0, &car);
|
||||
while (1) {
|
||||
// CAM is a continuous beacon - send every interval, unconditionally.
|
||||
send_cam();
|
||||
vTaskDelay(pdMS_TO_TICKS(TX_INTERVAL_MS));
|
||||
int64_t now_ms = esp_timer_get_time() / 1000;
|
||||
if (!sent_any || cam_due(&car, &last_sent, now_ms - last_sent_ms)) {
|
||||
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No real clock here, so use
|
||||
// milliseconds since boot, which advances at the right rate.
|
||||
send_cam(&car, (uint16_t)now_ms);
|
||||
last_sent = car;
|
||||
last_sent_ms = now_ms;
|
||||
sent_any = true;
|
||||
}
|
||||
vTaskDelayUntil(&wake, pdMS_TO_TICKS(CHECK_INTERVAL_MS));
|
||||
route_step(CHECK_INTERVAL_MS / 1000.0, &car);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,8 +302,13 @@ void app_main(void)
|
||||
phy_change_channel(TX_FREQ_MHZ, 1, 0, 0);
|
||||
ESP_LOGI(TAG, "phy_change_channel returned");
|
||||
|
||||
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, transmitting every %d ms",
|
||||
TX_FREQ_MHZ, TX_INTERVAL_MS);
|
||||
if (route_init(route_points, sizeof(route_points) / sizeof(route_points[0]),
|
||||
CRUISE_MPS, MIN_CORNER_MPS) != 0) {
|
||||
ESP_LOGE(TAG, "route_init failed - check route_points");
|
||||
return;
|
||||
}
|
||||
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, driving a %d-point street loop",
|
||||
TX_FREQ_MHZ, (int)(sizeof(route_points) / sizeof(route_points[0])));
|
||||
|
||||
xTaskCreate(tx_task, "tx_task", 4096, NULL, 5, NULL);
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,9 +1,64 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
# obu-firmware: the ESP32-C5 half of the MicrOBU station, on the vanetza-idf C-ITS stack.
|
||||
#
|
||||
# Since 2026-09-23 this is a port of the colleague's standalone VRU station
|
||||
# (microbu-esp32c5/firmware, in their own repository, not part of this one). From it:
|
||||
# BTP/GeoNetworking and the TS 103 097 security entity (vanetza-idf), the station-link message
|
||||
# layer over native USB Serial/JTAG and BLE GATT, the NVS credential store, and the C5 radio adapter.
|
||||
# Added here for this project: the raw receive path of the previous firmware (gn_unwrap.c, forwarded
|
||||
# as link opcode V2X_RX) so unsigned and non-demo-signed traffic still reaches the phone, the
|
||||
# unsigned transmit path of the previous firmware (geonet.c), and BLE pausing while USB is in use.
|
||||
# See NOTES.md.
|
||||
#
|
||||
# ESP-IDF 6.0.2 exactly: the C5 radio's private Wi-Fi driver ABI (otm_tx_custom.c) is pinned to it
|
||||
# by vanetza-idf's radio_c5.cmake and has only been validated there. The previous C firmware was
|
||||
# built with IDF 6.1; see FLASHING.md for the export script of each.
|
||||
#
|
||||
# vanetza-idf lives in external/vanetza-idf, copied from the colleague's repository (their
|
||||
# external/vanetza-idf at commit cf4b99f, unchanged; see its PROVENANCE.md). Override with
|
||||
# -DVANETZA_IDF_DIR=... to build against another checkout.
|
||||
if(NOT VANETZA_IDF_DIR)
|
||||
set(VANETZA_IDF_DIR "${CMAKE_CURRENT_LIST_DIR}/external/vanetza-idf")
|
||||
endif()
|
||||
if(NOT EXISTS "${VANETZA_IDF_DIR}/idf_component.yml")
|
||||
message(FATAL_ERROR "vanetza-idf not found at ${VANETZA_IDF_DIR}: obu-firmware/external/vanetza-idf "
|
||||
"is part of this repository; check it is complete, or pass "
|
||||
"-DVANETZA_IDF_DIR=<path to a vanetza-idf checkout>")
|
||||
endif()
|
||||
|
||||
# Provenance guard for main/otm_tx_custom.c, copied unchanged from microbu-esp32c5/firmware/CMakeLists.txt:
|
||||
# this checked-in copy is what microbu::C5Radio::request() links against for every transmission.
|
||||
# Fail configure if either the pinned upstream or this file drifts from the reviewed revision.
|
||||
set(_otm_upstream "${VANETZA_IDF_DIR}/ports/esp_idf/third_party/otm/main/tx_custom.c")
|
||||
if(EXISTS "${_otm_upstream}")
|
||||
file(READ "${_otm_upstream}" _otm_upstream_text)
|
||||
string(REPLACE "\r\n" "\n" _otm_upstream_text "${_otm_upstream_text}")
|
||||
string(SHA256 _otm_upstream_hash "${_otm_upstream_text}")
|
||||
if(NOT _otm_upstream_hash STREQUAL "cb1dccfef96912ca59275e8a9102f41f56925b94a19d4a4629082aaeb779be1b")
|
||||
message(FATAL_ERROR "OpenTrafficMap upstream tx_custom.c differs from the reviewed source revision (674e3412) -- review before updating main/otm_tx_custom.c and this hash")
|
||||
endif()
|
||||
endif()
|
||||
set(_otm_checked_in "${CMAKE_CURRENT_LIST_DIR}/main/otm_tx_custom.c")
|
||||
file(READ "${_otm_checked_in}" _otm_checked_in_text)
|
||||
string(REPLACE "\r\n" "\n" _otm_checked_in_text "${_otm_checked_in_text}")
|
||||
string(SHA256 _otm_checked_in_hash "${_otm_checked_in_text}")
|
||||
if(NOT _otm_checked_in_hash STREQUAL "114693af99ce3866cfc767066d484e45822eaf15335d94a03626b60ac5777277")
|
||||
message(FATAL_ERROR "main/otm_tx_custom.c differs from the reviewed copy -- review the change, then update this hash")
|
||||
endif()
|
||||
|
||||
list(APPEND EXTRA_COMPONENT_DIRS "${VANETZA_IDF_DIR}")
|
||||
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
|
||||
set(COMPONENTS main)
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
|
||||
# No longer need -Wl,-zmuldefs here - that was only for main/wifi_patches.c's
|
||||
# symbol-override attempt (which didn't work anyway; see docs/04-transmit-setup.md),
|
||||
# and that file is no longer part of the build. Superseded by main/tx_custom.c,
|
||||
# which bypasses the gate at a different layer instead of trying to override it.
|
||||
|
||||
project(obu_firmware)
|
||||
add_compile_options(-Wno-error -Wno-cpp -Wno-error=implicit-function-declaration)
|
||||
idf_component_get_property(vanetza_lib vanetza-idf COMPONENT_LIB)
|
||||
if(vanetza_lib)
|
||||
target_compile_options(${vanetza_lib} PRIVATE -Wno-error=implicit-function-declaration -Wno-error=cpp)
|
||||
endif()
|
||||
# GCC 15's stricter -Warray-bounds false-positives on NimBLE's fixed-size bond-store arrays
|
||||
# (upstream Apache Mynewt code); demote to a warning so the component still builds.
|
||||
idf_component_get_property(bt_lib bt COMPONENT_LIB)
|
||||
if(bt_lib)
|
||||
target_compile_options(${bt_lib} PRIVATE -Wno-error=array-bounds)
|
||||
endif()
|
||||
|
||||
+101
-27
@@ -2,35 +2,93 @@
|
||||
|
||||
## Two toolchains - use a dedicated terminal for each
|
||||
|
||||
This project builds against the receiver-firmware's pinned ESP-IDF **6.1**.
|
||||
The separate `obu-cam-transmistter` project builds against the global ESP-IDF
|
||||
**5.5.4**. Exporting both in one PowerShell window fails: the second export
|
||||
Since 2026-09-23 this project builds against ESP-IDF **6.0.2** exactly
|
||||
(`C:\Espressif\frameworks\esp-idf-v6.0.2`): it is the vanetza-idf port (see
|
||||
NOTES.md), and vanetza-idf's `radio_c5.cmake` refuses any other version because
|
||||
the raw TX path pokes private Wi-Fi driver structures only validated there. The
|
||||
previous C firmware used the receiver firmware's IDF **6.1**; the separate
|
||||
`obu-cam-transmistter` project builds against the global ESP-IDF **5.5.4**.
|
||||
Exporting two of them in one PowerShell window fails: the second export
|
||||
inherits the first's `IDF_PYTHON_ENV_PATH` and reports every Python dependency
|
||||
as unmet. Don't run `install.bat` to "fix" that - open a fresh terminal, or
|
||||
clear the state with `$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null`.
|
||||
|
||||
## Every new PowerShell session
|
||||
The build is self-contained: the C-ITS library comes from
|
||||
`obu-firmware/external/vanetza-idf`, a copy of `external/vanetza-idf` from the
|
||||
colleague's microbu-esp32c5 repository (their commit cf4b99f, unchanged). Pass
|
||||
`-DVANETZA_IDF_DIR=<path>` to `idf.py` to build against another checkout. The
|
||||
first build downloads `espressif/esp-boost` into `managed_components/`.
|
||||
|
||||
Activate the toolchain (obu-firmware has no esp-idf of its own — reuse the
|
||||
receiver firmware's already-installed checkout):
|
||||
Nothing is fetched from or pushed to the colleague's repository (HAW GitLab,
|
||||
urban-mobility-lab/microbu/microbu-esp32c5). To take a newer vanetza-idf from
|
||||
it, copy their `external/vanetza-idf` over this folder, rebuild and test, and
|
||||
commit it here. The rest of their tree (their own VAM firmware, PKI tooling,
|
||||
station-link Python tools, the V2X2MAP bridge) is not part of this repository.
|
||||
|
||||
## Every new PowerShell session
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
C:\Users\Ashin\Documents\micrOBU_workspace\its-g5-receiver-firmware\esp-idf\export.ps1
|
||||
idf.py --version
|
||||
$env:IDF_TOOLS_PATH = "C:\Espressif"
|
||||
C:\Espressif\frameworks\esp-idf-v6.0.2\export.ps1
|
||||
idf.py --version # v6.0.2
|
||||
```
|
||||
|
||||
## Build & flash
|
||||
|
||||
Flash over the board's **UART-bridge port** (CH343; COM3 for the production OBU
|
||||
on the bench), not the native port the phone uses.
|
||||
|
||||
```powershell
|
||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-firmware
|
||||
idf.py set-target esp32c5 # only needed once per clean build folder
|
||||
idf.py build
|
||||
idf.py -p COM5 -b 921600 flash monitor
|
||||
idf.py -p COM3 -b 921600 flash monitor
|
||||
```
|
||||
|
||||
Swap `COM5` for whatever port the ESP32-C5 enumerates as (Device Manager →
|
||||
Swap `COM3` for whatever port the ESP32-C5's bridge enumerates as (Device Manager →
|
||||
Ports). `monitor` opens the serial console after flashing — `Ctrl+]` to exit.
|
||||
Use `flash` (bootloader + partition table + app), not `app-flash`, whenever the
|
||||
partition table may differ from what the board has.
|
||||
|
||||
### First flash onto a board with the previous firmware
|
||||
|
||||
The partition table changed with the port (NVS 24 KB → 80 KB, app 0x10000 →
|
||||
0x20000), and the old NVS is full of Wi-Fi settings the previous firmware left
|
||||
behind. Erase the whole new NVS range once, then do a full flash:
|
||||
|
||||
```powershell
|
||||
python -m esptool --chip esp32c5 -p COM3 -b 921600 erase-region 0x9000 0x15000
|
||||
idf.py -p COM3 -b 921600 flash
|
||||
```
|
||||
|
||||
Without the erase, the board cannot store the BLE bond and the phone has to pair
|
||||
on every connection (2026-09-23). After it:
|
||||
|
||||
- the boot log shows `0 bonded phone(s) in NVS`, `BLE advertising started as
|
||||
'micrOBU-XXXX'` and `station task ready`; the radio stays off until the app
|
||||
connects;
|
||||
- on the first Connect the app provisions the demo credentials once (the card
|
||||
says "Provisioning the demo credentials"); they stay in NVS from then on;
|
||||
- a phone that was paired with the board before must forget `micrOBU-XXXX` in
|
||||
Android's Bluetooth settings and pair again (passkey 123456).
|
||||
|
||||
Erasing NVS later (e.g. after a partition change) has the same effects.
|
||||
|
||||
## Going back to the previous firmware
|
||||
|
||||
The previous, unsigned C firmware (frame types 0x01-0x05, CAM over USB only)
|
||||
can come back two ways. The app detects it and falls back to its protocol.
|
||||
|
||||
- **Image:** `firmware-backups/` in the repository root (gitignored, on the lab
|
||||
laptop only) holds a full-flash image of the COM3 board as it was before the
|
||||
port, with the esptool command in its README.txt. It restores the old
|
||||
partition table and NVS too.
|
||||
- **Source:** commit `7285fa1` is the last one before the port. Check it out in
|
||||
a separate worktree and build it with the receiver firmware's IDF 6.1
|
||||
(`its-g5-receiver-firmware\esp-idf\export.ps1`). Erase the NVS range as above
|
||||
first, then `flash` (full, since the partition table differs); erasing after
|
||||
flashing would wipe the start of the old app, which sits at 0x10000.
|
||||
|
||||
## If the build fails
|
||||
|
||||
@@ -50,9 +108,9 @@ Ports). `monitor` opens the serial console after flashing — `Ctrl+]` to exit.
|
||||
The board has two USB-C ports — use the right one:
|
||||
|
||||
- **Native USB-C port** (labeled for JTAG/native USB, up to 12 Mbps) — this
|
||||
is where the phone plugs in via USB-OTG. The CAM serial link
|
||||
(`serial_link.c`) runs over the ESP32-C5's native USB Serial/JTAG
|
||||
peripheral on this port, enumerating as a CDC-ACM device under Espressif's
|
||||
is where the phone plugs in via USB-OTG. The station link
|
||||
(`serial_link.cpp`: station-link messages as frame type 0x10 in the 0xAA55
|
||||
framing) runs over the ESP32-C5's native USB Serial/JTAG peripheral on this port, enumerating as a CDC-ACM device under Espressif's
|
||||
VID/PID (0x303A/0x1001).
|
||||
- **UART-bridge port** (labeled for flashing) — this is what you use for
|
||||
`idf.py flash monitor` from your PC. Leave the phone unplugged from this
|
||||
@@ -69,10 +127,10 @@ at all — that isolates a bad/charge-only OTG cable from an app-side issue.
|
||||
|
||||
Work down this list — each step isolates the layer below it.
|
||||
|
||||
1. **Flash and install together.** `SERIAL_LINK_MAX_PAYLOAD` is 512 on both sides.
|
||||
A phone at 512 talking to firmware still at 160 (or vice versa) silently
|
||||
rejects every large frame at the `length exceeds max, resync` branch. Never
|
||||
update one side alone.
|
||||
1. **Flash and install together.** The app detects the firmware generation by
|
||||
its heartbeat and speaks either protocol, but only an app from 2026-09-23 on
|
||||
knows the station-link protocol; messages are at most 512 octets on both
|
||||
sides.
|
||||
2. **Does Android see the device at all?** Plug the phone into the **native**
|
||||
USB-C port, hit Connect, and read logcat for `UsbSerialTransport`. It logs
|
||||
every attached device *and* each device's interfaces. Empty list = cable /
|
||||
@@ -81,10 +139,12 @@ Work down this list — each step isolates the layer below it.
|
||||
composite device — expect CDC control (class 2) + CDC data (class 10) +
|
||||
vendor-specific JTAG (class 255) in that dump. Compare against the `ports=`
|
||||
count on the `matched device` line.
|
||||
4. **Is the link alive?** The firmware sends a STATUS heartbeat at 1 Hz
|
||||
regardless of radio traffic, and the app marks the link ERROR after ~3.5 s of
|
||||
silence. Connected-and-staying-connected means device→host actually works.
|
||||
5. **If it connects but no CAM_RX ever arrives** — suspect DTR. The app now
|
||||
4. **Is the link alive?** The firmware sends a STATUS at 1 Hz regardless of
|
||||
radio traffic, and the app marks the link ERROR after ~3.5 s of silence.
|
||||
Connected-and-staying-connected means device→host actually works. The board
|
||||
starts its radio only after the app's STATION_CONFIGURE, so nothing is
|
||||
received before Connect.
|
||||
5. **If it connects but no V2X_RX ever arrives** — suspect DTR. The app now
|
||||
asserts DTR/RTS on open (`openDevice()` in `UsbSerialTransport.kt`), because
|
||||
`CdcAcmSerialDriver` doesn't do it by default and the ESP32's USB Serial/JTAG
|
||||
endpoint may gate TX on the host opening the CDC line. **This is still
|
||||
@@ -93,14 +153,28 @@ Work down this list — each step isolates the layer below it.
|
||||
next to the VID/PID note, so nobody has to guess again.
|
||||
6. **Watch the counters, not just "Sent: N".** The CAM Pinger card shows
|
||||
consecutive write failures (phone side) and the firmware's tx-failure /
|
||||
oversize-drop / CRC-error totals from the heartbeat. A rising `tx fail` means
|
||||
CAMs reach the ESP32 but `esp_wifi_80211_tx` rejects them — a radio problem,
|
||||
not a link problem.
|
||||
RX-queue-drop / CRC-error totals from the heartbeat; the connection card
|
||||
shows tickets, signed and refused counts. A rising `tx fail` means messages
|
||||
reach the ESP32 but the radio refuses them — a radio problem, not a link
|
||||
problem. A rising `refused` with signing on usually means no ticket (NVS
|
||||
erased: reconnect so the app provisions again).
|
||||
|
||||
## Connecting over Bluetooth instead
|
||||
|
||||
Settings > Connection > ESP32-C5 > link: Bluetooth, then Connect. The board
|
||||
advertises as `micrOBU-XXXX` (last two bytes of its BT MAC; `micrOBU-4AFA` on
|
||||
COM3), but only while nothing uses its native USB port. Android asks to pair
|
||||
the first time: passkey **123456** (fixed in `simple_ble.cpp`). The bond is kept
|
||||
on both sides; the board keeps one bond, so pairing a second phone or a PC
|
||||
replaces the first. Log lines on the console start with `cits_ble:`.
|
||||
|
||||
## Notes
|
||||
|
||||
- No `git submodule update` needed here — obu-firmware has no pinned
|
||||
submodule of its own, unlike its-g5-receiver-firmware.
|
||||
- Don't use the global "ESP-IDF 5.5 PowerShell" shortcut — always export from
|
||||
the receiver-firmware's pinned checkout, since this firmware's undocumented
|
||||
PHY/driver internals were verified against that specific build.
|
||||
- Don't use the global "ESP-IDF 5.5 PowerShell" shortcut or the receiver
|
||||
firmware's 6.1 checkout — export from `esp-idf-v6.0.2`, the version the
|
||||
vanetza-idf radio's undocumented driver internals were verified against.
|
||||
- The console (ESP_LOG, boot messages, panics) stays on the UART-bridge port.
|
||||
Opening it resets the board; do that only while nothing else depends on the
|
||||
session.
|
||||
|
||||
+55
-1
@@ -1,4 +1,58 @@
|
||||
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||
# obu-firmware
|
||||
|
||||
## Since 2026-09-23: signed ITS on vanetza-idf
|
||||
|
||||
This firmware is a port of the colleague's standalone ESP32-C5 VRU station
|
||||
(`microbu-esp32c5/firmware`, in their own repository; only its `external/vanetza-idf` is copied
|
||||
here, to `obu-firmware/external/vanetza-idf`).
|
||||
From it: the vanetza-idf C-ITS stack (BTP, GeoNetworking, the TS 103 097 security entity with
|
||||
credentials in NVS), the station-link protocol v1 (`link_protocol.*`, `link_service.*`) over the
|
||||
native USB port (`serial_link.*`, frame type 0x10 in the same 0xAA55 framing as before) and over BLE
|
||||
GATT (`simple_ble.*`), and the radio adapter (`c5_radio.*`, `otm_tx_custom.c`). Build with ESP-IDF
|
||||
**6.0.2**; see FLASHING.md.
|
||||
|
||||
The phone builds CAM or VAM, configures the station, provisions credentials, sends PoTi and hands
|
||||
each message over as a BTP-DATA.request; the firmware adds GN/BTP, signs with the authorization
|
||||
ticket, and transmits. The phone side is `Esp32Link.kt` in the app.
|
||||
|
||||
Changed or added for this project (search for `MicrOBU:` in the sources):
|
||||
|
||||
- **Reception stays as it was.** vanetza-idf decapsulates with `itsGnSnDecapResultHandling =
|
||||
STRICT`, so it drops unsigned traffic (the bench sim car) and everything signed under a root other
|
||||
than the provisioned demo root (every RSU). Every captured frame therefore also goes through the
|
||||
previous firmware's `gn_unwrap.c` and reaches the phone as link opcode `V2X_RX` (0x85), whose body
|
||||
is exactly the old `SERIAL_MSG_V2X_RX` payload (`Station::forward_raw`). The app's receive side is
|
||||
unchanged.
|
||||
- **Unsigned transmission is still possible.** The colleague's station refuses unsecured requests.
|
||||
Here a request with GN security profile 1 goes out with the previous firmware's `geonet.c` header
|
||||
and the position of the last PoTi (`Station::unsecured_request`); the app's "Sign outgoing
|
||||
messages" setting decides per message.
|
||||
- **Console on UART0.** ESP_LOG stays on the CH343 bridge port (COM3 on the bench); the native port
|
||||
carries only link frames. The colleague's single-port board routes the log into LOG frames there
|
||||
(`CONFIG_MICROBU_LOG_OVER_LINK`, off here).
|
||||
- **BLE pauses while USB is in use** (`CONFIG_MICROBU_BLE_USB_IDLE_MS`, 3 s): BLE and ITS-G5 share
|
||||
the C5's one RF front end. Whether a live BLE connection disturbs 5.9 GHz is still unmeasured
|
||||
(TODO.md).
|
||||
- A serial write no longer stalls the station task when no USB host is present (BLE-only use).
|
||||
- A notification longer than the ATT MTU is dropped instead of silently truncated.
|
||||
- The Wi-Fi RX callback's 2.4 KB capture buffer is static rather than on the driver task's stack
|
||||
(the previous firmware's commit 04b0076 fixed the same risk).
|
||||
- Manual country policy and the TX-power read-back from the previous firmware's radio bring-up.
|
||||
- The activity LED (GPIO27 on the colleague's XIAO board) is off unless configured.
|
||||
|
||||
Credentials: the app ships `assets/demo-chain.vcr`, a throwaway chain generated 2026-09-23 with the
|
||||
colleague's `vidf_issue` (root `6E7D0374FB021901`, AA `B3312F29844299E0`, AT `B80B49387A4C12EB`
|
||||
valid two years, permissions psid 36 SSP `010000` and psid 638 SSP `01`). The colleague's own demo
|
||||
chain only grants psid 638 and so cannot sign CAMs. Not EU-registered: receivers that verify against
|
||||
the EU trust list drop what it signs.
|
||||
|
||||
Time: the signature's generationTime comes from the PoTi timestamp, which follows the app's
|
||||
`ItsTime` convention (UTC-based, no leap seconds). The colleague's VBS adds the 5 leap seconds.
|
||||
Which one is right is the open question documented in `ItsTime.kt`.
|
||||
|
||||
## Earlier notes (Phase 2, superseded)
|
||||
|
||||
### OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||
|
||||
Started. See `docs/04-transmit-setup.md` in the project root for build/flash
|
||||
steps and how to validate this against your own sniffer.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
dependencies:
|
||||
espressif/esp-boost:
|
||||
component_hash: 45cfa63ade2ad7c489203ab2ddf3f33be50ec9cab752b6eb17a79f06aee8f8f0
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.3'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 0.4.1
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
version: 6.0.2
|
||||
direct_dependencies:
|
||||
- espressif/esp-boost
|
||||
- idf
|
||||
manifest_hash: 3fca1283d556ade5b08c63857e23cb3b08582f1d1c24bb7c1d5e374f438415d7
|
||||
target: esp32c5
|
||||
version: 3.0.0
|
||||
@@ -0,0 +1,14 @@
|
||||
build
|
||||
.cache/
|
||||
build-*/
|
||||
build-*.log
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
sdkconfig.*.old
|
||||
managed_components/
|
||||
dependencies.lock
|
||||
__pycache__/
|
||||
conanfile.pyc
|
||||
doxygen/html/
|
||||
www/
|
||||
build.asn1/
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
if(ESP_PLATFORM)
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/ports/esp_idf/CMakeLists.txt")
|
||||
return()
|
||||
endif()
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
cmake_policy(VERSION 3.14...3.31)
|
||||
project(Vanetza VERSION 26.02)
|
||||
set(VANETZA_SOVERSION 0)
|
||||
list(APPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/cmake)
|
||||
|
||||
# Look up threading library (usually pthread)
|
||||
set(CMAKE_THREAD_PREFER_PTHREAD TRUE)
|
||||
set(THREADS_PREFER_PTHREAD_FLAG TRUE)
|
||||
find_package(Threads MODULE)
|
||||
|
||||
# Build configuration options
|
||||
option(BUILD_SHARED_LIBS "Build shared libraries" OFF)
|
||||
|
||||
option(BUILD_TESTS "Build unit tests" OFF)
|
||||
if(BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(gtest)
|
||||
endif()
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/bin)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/lib)
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/lib/static)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
include(UseVanetza)
|
||||
|
||||
# Enable usage of targets' folder property (good for Visual Studio)
|
||||
set_property(GLOBAL PROPERTY USE_FOLDERS ON)
|
||||
|
||||
# Find project dependencies
|
||||
find_package(Boost 1.70 REQUIRED COMPONENTS date_time OPTIONAL_COMPONENTS program_options CONFIG)
|
||||
vanetza_optional_dependency(GeographicLib 1.37 VANETZA_WITH_GEOGRAPHICLIB "Use GeographicLib for precise geodesy")
|
||||
vanetza_optional_dependency(CapnProto 0.8 VANETZA_WITH_RPC "Enable RPC support")
|
||||
vanetza_optional_dependency(CryptoPP 5.6.1 VANETZA_WITH_CRYPTOPP "Enable Crypto++ support")
|
||||
vanetza_optional_dependency(OpenSSL 1.1.1 VANETZA_WITH_OPENSSL "Enable OpenSSL extensions")
|
||||
|
||||
option(VANETZA_WITH_PQC "Enable the experimental hybrid-PQC certificate profile" OFF)
|
||||
if(VANETZA_WITH_PQC)
|
||||
find_package(liboqs 0.14 CONFIG REQUIRED)
|
||||
endif()
|
||||
|
||||
option(VANETZA_EMBED_COUNTRY_DATA "Embed country boundary data into geodesy library" OFF)
|
||||
|
||||
add_subdirectory(vanetza/access)
|
||||
add_subdirectory(vanetza/asn1)
|
||||
add_subdirectory(vanetza/btp)
|
||||
add_subdirectory(vanetza/common)
|
||||
add_subdirectory(vanetza/dcc)
|
||||
add_subdirectory(vanetza/facilities)
|
||||
add_subdirectory(vanetza/geodesy)
|
||||
add_subdirectory(vanetza/geonet)
|
||||
add_subdirectory(vanetza/gnss)
|
||||
add_subdirectory(vanetza/net)
|
||||
add_subdirectory(vanetza/rpc)
|
||||
add_subdirectory(vanetza/security)
|
||||
add_subdirectory(vanetza/units)
|
||||
|
||||
option(BUILD_SOCKTAP "Build socktap application" OFF)
|
||||
if(BUILD_SOCKTAP)
|
||||
add_subdirectory(tools/socktap)
|
||||
endif()
|
||||
|
||||
option(BUILD_CERTIFY "Build certify application" OFF)
|
||||
if(BUILD_CERTIFY)
|
||||
add_subdirectory(tools/certify)
|
||||
if(VANETZA_WITH_PQC)
|
||||
add_subdirectory(tools/certify-pqc)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
option(BUILD_BENCHMARK "Build benchmark application" OFF)
|
||||
if(BUILD_BENCHMARK)
|
||||
add_subdirectory(tools/benchmark)
|
||||
endif()
|
||||
|
||||
option(BUILD_FUZZ "Build fuzz harness" OFF)
|
||||
if(BUILD_FUZZ)
|
||||
add_subdirectory(tools/fuzz-harness)
|
||||
endif()
|
||||
|
||||
option(BUILD_PKI "Build PKI tools" OFF)
|
||||
if(BUILD_PKI AND VANETZA_WITH_PQC)
|
||||
message(FATAL_ERROR
|
||||
"BUILD_PKI cannot be combined with VANETZA_WITH_PQC: the unchanged PKI "
|
||||
"client uses Vanetza's standard Security ASN.1 profile. Build the PKI "
|
||||
"client separately with VANETZA_WITH_PQC=OFF.")
|
||||
endif()
|
||||
if(BUILD_PKI)
|
||||
add_subdirectory(tools/pki)
|
||||
endif()
|
||||
|
||||
# interface library for convenience
|
||||
get_property(_components GLOBAL PROPERTY VANETZA_COMPONENTS)
|
||||
add_library(vanetza INTERFACE)
|
||||
add_library(Vanetza::vanetza ALIAS vanetza)
|
||||
foreach(_component IN LISTS _components)
|
||||
target_link_libraries(vanetza INTERFACE ${_component})
|
||||
endforeach()
|
||||
|
||||
# installation rules
|
||||
include(CMakePackageConfigHelpers)
|
||||
set(CMAKECONFIG_INSTALL_DIR "${CMAKE_INSTALL_LIBDIR}/cmake/Vanetza")
|
||||
set(CMAKECONFIG_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}/cmake-config")
|
||||
write_basic_package_version_file(${CMAKECONFIG_BUILD_DIR}/VanetzaConfigVersion.cmake
|
||||
COMPATIBILITY ExactVersion)
|
||||
configure_package_config_file(cmake/VanetzaConfig.cmake.in
|
||||
${CMAKECONFIG_BUILD_DIR}/VanetzaConfig.cmake
|
||||
INSTALL_DESTINATION ${CMAKECONFIG_INSTALL_DIR}
|
||||
PATH_VARS CMAKE_INSTALL_INCLUDEDIR)
|
||||
|
||||
install(TARGETS vanetza EXPORT ${PROJECT_NAME})
|
||||
|
||||
install(FILES
|
||||
${CMAKECONFIG_BUILD_DIR}/VanetzaConfig.cmake
|
||||
${CMAKECONFIG_BUILD_DIR}/VanetzaConfigVersion.cmake
|
||||
DESTINATION ${CMAKECONFIG_INSTALL_DIR})
|
||||
|
||||
install(DIRECTORY ${PROJECT_SOURCE_DIR}/cmake/
|
||||
DESTINATION ${CMAKECONFIG_INSTALL_DIR}
|
||||
FILES_MATCHING
|
||||
PATTERN "Find*.cmake")
|
||||
|
||||
install(EXPORT ${PROJECT_NAME} NAMESPACE Vanetza:: FILE VanetzaTargets.cmake DESTINATION ${CMAKECONFIG_INSTALL_DIR})
|
||||
|
||||
# install all C++ headers except those found for tests
|
||||
file(GLOB_RECURSE _vanetza_headers
|
||||
RELATIVE ${PROJECT_SOURCE_DIR}
|
||||
CONFIGURE_DEPENDS
|
||||
vanetza/*.hpp)
|
||||
list(FILTER _vanetza_headers EXCLUDE REGEX "/tests/")
|
||||
if(NOT VANETZA_WITH_PQC)
|
||||
list(FILTER _vanetza_headers EXCLUDE REGEX "vanetza/security/pqc/")
|
||||
endif()
|
||||
foreach(_header IN LISTS _vanetza_headers)
|
||||
get_filename_component(_dir ${_header} DIRECTORY)
|
||||
install(FILES ${_header}
|
||||
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/${_dir})
|
||||
endforeach()
|
||||
|
||||
# export build tree (allows import by outside projects)
|
||||
option(VANETZA_EXPORT_PACKAGE "Export Vanetza build directory to CMake package registry" OFF)
|
||||
if(VANETZA_EXPORT_PACKAGE)
|
||||
export(EXPORT ${PROJECT_NAME} NAMESPACE Vanetza:: FILE VanetzaExports.cmake)
|
||||
file(COPY ${CMAKECONFIG_BUILD_DIR}/VanetzaConfigVersion.cmake
|
||||
DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
|
||||
configure_file(cmake/VanetzaExportsConfig.cmake.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/VanetzaConfig.cmake @ONLY)
|
||||
export(PACKAGE ${PROJECT_NAME})
|
||||
endif()
|
||||
|
||||
# mark build directory as cache dir, see https://bford.info/cachedir
|
||||
file(WRITE ${PROJECT_BINARY_DIR}/CACHEDIR.TAG
|
||||
"Signature: 8a477f597d28d172789f06886806bc55")
|
||||
+2983
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
||||
rsource "ports/esp_idf/Kconfig"
|
||||
+853
@@ -0,0 +1,853 @@
|
||||
This file contains copies of the GNU Lesser General Public License as well
|
||||
as the GNU General Public License, both in their third version.
|
||||
|
||||
|
||||
# GNU Lesser General Public License (LGPL) v3
|
||||
|
||||
```
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
```
|
||||
|
||||
|
||||
# GNU General Public License (GPL) v3
|
||||
|
||||
```
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
```
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Vanetza Upstream Provenance
|
||||
|
||||
This directory (`external/vanetza-idf`) contains the Vanetza protocol stack and its ESP-IDF integration for the micrOBU ESP32-C5 ITS-S station.
|
||||
|
||||
## Upstream Software Basis
|
||||
|
||||
- **Project**: Vanetza (ETSI C-ITS protocol stack implementation in C++17)
|
||||
- **Upstream Repository**: `https://github.com/riebl/vanetza.git`
|
||||
- **Pinned Upstream Revision**: `a7cacc1879f3e2124dfb8ef2d83886b1e82d3b36`
|
||||
- **Author / Upstream Maintainer**: Raphael Riebl et al.
|
||||
- **Citekey**: `rieblRieblVanetza2026`
|
||||
- **License**: BSD-3-Clause (retained in `LICENSE.md`)
|
||||
|
||||
## Integrated Components & Third-Party Dependencies
|
||||
|
||||
1. **Boost C++ Libraries (v1.87.0)**:
|
||||
- Located in `ports/esp_idf/third_party/` (`circular_buffer`, `geometry`, `heap`, `iostreams`, `multiprecision`, `rational`).
|
||||
- License: Boost Software License 1.0 (`BSL-1.0`).
|
||||
|
||||
2. **OpenTrafficMap (OTM) Transmitter**:
|
||||
- Upstream: `https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled.git`
|
||||
- Reviewed Revision: `674e34128279235ba34c9f8d778f43cf1d075397`
|
||||
- Core file: `ports/esp_idf/third_party/otm/main/tx_custom.c` (guarded by SHA-256 in `firmware/CMakeLists.txt`).
|
||||
|
||||
3. **ESP-IDF Port (`ports/esp_idf`)**:
|
||||
- Implements hardware-accelerated crypto (mbedTLS ECC/ECDSA/SHA), NVS credential persistence, ESP32-C5 802.11p PHY bindings (`radio_c5.cmake`), and localhost PKI verification tooling (`ports/esp_idf/tools/local_pki.py`, `pki_client.py`).
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# vanetza-idf
|
||||
|
||||
Modular C++17 Vanetza library for ESP-IDF. Choose an access, network/transport,
|
||||
or facilities-codec profile; supply your own platform and radio adapters.
|
||||
The portable core targets the ESP32 family. The integrated C5 radio backend
|
||||
is experimental and requires separate hardware validation.
|
||||
|
||||
**Start with the [ESP-IDF integration guide](docs/idf/README.md).** It covers
|
||||
installation, Kconfig, ownership/event-loop rules, examples, and standalone
|
||||
host/device tests. [Standards traceability](docs/idf/standards.md) links the public
|
||||
interfaces to precise ETSI editions and clauses.
|
||||
The [interface assessment](docs/idf/interface-assessment.md) identifies remaining
|
||||
contract and capability gaps. [Test campaigns](docs/idf/test-campaigns.md) explains
|
||||
how the separately installed ETSI framework and two-C5 radio tests connect.
|
||||
|
||||
**This is an experimental port, not a completed ETSI-conformant stack.**
|
||||
See [implementation and conformance gaps](docs/idf/conformance.md) and
|
||||
[validation results](docs/idf/validation.md). CAM/DENM/VAM codecs and endpoints
|
||||
are distinct from complete Basic Services.
|
||||
|
||||
## Components and configurable boundaries
|
||||
|
||||
The arrangement follows the facilities, networking/transport and access layers
|
||||
of the ETSI ITS station architecture, with management and security as shared
|
||||
cross-layer services. It is an implementation map, inspired by
|
||||
[TS 102 723-11, clauses 4–5](https://www.etsi.org/deliver/etsi_ts/102700_102799/10272311/01.01.01_60/ts_10272311v010101p.pdf)
|
||||
and [EN 303 797, Annex B](https://www.etsi.org/deliver/etsi_en/303700_303799/303797/02.01.01_60/en_303797v020101p.pdf).
|
||||
It does not imply that every service in those figures is implemented.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph station["vanetza-idf"]
|
||||
direction TB
|
||||
|
||||
subgraph entry["Application / test entry points"]
|
||||
direction LR
|
||||
application["Application or upper tester"]
|
||||
f_entry["Facilities PDU entry"]
|
||||
b_entry["BTP-DATA entry"]
|
||||
a_entry["AL_DATA entry"]
|
||||
application --> f_entry
|
||||
application --> b_entry
|
||||
application --> a_entry
|
||||
end
|
||||
|
||||
subgraph dataplane["Protocol data plane"]
|
||||
direction LR
|
||||
|
||||
subgraph facilities["Facilities"]
|
||||
direction TB
|
||||
codecs["CAM R2 / DENM R2 / VAM R2\nASN.1 codecs"]
|
||||
basic_services["CA / DEN / VRU Basic Services\npending"]
|
||||
end
|
||||
|
||||
subgraph nt["Networking & Transport"]
|
||||
direction TB
|
||||
btp["BTP-A / BTP-B"]
|
||||
gn["GeoNetworking\nSHB / GBC"]
|
||||
btp --> gn
|
||||
end
|
||||
|
||||
subgraph access_layer["Access boundary"]
|
||||
direction TB
|
||||
access["AL_DATA\nparameter validation"]
|
||||
end
|
||||
|
||||
codecs --> btp
|
||||
gn --> access
|
||||
end
|
||||
|
||||
subgraph shared["Shared / cross-layer services"]
|
||||
direction LR
|
||||
position["Position + clock"]
|
||||
management["Management\nMIB + configuration"]
|
||||
security["Security entity\nTS 103 097 signing + ticket pool\nverification pending"]
|
||||
saps["SAP bindings\nSN / SF / MN / MF / MI"]
|
||||
end
|
||||
|
||||
f_entry --> codecs
|
||||
b_entry --> btp
|
||||
a_entry --> access
|
||||
|
||||
position -.-> gn
|
||||
management -.-> gn
|
||||
security -.-> gn
|
||||
saps -.-> security
|
||||
saps -.-> gn
|
||||
end
|
||||
|
||||
subgraph integration["Platform / external integration"]
|
||||
direction LR
|
||||
etsi["External ETSI TTCN-3\nframework + SUT adapter"]
|
||||
hil["Optional HIL framing\nbounded VID1 envelope"]
|
||||
adapter["External access adapter\nsoftware lower tester / radio"]
|
||||
radio["ESP32-C5 radio backend\nexperimental; DCC + HW validation pending"]
|
||||
|
||||
hil -.-> etsi
|
||||
adapter -.-> radio
|
||||
end
|
||||
|
||||
etsi -.-> application
|
||||
etsi -.-> adapter
|
||||
access <-->|"owned GNPDU + metadata"| adapter
|
||||
```
|
||||
|
||||
The main transmit path is deliberately kept horizontal: Facilities → BTP →
|
||||
GeoNetworking → Access. Management, position/time and security are shown as
|
||||
shared services because they support protocol processing rather than form another
|
||||
encapsulation stage. Reception travels back up the selected layers. The access
|
||||
profile omits the network and facilities layers; the network profile adds
|
||||
BTP/GeoNetworking; the facilities profile also enables the individually
|
||||
selectable codecs. Complete CA, DEN and VRU Basic Services are still pending.
|
||||
The optional HIL envelope carries adapter payloads; it is not an ETSI protocol.
|
||||
|
||||
## External TTCN-3 and HIL
|
||||
|
||||
Install the TTCN-3 runtime and the [official ETSI ITS framework](https://forge.etsi.org/rep/ITS/TS.ITS)
|
||||
separately. The library does not install or bundle them. The test application
|
||||
connects that framework's suite-specific SUT adapter to these points:
|
||||
|
||||
| Point | API/boundary | Intended use |
|
||||
|---|---|---|
|
||||
| Upper tester | Named NF-SAP binding, `Stack::request`, position/time/security injection | Stimulate implemented behavior and collect actual results; full service operations remain pending |
|
||||
| Software lower tester | `Access::request` and `Stack::indicate` | Inject/observe GN traffic while the protocol implementation runs on the ESP32 |
|
||||
| Physical lower tester | Independent ITS-G5 capture/injection radio | Validate transmitted frames, access behavior and RF properties |
|
||||
|
||||
Enable `CONFIG_VANETZA_IDF_HIL` for optional bounded framing. USB/UART/BLE/IP
|
||||
transports and suite-specific command decoding remain in the test application
|
||||
and host SUT adapter. Preserve the ETSI codec payload and map it to the actual
|
||||
service under test; never synthesize a successful acknowledgement.
|
||||
|
||||
The [hook-up guide](docs/idf/README.md#connect-a-separately-installed-etsi-ttcn-3-framework)
|
||||
explains payload boundaries, metadata, real-time versus injected-time tests,
|
||||
PICS/PIXIT, Release 2 ATS adaptation, and evidence retention. A mirrored SUT
|
||||
packet is software evidence; radio conformance requires independent observation.
|
||||
|
||||
## Foundation and acknowledgements
|
||||
|
||||
vanetza-idf is an independent ESP-IDF adaptation built on
|
||||
[Vanetza](https://github.com/riebl/vanetza). It is not maintained by, affiliated
|
||||
with, or endorsed by the Vanetza team. Upstream authorship and license notices
|
||||
remain with the original source files. See the
|
||||
[upstream documentation](https://www.vanetza.org/) for the original library.
|
||||
|
||||
Thank you to [OpenTrafficMap](https://codeberg.org/opentrafficmap) for the
|
||||
foundational work that established how to transmit ITS-G5 using the ESP32-C5
|
||||
radio, shared in their
|
||||
[receiver firmware with TX enabled](https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled).
|
||||
The C5 backend builds on that work. Its private SDK dependencies and validation
|
||||
status are documented separately from the portable protocol core.
|
||||
|
||||
Vanetza is licensed under LGPLv3; see [LICENSE.md](LICENSE.md).
|
||||
Third-party components retain their respective notices and licensing terms.
|
||||
@@ -0,0 +1,47 @@
|
||||
These ASN.1 files are taken from their respective standardisation documents and are **not** licensed the same way as Vanetza itself.
|
||||
Please refer to the referenced standardisation documents for details about the applicable Intellectual Property Rights (IPR).
|
||||
To the best of my knowledge, ETSI does not object to bundling ASN.1 files of their published documents along with software.
|
||||
Some editorial changes may have been applied, e.g. removal of trailing whitespace etc.
|
||||
Code generated from the files located here can be found in the *vanetza/asn1/its* directory.
|
||||
|
||||
# Standardisation documents
|
||||
|
||||
- *EN302637-2v141-CAM.asn* from ETSI EN 302 637-2 v1.4.1 (2019-04)
|
||||
- *EN302637-3v131-DENM.asn* from ETSI EN 302 637-3 v1.3.1 (2019-04)
|
||||
- *TS102894-2v131-CDD.asn* from ETSI TS 102 894-2 v1.3.1 (2018-08)
|
||||
- *TS102941v131-\*.asn* from ETSI TS 102 941 v1.3.1 (2019-02)
|
||||
- *TS103097v131.asn* from ETSI TS 103 097 v1.3.1 (2017-10)
|
||||
- *TS103097v211-\*.asn* from ETSI TS 103 097 v2.1.1 (2021-10)
|
||||
- *TR103562v211.asn* from ETSI TR 103 562 v2.1.1 (2019-12)
|
||||
|
||||
- *IEEE1609dot2.asn* and *IEEE1609dot2BaseTypes.asn* from IEEE 1609.2 as printed in ETSI TS 103 097 v1.3.1
|
||||
|
||||
# ETSI WebSVN
|
||||
|
||||
You may find these ITS ASN.1 modules also in ETSI's public SVN repository: http://oldforge.etsi.org/websvn/listing.php?repname=ITS.ITS_ASN1
|
||||
While above source is still online (in March 2020), ETSI seems to have moved to https://forge.etsi.org/rep/ITS/ITS_ASN1 by now.
|
||||
This newer source also contains a license file explicitly. A copy is included below for reference.
|
||||
|
||||
> Copyright 2019 ETSI
|
||||
>
|
||||
> Redistribution and use in source and binary forms, with or without
|
||||
> modification, are permitted provided that the following conditions are met:
|
||||
> 1. Redistributions of source code must retain the above copyright notice,
|
||||
> this list of conditions and the following disclaimer.
|
||||
> 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
> this list of conditions and the following disclaimer in the documentation
|
||||
> and/or other materials provided with the distribution.
|
||||
> 3. Neither the name of the copyright holder nor the names of its contributors
|
||||
> may be used to endorse or promote products derived from this software without
|
||||
> specific prior written permission.
|
||||
>
|
||||
> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
> ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
> IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
> INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
> BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
> DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
> LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
||||
> OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
> OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,132 @@
|
||||
CAM-PDU-Descriptions {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) en (302637) cam (2) version (2)
|
||||
}
|
||||
|
||||
DEFINITIONS AUTOMATIC TAGS ::=
|
||||
|
||||
BEGIN
|
||||
|
||||
IMPORTS
|
||||
ItsPduHeader, CauseCode, ReferencePosition, AccelerationControl, Curvature, CurvatureCalculationMode, Heading, LanePosition, EmergencyPriority, EmbarkationStatus, Speed, DriveDirection, LongitudinalAcceleration, LateralAcceleration, VerticalAcceleration, StationType, ExteriorLights, DangerousGoodsBasic, SpecialTransportType, LightBarSirenInUse, VehicleRole, VehicleLength, VehicleWidth, PathHistory, RoadworksSubCauseCode, ClosedLanes, TrafficRule, SpeedLimit, SteeringWheelAngle, PerformanceClass, YawRate, ProtectedCommunicationZone, PtActivation, Latitude, Longitude, ProtectedCommunicationZonesRSU, CenDsrcTollingZone FROM ITS-Container {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts (102894) cdd (2) version (2)
|
||||
};
|
||||
|
||||
|
||||
-- The root data frame for cooperative awareness messages
|
||||
|
||||
CAM ::= SEQUENCE {
|
||||
header ItsPduHeader,
|
||||
cam CoopAwareness
|
||||
}
|
||||
|
||||
CoopAwareness ::= SEQUENCE {
|
||||
generationDeltaTime GenerationDeltaTime,
|
||||
camParameters CamParameters
|
||||
}
|
||||
|
||||
CamParameters ::= SEQUENCE {
|
||||
basicContainer BasicContainer,
|
||||
highFrequencyContainer HighFrequencyContainer,
|
||||
lowFrequencyContainer LowFrequencyContainer OPTIONAL,
|
||||
specialVehicleContainer SpecialVehicleContainer OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
HighFrequencyContainer ::= CHOICE {
|
||||
basicVehicleContainerHighFrequency BasicVehicleContainerHighFrequency,
|
||||
rsuContainerHighFrequency RSUContainerHighFrequency,
|
||||
...
|
||||
}
|
||||
|
||||
LowFrequencyContainer ::= CHOICE {
|
||||
basicVehicleContainerLowFrequency BasicVehicleContainerLowFrequency,
|
||||
...
|
||||
}
|
||||
|
||||
SpecialVehicleContainer ::= CHOICE {
|
||||
publicTransportContainer PublicTransportContainer,
|
||||
specialTransportContainer SpecialTransportContainer,
|
||||
dangerousGoodsContainer DangerousGoodsContainer,
|
||||
roadWorksContainerBasic RoadWorksContainerBasic,
|
||||
rescueContainer RescueContainer,
|
||||
emergencyContainer EmergencyContainer,
|
||||
safetyCarContainer SafetyCarContainer,
|
||||
...
|
||||
}
|
||||
|
||||
BasicContainer ::= SEQUENCE {
|
||||
stationType StationType,
|
||||
referencePosition ReferencePosition,
|
||||
...
|
||||
}
|
||||
|
||||
BasicVehicleContainerHighFrequency ::= SEQUENCE {
|
||||
heading Heading,
|
||||
speed Speed,
|
||||
driveDirection DriveDirection,
|
||||
vehicleLength VehicleLength,
|
||||
vehicleWidth VehicleWidth,
|
||||
longitudinalAcceleration LongitudinalAcceleration,
|
||||
curvature Curvature,
|
||||
curvatureCalculationMode CurvatureCalculationMode,
|
||||
yawRate YawRate,
|
||||
accelerationControl AccelerationControl OPTIONAL,
|
||||
lanePosition LanePosition OPTIONAL,
|
||||
steeringWheelAngle SteeringWheelAngle OPTIONAL,
|
||||
lateralAcceleration LateralAcceleration OPTIONAL,
|
||||
verticalAcceleration VerticalAcceleration OPTIONAL,
|
||||
performanceClass PerformanceClass OPTIONAL,
|
||||
cenDsrcTollingZone CenDsrcTollingZone OPTIONAL
|
||||
}
|
||||
|
||||
BasicVehicleContainerLowFrequency ::= SEQUENCE {
|
||||
vehicleRole VehicleRole,
|
||||
exteriorLights ExteriorLights,
|
||||
pathHistory PathHistory
|
||||
}
|
||||
|
||||
PublicTransportContainer ::= SEQUENCE {
|
||||
embarkationStatus EmbarkationStatus,
|
||||
ptActivation PtActivation OPTIONAL
|
||||
}
|
||||
|
||||
SpecialTransportContainer ::= SEQUENCE {
|
||||
specialTransportType SpecialTransportType,
|
||||
lightBarSirenInUse LightBarSirenInUse
|
||||
}
|
||||
|
||||
DangerousGoodsContainer ::= SEQUENCE {
|
||||
dangerousGoodsBasic DangerousGoodsBasic
|
||||
}
|
||||
|
||||
RoadWorksContainerBasic ::= SEQUENCE {
|
||||
roadworksSubCauseCode RoadworksSubCauseCode OPTIONAL,
|
||||
lightBarSirenInUse LightBarSirenInUse,
|
||||
closedLanes ClosedLanes OPTIONAL
|
||||
}
|
||||
|
||||
RescueContainer ::= SEQUENCE {
|
||||
lightBarSirenInUse LightBarSirenInUse
|
||||
}
|
||||
|
||||
EmergencyContainer ::= SEQUENCE {
|
||||
lightBarSirenInUse LightBarSirenInUse,
|
||||
incidentIndication CauseCode OPTIONAL,
|
||||
emergencyPriority EmergencyPriority OPTIONAL
|
||||
}
|
||||
|
||||
SafetyCarContainer ::= SEQUENCE {
|
||||
lightBarSirenInUse LightBarSirenInUse,
|
||||
incidentIndication CauseCode OPTIONAL,
|
||||
trafficRule TrafficRule OPTIONAL,
|
||||
speedLimit SpeedLimit OPTIONAL
|
||||
}
|
||||
|
||||
RSUContainerHighFrequency ::= SEQUENCE {
|
||||
protectedCommunicationZonesRSU ProtectedCommunicationZonesRSU OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
GenerationDeltaTime ::= INTEGER { oneMilliSec(1) } (0..65535)
|
||||
|
||||
END
|
||||
@@ -0,0 +1,109 @@
|
||||
DENM-PDU-Descriptions {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) en (302637) denm (1) version (2)
|
||||
}
|
||||
|
||||
DEFINITIONS AUTOMATIC TAGS ::=
|
||||
|
||||
BEGIN
|
||||
|
||||
IMPORTS
|
||||
ItsPduHeader, CauseCode, Speed, InformationQuality, ReferencePosition, ClosedLanes, DangerousGoodsExtended, Heading, LanePosition, LightBarSirenInUse, RoadType, HeightLonCarr, PosLonCarr, PosCentMass, PositioningSolutionType, RequestResponseIndication, StationType, SpeedLimit, StationarySince, TimestampIts, WheelBaseVehicle, TurningRadius, PosFrontAx, PositionOfOccupants, Temperature, VehicleMass, VehicleIdentification, EnergyStorageType, ActionID, ItineraryPath, NumberOfOccupants, PositionOfPillars, RelevanceTrafficDirection, RestrictedTypes, Traces, TransmissionInterval, ValidityDuration, RelevanceDistance, EventHistory, TrafficRule, DeltaReferencePosition FROM ITS-Container {
|
||||
itu-t (0) identified-organization (4) etsi (0) itsDomain (5) wg1 (1) ts (102894) cdd (2) version (2)
|
||||
};
|
||||
|
||||
|
||||
DENM ::= SEQUENCE {
|
||||
header ItsPduHeader,
|
||||
denm DecentralizedEnvironmentalNotificationMessage
|
||||
}
|
||||
|
||||
DecentralizedEnvironmentalNotificationMessage ::= SEQUENCE {
|
||||
management ManagementContainer,
|
||||
situation SituationContainer OPTIONAL,
|
||||
location LocationContainer OPTIONAL,
|
||||
alacarte AlacarteContainer OPTIONAL
|
||||
}
|
||||
|
||||
ManagementContainer ::= SEQUENCE {
|
||||
actionID ActionID,
|
||||
detectionTime TimestampIts,
|
||||
referenceTime TimestampIts,
|
||||
termination Termination OPTIONAL,
|
||||
eventPosition ReferencePosition,
|
||||
relevanceDistance RelevanceDistance OPTIONAL,
|
||||
relevanceTrafficDirection RelevanceTrafficDirection OPTIONAL,
|
||||
validityDuration ValidityDuration DEFAULT defaultValidity,
|
||||
transmissionInterval TransmissionInterval OPTIONAL,
|
||||
stationType StationType,
|
||||
...
|
||||
}
|
||||
|
||||
SituationContainer ::= SEQUENCE {
|
||||
informationQuality InformationQuality,
|
||||
eventType CauseCode,
|
||||
linkedCause CauseCode OPTIONAL,
|
||||
eventHistory EventHistory OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
LocationContainer ::= SEQUENCE {
|
||||
eventSpeed Speed OPTIONAL,
|
||||
eventPositionHeading Heading OPTIONAL,
|
||||
traces Traces,
|
||||
roadType RoadType OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
ImpactReductionContainer ::= SEQUENCE {
|
||||
heightLonCarrLeft HeightLonCarr,
|
||||
heightLonCarrRight HeightLonCarr,
|
||||
posLonCarrLeft PosLonCarr,
|
||||
posLonCarrRight PosLonCarr,
|
||||
positionOfPillars PositionOfPillars,
|
||||
posCentMass PosCentMass,
|
||||
wheelBaseVehicle WheelBaseVehicle,
|
||||
turningRadius TurningRadius,
|
||||
posFrontAx PosFrontAx,
|
||||
positionOfOccupants PositionOfOccupants,
|
||||
vehicleMass VehicleMass,
|
||||
requestResponseIndication RequestResponseIndication
|
||||
}
|
||||
|
||||
RoadWorksContainerExtended ::= SEQUENCE {
|
||||
lightBarSirenInUse LightBarSirenInUse OPTIONAL,
|
||||
closedLanes ClosedLanes OPTIONAL,
|
||||
restriction RestrictedTypes OPTIONAL,
|
||||
speedLimit SpeedLimit OPTIONAL,
|
||||
incidentIndication CauseCode OPTIONAL,
|
||||
recommendedPath ItineraryPath OPTIONAL,
|
||||
startingPointSpeedLimit DeltaReferencePosition OPTIONAL,
|
||||
trafficFlowRule TrafficRule OPTIONAL,
|
||||
referenceDenms ReferenceDenms OPTIONAL
|
||||
}
|
||||
|
||||
StationaryVehicleContainer ::= SEQUENCE {
|
||||
stationarySince StationarySince OPTIONAL,
|
||||
stationaryCause CauseCode OPTIONAL,
|
||||
carryingDangerousGoods DangerousGoodsExtended OPTIONAL,
|
||||
numberOfOccupants NumberOfOccupants OPTIONAL,
|
||||
vehicleIdentification VehicleIdentification OPTIONAL,
|
||||
energyStorageType EnergyStorageType OPTIONAL
|
||||
}
|
||||
|
||||
AlacarteContainer ::= SEQUENCE {
|
||||
lanePosition LanePosition OPTIONAL,
|
||||
impactReduction ImpactReductionContainer OPTIONAL,
|
||||
externalTemperature Temperature OPTIONAL,
|
||||
roadWorks RoadWorksContainerExtended OPTIONAL,
|
||||
positioningSolution PositioningSolutionType OPTIONAL,
|
||||
stationaryVehicle StationaryVehicleContainer OPTIONAL,
|
||||
...
|
||||
}
|
||||
|
||||
defaultValidity INTEGER ::= 600
|
||||
|
||||
Termination ::= ENUMERATED {isCancellation(0), isNegation (1)}
|
||||
|
||||
ReferenceDenms ::= SEQUENCE (SIZE(1..8, ...)) OF ActionID
|
||||
|
||||
END
|
||||
@@ -0,0 +1,306 @@
|
||||
IEEE1609dot2 {
|
||||
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base (1) schema (1) major-version-2(2)
|
||||
}
|
||||
|
||||
--******************************************************************************
|
||||
--
|
||||
-- IEEE P1609.2 Data Types
|
||||
--
|
||||
--******************************************************************************
|
||||
|
||||
DEFINITIONS AUTOMATIC TAGS ::= BEGIN
|
||||
|
||||
EXPORTS ALL;
|
||||
|
||||
IMPORTS
|
||||
CrlSeries,
|
||||
EccP256CurvePoint,
|
||||
EciesP256EncryptedKey,
|
||||
EncryptionKey,
|
||||
GeographicRegion,
|
||||
GroupLinkageValue,
|
||||
HashAlgorithm,
|
||||
HashedId3,
|
||||
HashedId8,
|
||||
Hostname,
|
||||
IValue,
|
||||
LinkageValue,
|
||||
Opaque,
|
||||
Psid,
|
||||
PsidSsp,
|
||||
PsidSspRange,
|
||||
PublicEncryptionKey,
|
||||
PublicVerificationKey,
|
||||
SequenceOfHashedId3,
|
||||
SequenceOfPsidSsp,
|
||||
SequenceOfPsidSspRange,
|
||||
ServiceSpecificPermissions,
|
||||
Signature,
|
||||
SubjectAssurance,
|
||||
SymmetricEncryptionKey,
|
||||
ThreeDLocation,
|
||||
Time64,
|
||||
Uint3,
|
||||
Uint8,
|
||||
Uint16,
|
||||
Uint32,
|
||||
ValidityPeriod
|
||||
FROM IEEE1609dot2BaseTypes {
|
||||
iso(1) identified-organization(3) ieee(111) standards-association-numbered-series-standards(2) wave-stds(1609) dot2(2) base(1) base-types(2) major-version-2 (2)
|
||||
};
|
||||
|
||||
--
|
||||
--*********************************************************************
|
||||
--
|
||||
-- Structures for describing secured data
|
||||
--
|
||||
--*********************************************************************
|
||||
|
||||
-- Necessary to get certain tools to generate sample PDUs
|
||||
-- TestIeee1609Dot2Data ::= Ieee1609Dot2Data
|
||||
-- TestCertificate ::= Certificate
|
||||
|
||||
-- this structure belongs later in the file but putting it here avoids
|
||||
-- compiler errors with certain tools
|
||||
SignedDataPayload ::= SEQUENCE {
|
||||
data Ieee1609Dot2Data OPTIONAL,
|
||||
extDataHash HashedData OPTIONAL,
|
||||
...
|
||||
}
|
||||
(WITH COMPONENTS {..., data PRESENT} |
|
||||
WITH COMPONENTS {..., extDataHash PRESENT})
|
||||
|
||||
Ieee1609Dot2Data ::= SEQUENCE {
|
||||
protocolVersion Uint8(3),
|
||||
content Ieee1609Dot2Content
|
||||
}
|
||||
|
||||
Ieee1609Dot2Content ::= CHOICE {
|
||||
unsecuredData Opaque,
|
||||
signedData SignedData,
|
||||
encryptedData EncryptedData,
|
||||
signedCertificateRequest Opaque,
|
||||
...
|
||||
}
|
||||
|
||||
SignedData ::= SEQUENCE {
|
||||
hashId HashAlgorithm,
|
||||
tbsData ToBeSignedData,
|
||||
signer SignerIdentifier,
|
||||
signature Signature
|
||||
}
|
||||
|
||||
SignerIdentifier ::= CHOICE {
|
||||
digest HashedId8,
|
||||
certificate SequenceOfCertificate,
|
||||
self NULL,
|
||||
...
|
||||
}
|
||||
|
||||
ToBeSignedData ::= SEQUENCE {
|
||||
payload SignedDataPayload,
|
||||
headerInfo HeaderInfo
|
||||
}
|
||||
|
||||
HashedData::= CHOICE {
|
||||
sha256HashedData OCTET STRING (SIZE(32)),
|
||||
...
|
||||
}
|
||||
|
||||
HeaderInfo ::= SEQUENCE {
|
||||
psid Psid,
|
||||
generationTime Time64 OPTIONAL,
|
||||
expiryTime Time64 OPTIONAL,
|
||||
generationLocation ThreeDLocation OPTIONAL,
|
||||
p2pcdLearningRequest HashedId3 OPTIONAL,
|
||||
missingCrlIdentifier MissingCrlIdentifier OPTIONAL,
|
||||
encryptionKey EncryptionKey OPTIONAL,
|
||||
...,
|
||||
inlineP2pcdRequest SequenceOfHashedId3 OPTIONAL,
|
||||
requestedCertificate Certificate OPTIONAL
|
||||
}
|
||||
|
||||
MissingCrlIdentifier ::= SEQUENCE {
|
||||
cracaId HashedId3,
|
||||
crlSeries CrlSeries,
|
||||
...
|
||||
}
|
||||
|
||||
Countersignature ::= Ieee1609Dot2Data (WITH COMPONENTS {...,
|
||||
content (WITH COMPONENTS {...,
|
||||
signedData (WITH COMPONENTS {...,
|
||||
tbsData (WITH COMPONENTS {...,
|
||||
payload (WITH COMPONENTS {...,
|
||||
data ABSENT,
|
||||
extDataHash PRESENT
|
||||
}),
|
||||
headerInfo(WITH COMPONENTS {...,
|
||||
generationTime PRESENT,
|
||||
expiryTime ABSENT,
|
||||
generationLocation ABSENT,
|
||||
p2pcdLearningRequest ABSENT,
|
||||
missingCrlIdentifier ABSENT,
|
||||
encryptionKey ABSENT
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
--**********************************************************************
|
||||
--
|
||||
-- Structures for describing encrypted data
|
||||
--
|
||||
--**********************************************************************
|
||||
|
||||
EncryptedData ::= SEQUENCE {
|
||||
recipients SequenceOfRecipientInfo,
|
||||
ciphertext SymmetricCiphertext
|
||||
}
|
||||
RecipientInfo ::= CHOICE {
|
||||
pskRecipInfo PreSharedKeyRecipientInfo,
|
||||
symmRecipInfo SymmRecipientInfo,
|
||||
certRecipInfo PKRecipientInfo,
|
||||
signedDataRecipInfo PKRecipientInfo,
|
||||
rekRecipInfo PKRecipientInfo
|
||||
}
|
||||
|
||||
SequenceOfRecipientInfo ::= SEQUENCE OF RecipientInfo
|
||||
|
||||
PreSharedKeyRecipientInfo ::= HashedId8
|
||||
SymmRecipientInfo ::= SEQUENCE {
|
||||
recipientId HashedId8,
|
||||
encKey SymmetricCiphertext
|
||||
}
|
||||
|
||||
PKRecipientInfo ::= SEQUENCE {
|
||||
recipientId HashedId8,
|
||||
encKey EncryptedDataEncryptionKey
|
||||
}
|
||||
|
||||
EncryptedDataEncryptionKey ::= CHOICE {
|
||||
eciesNistP256 EciesP256EncryptedKey,
|
||||
eciesBrainpoolP256r1 EciesP256EncryptedKey,
|
||||
...
|
||||
}
|
||||
|
||||
SymmetricCiphertext ::= CHOICE {
|
||||
aes128ccm AesCcmCiphertext,
|
||||
...
|
||||
}
|
||||
|
||||
AesCcmCiphertext ::= SEQUENCE {
|
||||
nonce OCTET STRING (SIZE (12)),
|
||||
ccmCiphertext Opaque -- 16 bytes longer than plaintext
|
||||
}
|
||||
|
||||
|
||||
--**********************************************************************
|
||||
--
|
||||
-- Certificates and other security management data structures
|
||||
--
|
||||
--**********************************************************************
|
||||
|
||||
-- Certificates are implicit (type = implicit, toBeSigned includes
|
||||
-- reconstruction value, signature absent) or explicit (type = explicit,
|
||||
-- toBeSigned includes verification key, signature present).
|
||||
|
||||
Certificate ::= CertificateBase (ImplicitCertificate | ExplicitCertificate)
|
||||
|
||||
SequenceOfCertificate ::= SEQUENCE OF Certificate
|
||||
|
||||
CertificateBase ::= SEQUENCE {
|
||||
version Uint8(3),
|
||||
type CertificateType,
|
||||
issuer IssuerIdentifier,
|
||||
toBeSigned ToBeSignedCertificate,
|
||||
signature Signature OPTIONAL
|
||||
}
|
||||
|
||||
CertificateType ::= ENUMERATED {
|
||||
explicit,
|
||||
implicit,
|
||||
...
|
||||
}
|
||||
|
||||
ImplicitCertificate ::= CertificateBase (WITH COMPONENTS {...,
|
||||
type(implicit),
|
||||
toBeSigned (WITH COMPONENTS {...,
|
||||
verifyKeyIndicator(WITH COMPONENTS {reconstructionValue})
|
||||
}),
|
||||
signature ABSENT
|
||||
})
|
||||
|
||||
ExplicitCertificate ::= CertificateBase (WITH COMPONENTS {...,
|
||||
type(explicit),
|
||||
toBeSigned(WITH COMPONENTS {...,
|
||||
verifyKeyIndicator(WITH COMPONENTS {verificationKey})
|
||||
}),
|
||||
signature PRESENT
|
||||
})
|
||||
|
||||
IssuerIdentifier ::= CHOICE {
|
||||
sha256AndDigest HashedId8,
|
||||
self HashAlgorithm,
|
||||
...,
|
||||
sha384AndDigest HashedId8
|
||||
}
|
||||
|
||||
ToBeSignedCertificate ::= SEQUENCE {
|
||||
id CertificateId,
|
||||
cracaId HashedId3,
|
||||
crlSeries CrlSeries,
|
||||
validityPeriod ValidityPeriod,
|
||||
region GeographicRegion OPTIONAL,
|
||||
assuranceLevel SubjectAssurance OPTIONAL,
|
||||
appPermissions SequenceOfPsidSsp OPTIONAL,
|
||||
certIssuePermissions SequenceOfPsidGroupPermissions OPTIONAL,
|
||||
certRequestPermissions SequenceOfPsidGroupPermissions OPTIONAL,
|
||||
canRequestRollover NULL OPTIONAL,
|
||||
encryptionKey PublicEncryptionKey OPTIONAL,
|
||||
verifyKeyIndicator VerificationKeyIndicator,
|
||||
...
|
||||
}
|
||||
(WITH COMPONENTS { ..., appPermissions PRESENT} |
|
||||
WITH COMPONENTS { ..., certIssuePermissions PRESENT} |
|
||||
WITH COMPONENTS { ..., certRequestPermissions PRESENT})
|
||||
|
||||
CertificateId ::= CHOICE {
|
||||
linkageData LinkageData,
|
||||
name Hostname,
|
||||
binaryId OCTET STRING(SIZE(1..64)),
|
||||
none NULL,
|
||||
...
|
||||
}
|
||||
|
||||
LinkageData ::= SEQUENCE {
|
||||
iCert IValue,
|
||||
linkage-value LinkageValue,
|
||||
group-linkage-value GroupLinkageValue OPTIONAL
|
||||
}
|
||||
|
||||
EndEntityType ::= BIT STRING { app (0), enrol (1) } (SIZE (8)) (ALL EXCEPT {})
|
||||
|
||||
PsidGroupPermissions ::= SEQUENCE {
|
||||
subjectPermissions SubjectPermissions,
|
||||
minChainLength INTEGER DEFAULT 1,
|
||||
chainLengthRange INTEGER DEFAULT 0,
|
||||
eeType EndEntityType DEFAULT {app} -- vanetza-idf: IEEE Std 1609.2-2022 6.4.28 (was '00'H)
|
||||
}
|
||||
|
||||
SequenceOfPsidGroupPermissions ::= SEQUENCE OF PsidGroupPermissions
|
||||
|
||||
SubjectPermissions ::= CHOICE {
|
||||
explicit SequenceOfPsidSspRange,
|
||||
all NULL,
|
||||
...
|
||||
}
|
||||
|
||||
VerificationKeyIndicator ::= CHOICE {
|
||||
verificationKey PublicVerificationKey,
|
||||
reconstructionValue EccP256CurvePoint,
|
||||
...
|
||||
}
|
||||
|
||||
END
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user