Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d4477f1e | ||
|
|
277c6b20f4 |
+2
-12
@@ -47,15 +47,5 @@ sdkconfig.old
|
|||||||
# and remove them on close, so they are transient and machine-local.
|
# and remove them on close, so they are transient and machine-local.
|
||||||
~$*
|
~$*
|
||||||
|
|
||||||
# Colleague's standalone ESP32-C5 VRU station (signed ITS via Vanetza). It is its
|
# Captures are large data files, not source (see capture/README.md).
|
||||||
# own git repository with its own history; kept beside the project as a reference
|
/capture/recordings/
|
||||||
# for porting the signed-TX path into obu-firmware, not tracked here.
|
|
||||||
/microbu-esp32c5/
|
|
||||||
|
|
||||||
# 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/
|
|
||||||
|
|||||||
@@ -5,249 +5,44 @@ Engineering to-do list. The reviewer-facing open items live in
|
|||||||
|
|
||||||
## Waiting on hardware
|
## 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 `microbu-esp32c5/tools/wireshark/psid-vru.lua`
|
|
||||||
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)
|
### Over-the-air check of the GN lifetime fix (added 2026-09-11)
|
||||||
|
|
||||||
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
|
`geonet.c` now writes GN lifetime `0x05` (1 s) instead of `0x83`, which decoded to 3200 s. Changed
|
||||||
in both `obu-firmware` and `obu-cam-transmistter`. Both still build (IDF 6.1 / 5.5.4), and the
|
in both `obu-firmware` and `obu-cam-transmistter`. Both still build (IDF 6.1 / 5.5.4), and the
|
||||||
compiled `geonet_wrap_shb` stores the new byte, but it has not been seen on air yet. Nothing else
|
compiled `geonet_wrap_shb` stores the new byte. Confirmed on air 2026-09-14. Nothing else
|
||||||
reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not
|
reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not
|
||||||
need updating alongside the firmware.
|
need updating alongside the firmware.
|
||||||
|
|
||||||
Needs: the phone with the app, the OBU ESP32-C5, and a **second** ESP32-C5 running
|
Needs: the phone with the app, the OBU ESP32-C5, and a **second** ESP32-C5 running
|
||||||
`its-g5-receiver-firmware` to capture with.
|
`its-g5-receiver-firmware` to capture with.
|
||||||
|
|
||||||
- [ ] Flash `obu-firmware` (see `obu-firmware/FLASHING.md`).
|
- [x] Flash `obu-firmware` (done 2026-09-14 on COM3; flash backed up first to
|
||||||
- [ ] Connect the phone, let it send CAMs, and confirm the CAM Pinger's `tx fail` counter stays 0.
|
`Documents/micrOBU_workspace/firmware-backups/COM3-2026-09-14-before-secured-rx.bin`).
|
||||||
- [ ] Capture with the receiver into `its-g5-receiver-firmware/recordings/`.
|
- [x] Capture with the receiver (COM8) into `its-g5-receiver-firmware/recordings/`.
|
||||||
- [ ] Run `python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/<capture>.pcap`.
|
- [x] `pcap_gn_tally.py` on capture_20260914_132126.pcap: our station sends SHB, port 2001,
|
||||||
The rows for the phone's pseudonym MACs must show SHB, port 2001, lifetime `0x05`, exactly
|
lifetime `0x05`, same as both bench stations. It was `0x83` in the August captures.
|
||||||
like every other station's CAMs.
|
- [x] Real-station CAMs/DENMs/SPATEM still reach the app (logcat: `handleCamUper`,
|
||||||
- [ ] While the phone is connected: real-station CAMs/DENMs still reach the app (RX path unchanged).
|
`handleDenmUper`, `handleSpatUper` all decoding, 2026-09-14).
|
||||||
|
- [x] Our own CAMs decode on air: 397 frames from station 999999 decode with asn1tools and
|
||||||
|
re-encode byte-identically.
|
||||||
|
- [ ] Confirm the CAM Pinger card's `tx fail` / oversize / CRC counters are 0 (needs a look at the
|
||||||
|
phone; not readable from the PC).
|
||||||
|
|
||||||
Partial check possible with one board and no phone: flash it, `idf.py -p COMx monitor`, and look
|
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
|
for `OCB @ 5900 MHz - TX/RX armed`. That proves the new build boots and brings the radio up, not
|
||||||
that it transmits correctly.
|
that it transmits correctly.
|
||||||
|
|
||||||
### Measure the OBU's actual transmit power (added 2026-09-14)
|
|
||||||
|
|
||||||
Nothing in this project has ever measured it. `main.c` asks for 20 dBm
|
|
||||||
(`esp_wifi_set_max_tx_power(80)`, 0.25 dBm units) and the build's ceiling is the same
|
|
||||||
(`CONFIG_ESP_PHY_MAX_TX_POWER=20`), but a request is a ceiling, not a guarantee: the driver clamps
|
|
||||||
it to its own calibrated table, and 5900 MHz is above the range this chip is rated for, so the
|
|
||||||
table actually in use is channel 177's. The firmware now reads the value back and logs it at boot,
|
|
||||||
which records what the driver admits to, not what leaves the antenna.
|
|
||||||
|
|
||||||
- [ ] Flash and `idf.py -p COM3 monitor`, then note the `tx power:` line. A value below 80 means
|
|
||||||
the driver clamped the request, which the code alone cannot tell you.
|
|
||||||
- [ ] Relative check with the second ESP32-C5 on `its-g5-receiver-firmware`: capture at a measured
|
|
||||||
distance in a straight line, read the RSSI the receive path already reports, and record
|
|
||||||
distance and RSSI together. This gives a comparable number between builds and antennas,
|
|
||||||
which is what matters for range work, without any lab equipment.
|
|
||||||
- [ ] Only a spectrum analyser or a calibrated reference receiver gives real radiated power. Worth
|
|
||||||
it only if the range result looks wrong, or if the thesis needs an absolute figure.
|
|
||||||
|
|
||||||
For context: ETSI allows up to 33 dBm EIRP on the ITS band, and production OBUs sit around
|
|
||||||
20 to 23 dBm, so the requested figure is in the right region if the PA really keys it there.
|
|
||||||
|
|
||||||
### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11)
|
### obu-cam-transmistter yawRateConfidence fix (added 2026-09-11)
|
||||||
|
|
||||||
Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of
|
Its `cam.c` (compiled into that firmware) wrote `yawRateConfidence` as 3 bits / 7 instead of
|
||||||
4 bits / unavailable(8), the bug the app fixed on 2026-08-20. Fixed in it and in obu-firmware's
|
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
|
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
|
IDF 5.5.4. Since 2026-09-14 the spare board on COM10 runs it as a bench beacon:
|
||||||
only matters if it is flashed again:
|
|
||||||
|
|
||||||
- [ ] After flashing it: capture, run `pcap_gn_tally.py`, and decode the CAM payload with
|
- [x] Done 2026-09-14: flashed on COM10 and captured on COM8. All 72 CAMs from station
|
||||||
asn1tools (`py -3.11`, modules in `asn1/`).
|
195936478 (0x0BADC0DE) decode with asn1tools and re-encode byte-identically, so the
|
||||||
|
4-bit yawRateConfidence is right on air. COM10 now runs this beacon rather than
|
||||||
|
obu-firmware - reflash it if the spare is needed as an OBU again.
|
||||||
|
|
||||||
### Signed-message reception and exact payloads (added 2026-09-11)
|
### Signed-message reception and exact payloads (added 2026-09-11)
|
||||||
|
|
||||||
@@ -255,69 +50,21 @@ obu-firmware's `gn_unwrap.c` now unwraps TS 103 097 signed packets (signature no
|
|||||||
reported as V2X_RX flags bit1) and cuts every message to the length its header declares, dropping
|
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.
|
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,
|
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
|
50M-iteration fuzz) and flashed to the production OBU on 2026-09-14. The remaining gap is signed
|
||||||
2026-09-10 build. Needs the OBU with this build, the phone, and signed traffic - real vehicles or
|
traffic to receive: real vehicles or
|
||||||
RSUs, since the bench CiT One sends unsigned. A second ESP32 running the receiver firmware is
|
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.
|
optional, but shows what was on air at the time.
|
||||||
|
|
||||||
- [ ] Flash obu-firmware (this also carries the GN lifetime fix above).
|
- [x] Flash obu-firmware (done 2026-09-14, COM3).
|
||||||
|
- [x] Unsigned bench traffic still decodes in the app, with messages now cut to their declared
|
||||||
|
length (CAM, DENM and SPATEM all decoding in logcat after the flash).
|
||||||
- [ ] Near signed traffic: signed CAMs/DENMs appear in the app, and a simultaneous capture shows
|
- [ ] 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`).
|
them on air (`pcap_gn_tally.py` lists them as `secured`). NOT possible at this bench: the
|
||||||
- [ ] Unsigned bench traffic still decodes in the app as before (messages now arrive 8 bytes
|
CiT One transmits unsigned (`ItsGnSecurity = 0`) and nothing else here signs. Needs a drive
|
||||||
shorter).
|
past real RSUs, the CiT One switched to signed mode if its API allows, or a replay firmware
|
||||||
- [ ] The heartbeat's oversize counter still counts over-long messages (e.g. road SPATEMs).
|
on a spare board that re-transmits the recorded signed frames.
|
||||||
|
- [ ] The heartbeat's oversize counter still counts over-long messages. Not exercised at the
|
||||||
### CiT One custom CAM injection over `v2x/tx/v2/cam` (added 2026-09-14)
|
bench: the SPATEMs here are ~340 bytes on air, far below the cap.
|
||||||
|
|
||||||
The haw-002 unit now runs the special firmware: Cohda's own CAM transmission disabled, and a
|
|
||||||
V2X-Gateway build that accepts a `SendV2XMessage` (schemas.consider-innovation.de/its-s/
|
|
||||||
v2x_interface.proto) carrying a UPER CAM on `v2x/tx/v2/cam`. `tools/cit_one_cam_tx.py` builds
|
|
||||||
and publishes those from a PC; its `--self-test` passes offline, proving only that the bytes
|
|
||||||
match `CamEncodeGoldenTest.kt` and that the protobuf wrapper round-trips. Nothing about what
|
|
||||||
the OBU does with them is established.
|
|
||||||
|
|
||||||
Reach the broker over Wi-Fi or Ethernet for now - the USB-peripheral-mode link needs the phone
|
|
||||||
to be USB host on a `172.25.1.0/24` interface with no DHCP server, which Android cannot
|
|
||||||
configure from inside an app.
|
|
||||||
|
|
||||||
Needs: the CiT One haw-002 on the same network as a PC, and a second ESP32-C5 running
|
|
||||||
`its-g5-receiver-firmware` sniffing G5CC (`-c 5900`) to capture with.
|
|
||||||
|
|
||||||
Bench run 2026-09-14, PC -> haw-002 (192.168.3.201), captured on the RSU (192.168.3.202,
|
|
||||||
**not** .2.202 - that address does not route). `tools/cit_one_rx_watch.py` decodes what a unit
|
|
||||||
hears. Result: the injection path works end to end, with one blocker found.
|
|
||||||
|
|
||||||
- [x] Publishes without the broker refusing the topic. 1.00 Hz, confirmed by subscribing to
|
|
||||||
`v2x/tx/v2/cam` on the OBU itself.
|
|
||||||
- [x] The RSU hears our CAMs on air, 1.00 Hz, matching what we publish.
|
|
||||||
- [x] `ItsPduHeader` **is** expected in the payload - we send it included and it decodes.
|
|
||||||
- [x] BTP destination port 2001. GN source address `08:00:26:93:92:01:91:dc`, the OBU's.
|
|
||||||
- [x] **Our CAM content goes out intact**: position, speed (417), heading (639), width (7) and
|
|
||||||
length (18) arrive byte-exact. The gateway does not touch the content.
|
|
||||||
- [x] **The gateway overwrites `stationID`** with the OBU's own (999999 -> 4033890855, which
|
|
||||||
matches `own_info.stationID` on `v2x/rx/obu_gnss`). This is what the "OBU owns identity"
|
|
||||||
decision wants, so `--follow-obu-identity` is not needed on this unit.
|
|
||||||
- [x] ~~BLOCKER: Cohda's own CAM is still transmitting.~~ Fixed 2026-09-14 by disabling CAM in
|
|
||||||
a second conf file: the RSU now hears only our stream, 0 CAMs with the stack's
|
|
||||||
unavailable dimensions over 30 s. Note the stack restart gave the unit a new identity
|
|
||||||
(stationID 4033890855 -> 2553426533, GN source `08:00:26:...` -> `08:00:a2:...`), which is
|
|
||||||
expected under `ItsGnLocalAddrConfMethod = 2` (anonymous, random at boot).
|
|
||||||
- [x] Re-checked: 41 published / 41 heard over 40 s, 1.02 Hz both ends, inter-arrival a steady
|
|
||||||
1.0 s. 100% delivery, no gateway rate limiting. An earlier 0.40 Hz sample was the stack
|
|
||||||
still settling after the restart and did not persist.
|
|
||||||
Two topics the v6 API does not document, found by subscribing to `#` on haw-002:
|
|
||||||
|
|
||||||
- `v2x/loopback/cam` - a `RecvV2XMessage` (btpHeader.type=2) carrying each CAM the unit
|
|
||||||
transmits, 1:1 with what we publish and **after** the gateway's stationID rewrite. This is the
|
|
||||||
TX confirmation we were going to ask consider it for: it makes "did my CAM go out, and under
|
|
||||||
which identity" answerable on the transmitting unit alone, without an RSU or a second ESP32.
|
|
||||||
- `v2x/rx/obuinfo` at 10 Hz - the protobuf `OwnStationInfo` (binary twin of `obu_gnss`;
|
|
||||||
field 2 decodes to the same stationID, field 10 to the same heading). Output only, so it is
|
|
||||||
not the content-feed input we speculated about.
|
|
||||||
|
|
||||||
- [ ] Wire `v2x/loopback/cam` into `cit_one_rx_watch.py` as a local TX check.
|
|
||||||
- [ ] Sanity-check the rate: `--rate 4` should produce 4 CAMs/s on air, since `ItsDCCEnabled = 0`
|
|
||||||
on this unit.
|
|
||||||
|
|
||||||
## Set up host testing
|
## Set up host testing
|
||||||
|
|
||||||
@@ -354,6 +101,19 @@ Suggested order after the host tests exist:
|
|||||||
Dropped: building vanetza as a GN/BTP oracle. Real captures (`pcap_gn_tally.py`), the host
|
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.
|
round-trip test and `asn1tools` for UPER cover what it would have checked.
|
||||||
|
|
||||||
|
## Follow-ups found 2026-09-14
|
||||||
|
|
||||||
|
- [x] **Capture tooling moved into this repo** (`capture/`), with the CR-insertion fix. The
|
||||||
|
sniffer's console inserts a CR before every LF, which also hits every 0x0a byte of the binary
|
||||||
|
pcap stream, shifting pcap record headers and frames. A 787 KB capture parsed cleanly for
|
||||||
|
only 82 of ~2000 records, and DENMs showed up on nonsense BTP ports. `undo_crlf()` reverses
|
||||||
|
it on the raw stream before framing; afterwards a capture parsed to EOF and DENMs read as
|
||||||
|
port 2002. **Every capture taken before 2026-09-14 is truncated at its first corrupted
|
||||||
|
record** - re-measure anything derived from them.
|
||||||
|
- [ ] `capture/dump_pcap.py` reads the same console and still needs the same treatment.
|
||||||
|
- [ ] **Do not open COM3's console while the phone is attached.** Opening it toggles DTR/RTS on the
|
||||||
|
CH343 and resets the OBU, which drops the phone's USB link and needs a manual Connect.
|
||||||
|
|
||||||
## Follow-ups found 2026-09-11
|
## Follow-ups found 2026-09-11
|
||||||
|
|
||||||
- [ ] **App: show the signed flag.** `V2xRxFrame.parse` in `SerialFrame.kt` only reads bit0 of
|
- [ ] **App: show the signed flag.** `V2xRxFrame.parse` in `SerialFrame.kt` only reads bit0 of
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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) -->
|
<!-- Networking (MQTT / tile downloads) -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
@@ -11,15 +10,6 @@
|
|||||||
|
|
||||||
<!-- Location -->
|
<!-- Location -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_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" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
<!-- Phase A: foreground service (trip recording) -->
|
<!-- Phase A: foreground service (trip recording) -->
|
||||||
|
|||||||
Binary file not shown.
@@ -26,12 +26,10 @@ import androidx.core.view.WindowCompat
|
|||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
|
||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.ui.components.StatusTopBar
|
import com.hawhamburg.micr0bu.ui.components.StatusTopBar
|
||||||
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
|
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
|
||||||
@@ -42,7 +40,6 @@ import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen
|
|||||||
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
|
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.SensorScreen
|
import com.hawhamburg.micr0bu.ui.screens.SensorScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
|
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.V2xMapScreen
|
|
||||||
import com.hawhamburg.micr0bu.ui.screens.MapScreen
|
import com.hawhamburg.micr0bu.ui.screens.MapScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
|
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
|
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
|
||||||
@@ -89,10 +86,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
|
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
|
||||||
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
||||||
val obuHardware by mqttViewModel.obuHardware.collectAsState()
|
val obuHardware by mqttViewModel.obuHardware.collectAsState()
|
||||||
val esp32LinkState by mqttViewModel.esp32LinkState.collectAsState()
|
val usbSerialState by mqttViewModel.usbSerialState.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.
|
// 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
|
// 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.
|
// decide when a hazard or a traffic light has gone stale.
|
||||||
@@ -110,13 +104,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
// The V2X live map is a full-bleed destination: the app's own chrome would eat a
|
|
||||||
// third of the display on the one screen whose entire job is showing where things
|
|
||||||
// are relative to each other. It carries its own floating back button, and system
|
|
||||||
// back still works, so nothing becomes unreachable.
|
|
||||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val isFullBleed = currentBackStackEntry?.destination?.route == Screen.V2xMap.route
|
|
||||||
|
|
||||||
val locationLauncher = rememberLauncherForActivityResult(
|
val locationLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
) { permissions ->
|
) { permissions ->
|
||||||
@@ -132,17 +119,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.startImuStreams()
|
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(
|
locationLauncher.launch(
|
||||||
arrayOf(
|
arrayOf(
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||||
) + bluetooth
|
)
|
||||||
)
|
)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
@@ -151,17 +132,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
if (!isFullBleed) {
|
|
||||||
StatusTopBar(
|
StatusTopBar(
|
||||||
state = state,
|
state = state,
|
||||||
mqttConnectionState = mqttConnectionState,
|
mqttConnectionState = mqttConnectionState,
|
||||||
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
|
||||||
esp32LinkState = esp32LinkState,
|
usbSerialState = usbSerialState,
|
||||||
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
bottomBar = { if (!isFullBleed) BottomNavBar(navController) },
|
bottomBar = { BottomNavBar(navController) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -174,8 +152,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
mqttConnectionState = mqttConnectionState,
|
mqttConnectionState = mqttConnectionState,
|
||||||
activeTransport = activeTransport,
|
activeTransport = activeTransport,
|
||||||
obuHardware = obuHardware,
|
obuHardware = obuHardware,
|
||||||
esp32LinkState = esp32LinkState,
|
usbSerialState = usbSerialState,
|
||||||
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
|
|
||||||
usbCableConnected = usbConnected,
|
usbCableConnected = usbConnected,
|
||||||
obuStationTypeWarning = obuStationTypeWarning,
|
obuStationTypeWarning = obuStationTypeWarning,
|
||||||
obuStationType = obuStationType,
|
obuStationType = obuStationType,
|
||||||
@@ -214,7 +191,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
state = state,
|
state = state,
|
||||||
mqttConnectionState = mqttConnectionState,
|
mqttConnectionState = mqttConnectionState,
|
||||||
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
|
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
|
||||||
esp32LinkState == Esp32LinkState.CONNECTED
|
usbSerialState == UsbSerialState.CONNECTED
|
||||||
else
|
else
|
||||||
mqttConnectionState == MqttConnectionState.CONNECTED,
|
mqttConnectionState == MqttConnectionState.CONNECTED,
|
||||||
tripServiceState = tripServiceState,
|
tripServiceState = tripServiceState,
|
||||||
@@ -276,19 +253,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable(Screen.MqttViewer.route) {
|
composable(Screen.MqttViewer.route) {
|
||||||
MqttTopicViewerScreen(
|
MqttTopicViewerScreen(viewModel = mqttViewModel)
|
||||||
viewModel = mqttViewModel,
|
|
||||||
onOpenMap = { navController.navigate(Screen.V2xMap.route) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(Screen.V2xMap.route) {
|
|
||||||
// Activity-scoped instance, like Connection below: a hiltViewModel()
|
|
||||||
// here would be scoped to this NavBackStackEntry and torn down on the
|
|
||||||
// way back out, taking the shared transport with it.
|
|
||||||
V2xMapScreen(
|
|
||||||
viewModel = mqttViewModel,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Screen.Settings.route) {
|
composable(Screen.Settings.route) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
@@ -316,12 +281,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onMqttPrefsChange = mqttViewModel::updatePrefs,
|
onMqttPrefsChange = mqttViewModel::updatePrefs,
|
||||||
obuHardware = obuHardware,
|
obuHardware = obuHardware,
|
||||||
onObuHardwareChange = mqttViewModel::setObuHardware,
|
onObuHardwareChange = mqttViewModel::setObuHardware,
|
||||||
esp32Transport = esp32Transport,
|
|
||||||
onEsp32TransportChange = mqttViewModel::setEsp32Transport,
|
|
||||||
outgoingMessage = outgoingMessage,
|
|
||||||
onOutgoingMessageChange = mqttViewModel::setOutgoingMessage,
|
|
||||||
signOutgoing = signOutgoing,
|
|
||||||
onSignOutgoingChange = mqttViewModel::setSignOutgoing,
|
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,7 @@ import java.time.DateTimeException
|
|||||||
* [SystemClock.currentGnssTimeClock] (API 29, this app's minSdk) is a UTC clock the platform keeps
|
* [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
|
* 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,
|
* 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
|
* typically indoors before any satellite fix since boot, the wall clock is used unchanged.
|
||||||
* 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
|
* 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.
|
* where a given run's timestamps came from.
|
||||||
@@ -42,9 +36,6 @@ object GnssTimeSource {
|
|||||||
/** Whether the last correction used GNSS time; null before the first. For change-only logging. */
|
/** Whether the last correction used GNSS time; null before the first. For change-only logging. */
|
||||||
@Volatile private var lastUsedGnss: Boolean? = null
|
@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. */
|
/** [systemMs], a wall-clock reading, moved onto GNSS time where GNSS time is available. */
|
||||||
fun correct(systemMs: Long): Long {
|
fun correct(systemMs: Long): Long {
|
||||||
val systemNow = System.currentTimeMillis()
|
val systemNow = System.currentTimeMillis()
|
||||||
@@ -53,9 +44,8 @@ object GnssTimeSource {
|
|||||||
} catch (e: DateTimeException) {
|
} catch (e: DateTimeException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
if (gnssNow != null) lastErrorMs = gnssNow - systemNow
|
|
||||||
noteSource(gnssNow, systemNow)
|
noteSource(gnssNow, systemNow)
|
||||||
return ItsTime.onGnssTime(systemMs, lastErrorMs?.let { systemNow + it }, systemNow)
|
return ItsTime.onGnssTime(systemMs, gnssNow, systemNow)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun noteSource(gnssNow: Long?, systemNow: Long) {
|
private fun noteSource(gnssNow: Long?, systemNow: Long) {
|
||||||
@@ -65,9 +55,6 @@ object GnssTimeSource {
|
|||||||
if (gnssNow != null) {
|
if (gnssNow != null) {
|
||||||
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
|
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
|
||||||
"${"%+.1f".format((systemNow - gnssNow) / 1000.0)} s off")
|
"${"%+.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 {
|
} else {
|
||||||
Log.w(TAG, "GNSS time unavailable, transmit timestamps fall back to the phone clock, " +
|
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")
|
"which has no automatic time source without a SIM or internet")
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
|||||||
import com.hawhamburg.micr0bu.data.transport.BtpPort
|
import com.hawhamburg.micr0bu.data.transport.BtpPort
|
||||||
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
|
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
|
||||||
import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
|
import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||||
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
|
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
|
||||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||||
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
|
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
|
||||||
@@ -102,7 +102,7 @@ private const val RAW_PREFERRED_WINDOW_MS = 5_000L
|
|||||||
class CamUseCaseRepository @Inject constructor(
|
class CamUseCaseRepository @Inject constructor(
|
||||||
private val mqttRepository: MqttRepository,
|
private val mqttRepository: MqttRepository,
|
||||||
private val prefs: UseCaseAlertPreferences,
|
private val prefs: UseCaseAlertPreferences,
|
||||||
private val esp32Link: Esp32Link,
|
private val usbSerialTransport: UsbSerialTransport,
|
||||||
private val camCodec: RealAsn1UperCodec,
|
private val camCodec: RealAsn1UperCodec,
|
||||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||||
private val pseudonymManager: PseudonymManager,
|
private val pseudonymManager: PseudonymManager,
|
||||||
@@ -285,9 +285,9 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
// this only ever sees CAM UPER bytes. No-op stream on the CiT One path (the transport
|
// 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).
|
// just never emits CAM_RX frames if nothing's plugged in over serial).
|
||||||
scope.launch {
|
scope.launch {
|
||||||
esp32Link.incomingFrames.collect { frame ->
|
usbSerialTransport.incomingFrames.collect { frame ->
|
||||||
if (frame.type != SerialFrameType.V2X_RX) return@collect
|
if (frame.type != SerialFrameType.V2X_RX) return@collect
|
||||||
if (esp32Link.state.value != Esp32LinkState.CONNECTED) return@collect
|
if (usbSerialTransport.state.value != UsbSerialState.CONNECTED) return@collect
|
||||||
val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect
|
val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect
|
||||||
when (v2x.btpPort) {
|
when (v2x.btpPort) {
|
||||||
BtpPort.CAM -> handleCamFromSerial(v2x)
|
BtpPort.CAM -> handleCamFromSerial(v2x)
|
||||||
@@ -304,10 +304,10 @@ class CamUseCaseRepository @Inject constructor(
|
|||||||
// last-seen positions and their alerts linger on the map and in the use-case panel after
|
// 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.
|
// an unplug, which reads as live traffic - the worst kind of stale on a safety display.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
esp32Link.state.collect { state ->
|
usbSerialTransport.state.collect { state ->
|
||||||
// ESP32-only: on the CiT One path this transport is permanently DISCONNECTED and
|
// ESP32-only: on the CiT One path this transport is permanently DISCONNECTED and
|
||||||
// resetting here would wipe perfectly good MQTT-derived state.
|
// resetting here would wipe perfectly good MQTT-derived state.
|
||||||
if (currentHardware == ObuHardware.ESP32_C5 && state != Esp32LinkState.CONNECTED) {
|
if (currentHardware == ObuHardware.ESP32_C5 && state != UsbSerialState.CONNECTED) {
|
||||||
engine.reset()
|
engine.reset()
|
||||||
_rsuStations.value = emptyMap()
|
_rsuStations.value = emptyMap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package com.hawhamburg.micr0bu.data.mqtt
|
package com.hawhamburg.micr0bu.data.mqtt
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
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.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
|
||||||
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -35,10 +32,6 @@ class ObuHardwarePreferences @Inject constructor(
|
|||||||
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
val OWN_STATION_ID = longPreferencesKey("own_station_id")
|
||||||
val OWN_MAC = stringPreferencesKey("own_mac")
|
val OWN_MAC = stringPreferencesKey("own_mac")
|
||||||
val OWN_PSEUDONYM_CREATED_MS = longPreferencesKey("own_pseudonym_created_ms")
|
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 ->
|
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||||
@@ -49,37 +42,6 @@ class ObuHardwarePreferences @Inject constructor(
|
|||||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
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.
|
* The transmit pseudonym last saved by [savePseudonym], or null if there is none.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,466 +0,0 @@
|
|||||||
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()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,9 +57,8 @@ const val SERIAL_LINK_MAX_PAYLOAD = 512
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
|
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
|
||||||
* [rxCrcErrors:2 LE][capabilities:1][rxQueueDrops:2 LE]` (10 bytes; the last two fields are an
|
* [rxCrcErrors:2 LE]` (7 bytes). Counters are free-running totals since firmware boot and
|
||||||
* optional tail — see [capabilities] and [rxQueueDrops]). Counters are free-running totals since
|
* saturate at 0xFFFF rather than wrapping.
|
||||||
* firmware boot and saturate at 0xFFFF rather than wrapping.
|
|
||||||
*
|
*
|
||||||
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
|
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
|
||||||
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
|
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
|
||||||
@@ -80,15 +79,6 @@ data class EspLinkStatus(
|
|||||||
* the phone needs from such firmware: it accepts nothing beyond the original messages.
|
* the phone needs from such firmware: it accepts nothing beyond the original messages.
|
||||||
*/
|
*/
|
||||||
val capabilities: Int = 0,
|
val capabilities: Int = 0,
|
||||||
/**
|
|
||||||
* Promiscuously-captured frames the firmware's `wifi_promisc_rx_cb` had to drop because its
|
|
||||||
* RX queue (8 deep) was still full of frames `rx_forward_task` hadn't finished forwarding —
|
|
||||||
* bytes 8-9 of the payload. 0 for firmware that predates this field (payload of 7 or 8 bytes),
|
|
||||||
* which is the honest answer: such firmware drops these frames identically, it just never
|
|
||||||
* counted them. A nonzero, growing value here — as opposed to [oversizeDrops] — points at
|
|
||||||
* bursty RX outrunning the forward task rather than any one frame being too large.
|
|
||||||
*/
|
|
||||||
val rxQueueDrops: Int = 0,
|
|
||||||
) {
|
) {
|
||||||
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
|
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
|
||||||
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
|
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
|
||||||
@@ -109,7 +99,6 @@ data class EspLinkStatus(
|
|||||||
txFailures = u16(3),
|
txFailures = u16(3),
|
||||||
rxCrcErrors = u16(5),
|
rxCrcErrors = u16(5),
|
||||||
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
|
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
|
||||||
rxQueueDrops = if (payload.size >= 10) u16(8) else 0,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
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,6 +35,9 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
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 ACTION_USB_PERMISSION = "com.hawhamburg.micr0bu.USB_SERIAL_PERMISSION"
|
||||||
private const val TAG = "UsbSerialTransport"
|
private const val TAG = "UsbSerialTransport"
|
||||||
|
|
||||||
@@ -60,11 +63,6 @@ private const val TAG = "UsbSerialTransport"
|
|||||||
* Baud rate is not applicable here — USB Serial/JTAG has no baud concept; `setParameters` below
|
* 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.
|
* 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
|
* ## Ownership
|
||||||
* This is a `@Singleton` shared by the UI ([com.hawhamburg.micr0bu.viewmodel.MqttViewModel]), the
|
* This is a `@Singleton` shared by the UI ([com.hawhamburg.micr0bu.viewmodel.MqttViewModel]), the
|
||||||
* foreground [com.hawhamburg.micr0bu.service.TripRecordingService]'s
|
* foreground [com.hawhamburg.micr0bu.service.TripRecordingService]'s
|
||||||
@@ -113,8 +111,8 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
|
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED)
|
||||||
val state: StateFlow<Esp32LinkState> = _state.asStateFlow()
|
val state: StateFlow<UsbSerialState> = _state.asStateFlow()
|
||||||
|
|
||||||
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
|
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
|
||||||
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
|
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
|
||||||
@@ -152,7 +150,7 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "usbReceiver: permission denied or device null " +
|
Log.w(TAG, "usbReceiver: permission denied or device null " +
|
||||||
"(granted=$granted, device=$device)")
|
"(granted=$granted, device=$device)")
|
||||||
_state.value = Esp32LinkState.ERROR
|
_state.value = UsbSerialState.ERROR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
||||||
@@ -176,7 +174,7 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
* repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
|
* repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
|
||||||
*/
|
*/
|
||||||
fun connect() {
|
fun connect() {
|
||||||
if (_state.value == Esp32LinkState.CONNECTED) {
|
if (_state.value == UsbSerialState.CONNECTED) {
|
||||||
Log.i(TAG, "connect(): already connected, no-op")
|
Log.i(TAG, "connect(): already connected, no-op")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -199,7 +197,7 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
"(see device list logged above) - either nothing is attached at the Android " +
|
"(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 " +
|
"USB level, or it's attached but its VID/PID doesn't match any entry in " +
|
||||||
"customProber's table")
|
"customProber's table")
|
||||||
_state.value = Esp32LinkState.DISCONNECTED
|
_state.value = UsbSerialState.DISCONNECTED
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (espDriver == null) {
|
if (espDriver == null) {
|
||||||
@@ -213,14 +211,14 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
Log.i(TAG, "connect(): matched device vid=0x${device.vendorId.toString(16)} " +
|
Log.i(TAG, "connect(): matched device vid=0x${device.vendorId.toString(16)} " +
|
||||||
"pid=0x${device.productId.toString(16)} name=${device.deviceName} " +
|
"pid=0x${device.productId.toString(16)} name=${device.deviceName} " +
|
||||||
"ports=${driver.ports.size}")
|
"ports=${driver.ports.size}")
|
||||||
_state.value = Esp32LinkState.DEVICE_ATTACHED
|
_state.value = UsbSerialState.DEVICE_ATTACHED
|
||||||
|
|
||||||
if (usbManager.hasPermission(device)) {
|
if (usbManager.hasPermission(device)) {
|
||||||
Log.i(TAG, "connect(): permission already granted, opening directly")
|
Log.i(TAG, "connect(): permission already granted, opening directly")
|
||||||
openDevice(device)
|
openDevice(device)
|
||||||
} else {
|
} else {
|
||||||
Log.i(TAG, "connect(): requesting USB permission from user")
|
Log.i(TAG, "connect(): requesting USB permission from user")
|
||||||
_state.value = Esp32LinkState.PERMISSION_REQUESTED
|
_state.value = UsbSerialState.PERMISSION_REQUESTED
|
||||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||||
val permissionIntent = PendingIntent.getBroadcast(
|
val permissionIntent = PendingIntent.getBroadcast(
|
||||||
context, 0, Intent(ACTION_USB_PERMISSION).setPackage(context.packageName), flags,
|
context, 0, Intent(ACTION_USB_PERMISSION).setPackage(context.packageName), flags,
|
||||||
@@ -271,14 +269,14 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
if (driver == null || driver.ports.isEmpty()) {
|
if (driver == null || driver.ports.isEmpty()) {
|
||||||
Log.w(TAG, "openDevice(): probeDevice returned null or no ports for " +
|
Log.w(TAG, "openDevice(): probeDevice returned null or no ports for " +
|
||||||
"vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
|
"vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
|
||||||
_state.value = Esp32LinkState.ERROR
|
_state.value = UsbSerialState.ERROR
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val connection = usbManager.openDevice(device)
|
val connection = usbManager.openDevice(device)
|
||||||
if (connection == null) {
|
if (connection == null) {
|
||||||
Log.w(TAG, "openDevice(): usbManager.openDevice() returned null - permission not " +
|
Log.w(TAG, "openDevice(): usbManager.openDevice() returned null - permission not " +
|
||||||
"actually granted, or Android couldn't claim the device")
|
"actually granted, or Android couldn't claim the device")
|
||||||
_state.value = Esp32LinkState.ERROR
|
_state.value = UsbSerialState.ERROR
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +287,7 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e)
|
Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e)
|
||||||
runCatching { newPort.close() }
|
runCatching { newPort.close() }
|
||||||
_state.value = Esp32LinkState.ERROR
|
_state.value = UsbSerialState.ERROR
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,15 +330,13 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
prev.txFailures != status.txFailures ||
|
prev.txFailures != status.txFailures ||
|
||||||
prev.rxCrcErrors != status.rxCrcErrors ||
|
prev.rxCrcErrors != status.rxCrcErrors ||
|
||||||
prev.status != status.status ||
|
prev.status != status.status ||
|
||||||
prev.capabilities != status.capabilities ||
|
prev.capabilities != status.capabilities
|
||||||
prev.rxQueueDrops != status.rxQueueDrops
|
|
||||||
) {
|
) {
|
||||||
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
Log.i(TAG, "ESP32 counters: status=${status.status} " +
|
||||||
"oversizeDrops=${status.oversizeDrops} " +
|
"oversizeDrops=${status.oversizeDrops} " +
|
||||||
"txFailures=${status.txFailures} " +
|
"txFailures=${status.txFailures} " +
|
||||||
"rxCrcErrors=${status.rxCrcErrors} " +
|
"rxCrcErrors=${status.rxCrcErrors} " +
|
||||||
"capabilities=${status.capabilities} " +
|
"capabilities=${status.capabilities}")
|
||||||
"rxQueueDrops=${status.rxQueueDrops}")
|
|
||||||
}
|
}
|
||||||
_linkStatus.value = status
|
_linkStatus.value = status
|
||||||
}
|
}
|
||||||
@@ -351,7 +347,7 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
|
|
||||||
override fun onRunError(e: Exception) {
|
override fun onRunError(e: Exception) {
|
||||||
Log.e(TAG, "SerialInputOutputManager.onRunError()", e)
|
Log.e(TAG, "SerialInputOutputManager.onRunError()", e)
|
||||||
_state.value = Esp32LinkState.ERROR
|
_state.value = UsbSerialState.ERROR
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ioManager = manager
|
ioManager = manager
|
||||||
@@ -364,13 +360,13 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
_consecutiveWriteFailures.value = 0
|
_consecutiveWriteFailures.value = 0
|
||||||
_linkStatus.value = null
|
_linkStatus.value = null
|
||||||
lastFrameAtMs = SystemClock.elapsedRealtime()
|
lastFrameAtMs = SystemClock.elapsedRealtime()
|
||||||
_state.value = Esp32LinkState.CONNECTED
|
_state.value = UsbSerialState.CONNECTED
|
||||||
startWatchdog()
|
startWatchdog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flips the link to [Esp32LinkState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
|
* Flips the link to [UsbSerialState.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
|
* 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.
|
* at some point in the past" — which on a bench is exactly the wrong thing to believe.
|
||||||
*/
|
*/
|
||||||
@@ -379,14 +375,14 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
watchdogJob = scope.launch {
|
watchdogJob = scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(WATCHDOG_POLL_MS)
|
delay(WATCHDOG_POLL_MS)
|
||||||
if (_state.value != Esp32LinkState.CONNECTED) continue
|
if (_state.value != UsbSerialState.CONNECTED) continue
|
||||||
val silentFor = SystemClock.elapsedRealtime() - lastFrameAtMs
|
val silentFor = SystemClock.elapsedRealtime() - lastFrameAtMs
|
||||||
if (silentFor > LINK_TIMEOUT_MS) {
|
if (silentFor > LINK_TIMEOUT_MS) {
|
||||||
Log.w(TAG, "watchdog: no frame from ESP32 for ${silentFor}ms (heartbeat " +
|
Log.w(TAG, "watchdog: no frame from ESP32 for ${silentFor}ms (heartbeat " +
|
||||||
"expected at 1 Hz) - marking link ERROR. Either the firmware is wedged/" +
|
"expected at 1 Hz) - marking link ERROR. Either the firmware is wedged/" +
|
||||||
"not running, or the host->device direction opened but device->host " +
|
"not running, or the host->device direction opened but device->host " +
|
||||||
"never did (see the DTR note in openDevice()).")
|
"never did (see the DTR note in openDevice()).")
|
||||||
_state.value = Esp32LinkState.ERROR
|
_state.value = UsbSerialState.ERROR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,29 +443,7 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
|
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
|
||||||
Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e)
|
Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e)
|
||||||
_state.value = Esp32LinkState.ERROR
|
_state.value = UsbSerialState.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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -485,7 +459,7 @@ class UsbSerialTransport @Inject constructor(
|
|||||||
openDeviceName = null
|
openDeviceName = null
|
||||||
_linkStatus.value = null
|
_linkStatus.value = null
|
||||||
_consecutiveWriteFailures.value = 0
|
_consecutiveWriteFailures.value = 0
|
||||||
_state.value = Esp32LinkState.DISCONNECTED
|
_state.value = UsbSerialState.DISCONNECTED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import javax.inject.Singleton
|
|||||||
* UPER (Unaligned Packed Encoding Rules) codec for CAM, used on the ESP32-C5 hardware path
|
* 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
|
* (Phase 03, Section 13): the phone builds outgoing CAM itself
|
||||||
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]) and UPER-encodes it before handing bytes
|
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]) and UPER-encodes it before handing bytes
|
||||||
* to [com.hawhamburg.micr0bu.data.transport.Esp32Link], and UPER-decodes whatever the
|
* to [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport], and UPER-decodes whatever the
|
||||||
* ESP32-C5 forwards back on receive (already stripped of 802.11/GeoNetworking/BTP framing by
|
* 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).
|
* the firmware's `gn_unwrap.c` — this only ever sees CAM UPER bytes, never raw radio frames).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import kotlin.math.abs
|
|||||||
* This class only does the sensor-fusion-into-CAM-fields part, independent of the wire protocol
|
* 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]:
|
* to the ESP32-C5. Live flow, driven by [com.hawhamburg.micr0bu.service.CamTransmitLoop]:
|
||||||
*
|
*
|
||||||
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `Esp32Link` (write).
|
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
|
||||||
*
|
*
|
||||||
* Position/speed/heading come straight from GNSS. Yaw rate is derived from the gyroscope's
|
* 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
|
* z-axis reading (rotation about the vertical axis while the phone is roughly flat/mounted
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,7 @@ import com.hawhamburg.micr0bu.data.GnssReading
|
|||||||
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
import com.hawhamburg.micr0bu.data.GnssTimeSource
|
||||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||||
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||||
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.asn1.RealAsn1UperCodec
|
||||||
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||||||
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
||||||
@@ -24,7 +21,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -53,8 +49,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class CamPinger @Inject constructor(
|
class CamPinger @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val esp32Link: Esp32Link,
|
private val usbSerialTransport: UsbSerialTransport,
|
||||||
private val prefs: ObuHardwarePreferences,
|
|
||||||
private val codec: RealAsn1UperCodec,
|
private val codec: RealAsn1UperCodec,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@@ -119,8 +114,7 @@ class CamPinger @Inject constructor(
|
|||||||
// Fixed bench identity on every layer, the link-layer address included, so a ping
|
// Fixed bench identity on every layer, the link-layer address included, so a ping
|
||||||
// stays recognisable in a capture and never rotates.
|
// stays recognisable in a capture and never rotates.
|
||||||
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
|
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
|
||||||
val signed = prefs.signOutgoingFlow.first()
|
if (usbSerialTransport.sendCamTx(bytes, pv)) {
|
||||||
if (esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes, pv, gnss.accuracyM, signed))) {
|
|
||||||
_sentCount.update { it + 1 }
|
_sentCount.update { it + 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,13 @@ import com.hawhamburg.micr0bu.data.GnssTimeSource
|
|||||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||||
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
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.GnPositionVector
|
||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.data.transport.OutgoingIts
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
|
||||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
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.CamTransmitConfig
|
||||||
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
|
||||||
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -50,19 +45,12 @@ import javax.inject.Singleton
|
|||||||
* [com.hawhamburg.micr0bu.domain.detection.EventDetector] stream that already drives trip event
|
* [com.hawhamburg.micr0bu.domain.detection.EventDetector] stream that already drives trip event
|
||||||
* logging). Both rate figures are placeholders pending real-world tuning, per
|
* logging). Both rate figures are placeholders pending real-world tuning, per
|
||||||
* [CamTransmitConfig]'s own disclaimer.
|
* [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
|
@Singleton
|
||||||
class CamTransmitLoop @Inject constructor(
|
class CamTransmitLoop @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||||
private val esp32Link: Esp32Link,
|
private val usbSerialTransport: UsbSerialTransport,
|
||||||
private val codec: RealAsn1UperCodec,
|
private val codec: RealAsn1UperCodec,
|
||||||
private val pseudonymManager: PseudonymManager,
|
private val pseudonymManager: PseudonymManager,
|
||||||
) {
|
) {
|
||||||
@@ -79,10 +67,6 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
|
||||||
@Volatile private var previousGnss: GnssReading? = null
|
@Volatile private var previousGnss: GnssReading? = null
|
||||||
|
|
||||||
@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
|
* Call when a braking/turning/stopping event fires during an active trip — bumps the CAM
|
||||||
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
|
* rate to [CamTransmitConfig.elevatedRateHz] for [ELEVATED_HOLD_MS] so nearby stations get
|
||||||
@@ -101,7 +85,6 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
if (job?.isActive == true) return
|
if (job?.isActive == true) return
|
||||||
elevatedUntilMs = 0L
|
elevatedUntilMs = 0L
|
||||||
previousGnss = null
|
previousGnss = null
|
||||||
vamRules.reset()
|
|
||||||
job = scope.launch {
|
job = scope.launch {
|
||||||
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
|
||||||
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
|
||||||
@@ -119,27 +102,10 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
private suspend fun runTransmitLoop() = coroutineScope {
|
private suspend fun runTransmitLoop() = coroutineScope {
|
||||||
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
|
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
|
||||||
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
|
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) {
|
while (true) {
|
||||||
val gnss = latestGnss
|
val gnss = latestGnss
|
||||||
if (gnss != null) {
|
if (gnss != null) {
|
||||||
when (outgoing) {
|
|
||||||
OutgoingMessage.CAM -> sendCam(gnss)
|
|
||||||
OutgoingMessage.VAM -> sendVamIfDue(gnss)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
// 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.
|
// rotation fall cleanly between two frames instead of inside one.
|
||||||
val pseudonym = pseudonymManager.current()
|
val pseudonym = pseudonymManager.current()
|
||||||
@@ -148,35 +114,10 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||||
val bytes = codec.encodeCam(cam)
|
val bytes = codec.encodeCam(cam)
|
||||||
esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes,
|
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
|
||||||
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
}
|
||||||
|
delay((1000.0 / currentRateHz(gnss)).toLong())
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,9 +158,6 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val ELEVATED_HOLD_MS = 5_000L
|
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. */
|
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
|
||||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ import androidx.compose.material.icons.filled.GpsOff
|
|||||||
import androidx.compose.material.icons.filled.Sensors
|
import androidx.compose.material.icons.filled.Sensors
|
||||||
import androidx.compose.material.icons.filled.SensorsOff
|
import androidx.compose.material.icons.filled.SensorsOff
|
||||||
import androidx.compose.material.icons.filled.Usb
|
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.UsbOff
|
||||||
import androidx.compose.material.icons.filled.Wifi
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
import androidx.compose.material.icons.filled.WifiOff
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
@@ -41,7 +39,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||||
|
|
||||||
private val GreenActive = Color(0xFF4CAF50)
|
private val GreenActive = Color(0xFF4CAF50)
|
||||||
@@ -55,9 +53,7 @@ fun StatusTopBar(
|
|||||||
state: SensorUiState,
|
state: SensorUiState,
|
||||||
mqttConnectionState: MqttConnectionState,
|
mqttConnectionState: MqttConnectionState,
|
||||||
isEsp32: Boolean = false,
|
isEsp32: Boolean = false,
|
||||||
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||||
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
|
|
||||||
esp32Bluetooth: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -97,7 +93,7 @@ fun StatusTopBar(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
ObuStatusIcon(mqttConnectionState, isEsp32, esp32LinkState, esp32Bluetooth = esp32Bluetooth)
|
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -110,21 +106,20 @@ fun StatusTopBar(
|
|||||||
* OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One
|
* 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
|
* 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
|
* 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 or Bluetooth glyph there
|
* report the OBU as offline while CAMs were streaming in. Uses a USB glyph there rather than the
|
||||||
* rather than the Wi-Fi one, since that is literally what the connection is.
|
* Wi-Fi one, since that is literally what the connection is.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ObuStatusIcon(
|
private fun ObuStatusIcon(
|
||||||
mqttState: MqttConnectionState,
|
mqttState: MqttConnectionState,
|
||||||
isEsp32: Boolean,
|
isEsp32: Boolean,
|
||||||
esp32LinkState: Esp32LinkState,
|
usbSerialState: UsbSerialState,
|
||||||
esp32Bluetooth: Boolean,
|
|
||||||
) {
|
) {
|
||||||
val state = if (isEsp32) esp32LinkState.asConnectionState() else mqttState
|
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState
|
||||||
val linkUp = state == MqttConnectionState.CONNECTED || state == MqttConnectionState.CONNECTING
|
|
||||||
val icon = when {
|
val icon = when {
|
||||||
isEsp32 && esp32Bluetooth -> if (linkUp) Icons.Default.Bluetooth else Icons.Default.BluetoothDisabled
|
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb
|
||||||
isEsp32 -> if (linkUp) Icons.Default.Usb else Icons.Default.UsbOff
|
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb
|
||||||
|
isEsp32 -> Icons.Default.UsbOff
|
||||||
state == MqttConnectionState.CONNECTED ||
|
state == MqttConnectionState.CONNECTED ||
|
||||||
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
||||||
else -> Icons.Default.WifiOff
|
else -> Icons.Default.WifiOff
|
||||||
@@ -199,10 +194,10 @@ private fun RecordingPulse() {
|
|||||||
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour
|
* 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.
|
* and pulse logic already speaks, so one indicator serves both transports.
|
||||||
*/
|
*/
|
||||||
private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
|
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
|
||||||
Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
|
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||||
Esp32LinkState.DEVICE_ATTACHED,
|
UsbSerialState.DEVICE_ATTACHED,
|
||||||
Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||||
Esp32LinkState.ERROR -> MqttConnectionState.ERROR
|
UsbSerialState.ERROR -> MqttConnectionState.ERROR
|
||||||
Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ sealed class Screen(val route: String, val labelRes: Int) {
|
|||||||
data object Connection : Screen("connection", R.string.nav_connection)
|
data object Connection : Screen("connection", R.string.nav_connection)
|
||||||
data object Map : Screen("map", R.string.map_title)
|
data object Map : Screen("map", R.string.map_title)
|
||||||
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
|
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
|
||||||
/** Full-screen V2X live map, opened from the V2X Monitor's map button. */
|
|
||||||
data object V2xMap : Screen("v2x_map", R.string.v2x_map_title)
|
|
||||||
|
|
||||||
// Phase A — Trip Recording
|
// Phase A — Trip Recording
|
||||||
data object TripHistory : Screen("trip_history", R.string.nav_trips)
|
data object TripHistory : Screen("trip_history", R.string.nav_trips)
|
||||||
@@ -85,7 +83,6 @@ private fun Screen.ownsRoute(route: String?): Boolean {
|
|||||||
route == Screen.Map.route ||
|
route == Screen.Map.route ||
|
||||||
route == Screen.Sensors.route
|
route == Screen.Sensors.route
|
||||||
Screen.Record -> route == Screen.Log.route
|
Screen.Record -> route == Screen.Log.route
|
||||||
Screen.MqttViewer -> route == Screen.V2xMap.route
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ import com.hawhamburg.micr0bu.R
|
|||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
|
|
||||||
import androidx.compose.material.icons.filled.Bluetooth
|
|
||||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||||
|
|
||||||
private val UsbGreen = Color(0xFF4CAF50)
|
private val UsbGreen = Color(0xFF4CAF50)
|
||||||
@@ -67,11 +65,7 @@ fun ConnectionSetupScreen(
|
|||||||
val activeTransport by viewModel.activeTransport.collectAsState()
|
val activeTransport by viewModel.activeTransport.collectAsState()
|
||||||
val mqttPrefs by viewModel.mqttPrefs.collectAsState()
|
val mqttPrefs by viewModel.mqttPrefs.collectAsState()
|
||||||
val obuHardware by viewModel.obuHardware.collectAsState()
|
val obuHardware by viewModel.obuHardware.collectAsState()
|
||||||
val esp32LinkState by viewModel.esp32LinkState.collectAsState()
|
val usbSerialState by viewModel.usbSerialState.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 isConnected = connectionState == MqttConnectionState.CONNECTED
|
||||||
val isConnecting = connectionState == MqttConnectionState.CONNECTING
|
val isConnecting = connectionState == MqttConnectionState.CONNECTING
|
||||||
@@ -235,22 +229,22 @@ fun ConnectionSetupScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ── ESP32-C5 real connection card (Phase 03) ────────────────────────
|
// ── ESP32-C5 real connection card (Phase 03) ────────────────────────
|
||||||
// Backed by Esp32Link: USB (UsbSerialTransport, native USB Serial/JTAG CDC-ACM - see
|
// Backed by UsbSerialTransport (native USB Serial/JTAG CDC-ACM link) - see that
|
||||||
// its KDoc for the VID/PID and native-vs-UART-bridge port note) or BLE (BleLinkTransport).
|
// class's KDoc for the VID/PID (0x303A/0x1001) and native-vs-UART-bridge port note.
|
||||||
val isEspConnected = esp32LinkState == Esp32LinkState.CONNECTED
|
val isEspConnected = usbSerialState == UsbSerialState.CONNECTED
|
||||||
val isEspBusy = esp32LinkState == Esp32LinkState.DEVICE_ATTACHED ||
|
val isEspBusy = usbSerialState == UsbSerialState.DEVICE_ATTACHED ||
|
||||||
esp32LinkState == Esp32LinkState.PERMISSION_REQUESTED
|
usbSerialState == UsbSerialState.PERMISSION_REQUESTED
|
||||||
val espContainerColor = when {
|
val espContainerColor = when {
|
||||||
isEspConnected -> UsbGreenBg
|
isEspConnected -> UsbGreenBg
|
||||||
isEspBusy -> UsbAmberBg
|
isEspBusy -> UsbAmberBg
|
||||||
else -> UsbGrayBg
|
else -> UsbGrayBg
|
||||||
}
|
}
|
||||||
val (espColor, espStateLabel) = when (esp32LinkState) {
|
val (espColor, espStateLabel) = when (usbSerialState) {
|
||||||
Esp32LinkState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
|
UsbSerialState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
|
||||||
Esp32LinkState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
|
UsbSerialState.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)
|
UsbSerialState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
|
||||||
Esp32LinkState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
|
UsbSerialState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
|
||||||
Esp32LinkState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
|
UsbSerialState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
@@ -263,7 +257,7 @@ fun ConnectionSetupScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
if (esp32Transport == Esp32Transport.BLE) Icons.Default.Bluetooth else Icons.Default.Usb,
|
Icons.Default.Usb,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = espColor,
|
tint = espColor,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
@@ -291,36 +285,11 @@ fun ConnectionSetupScreen(
|
|||||||
Text(espStateLabel, style = MaterialTheme.typography.bodySmall, color = espColor)
|
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
if (isEspConnected || isEspBusy) {
|
if (isEspConnected) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { viewModel.disconnectEsp32() },
|
onClick = { viewModel.disconnectUsbSerial() },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
contentColor = Color(0xFFFF5252),
|
contentColor = Color(0xFFFF5252),
|
||||||
@@ -328,13 +297,13 @@ fun ConnectionSetupScreen(
|
|||||||
) {
|
) {
|
||||||
Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
|
Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
// While connecting (BLE retries until it succeeds) this cancels the attempt.
|
Text(stringResource(R.string.conn_disconnect))
|
||||||
Text(stringResource(if (isEspConnected) R.string.conn_disconnect else R.string.conn_esp32_cancel))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button(
|
Button(
|
||||||
onClick = { viewModel.connectEsp32() },
|
onClick = { viewModel.connectUsbSerial() },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !isEspBusy,
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
|
Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import com.hawhamburg.micr0bu.R
|
|||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||||
@@ -78,9 +78,7 @@ fun DashboardScreen(
|
|||||||
mqttConnectionState: MqttConnectionState,
|
mqttConnectionState: MqttConnectionState,
|
||||||
activeTransport: TransportType = TransportType.USB_C,
|
activeTransport: TransportType = TransportType.USB_C,
|
||||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||||
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||||
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
|
|
||||||
esp32Bluetooth: Boolean = false,
|
|
||||||
usbCableConnected: Boolean = false,
|
usbCableConnected: Boolean = false,
|
||||||
obuStationTypeWarning: Boolean = false,
|
obuStationTypeWarning: Boolean = false,
|
||||||
obuStationType: Int? = null,
|
obuStationType: Int? = null,
|
||||||
@@ -283,14 +281,14 @@ fun DashboardScreen(
|
|||||||
// even once the serial link is actually up.
|
// even once the serial link is actually up.
|
||||||
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
|
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
|
||||||
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
|
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
|
||||||
val obuConnected = if (isEsp32) esp32LinkState == Esp32LinkState.CONNECTED else mqttConnected
|
val obuConnected = if (isEsp32) usbSerialState == UsbSerialState.CONNECTED else mqttConnected
|
||||||
val transportIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.Bluetooth else when (activeTransport) {
|
val transportIcon = when (activeTransport) {
|
||||||
TransportType.USB_C -> Icons.Default.Usb
|
TransportType.USB_C -> Icons.Default.Usb
|
||||||
TransportType.USB_SERIAL -> Icons.Default.Usb
|
TransportType.USB_SERIAL -> Icons.Default.Usb
|
||||||
TransportType.WIFI -> Icons.Default.Wifi
|
TransportType.WIFI -> Icons.Default.Wifi
|
||||||
TransportType.BLUETOOTH -> Icons.Default.Bluetooth
|
TransportType.BLUETOOTH -> Icons.Default.Bluetooth
|
||||||
}
|
}
|
||||||
val transportInactiveIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.BluetoothDisabled else when (activeTransport) {
|
val transportInactiveIcon = when (activeTransport) {
|
||||||
TransportType.USB_C -> Icons.Default.Usb
|
TransportType.USB_C -> Icons.Default.Usb
|
||||||
TransportType.USB_SERIAL -> Icons.Default.Usb
|
TransportType.USB_SERIAL -> Icons.Default.Usb
|
||||||
TransportType.WIFI -> Icons.Default.WifiOff
|
TransportType.WIFI -> Icons.Default.WifiOff
|
||||||
@@ -326,12 +324,12 @@ fun DashboardScreen(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (isEsp32) {
|
text = if (isEsp32) {
|
||||||
when (esp32LinkState) {
|
when (usbSerialState) {
|
||||||
Esp32LinkState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
|
UsbSerialState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
|
||||||
Esp32LinkState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
|
UsbSerialState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
|
||||||
Esp32LinkState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
|
UsbSerialState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
|
||||||
Esp32LinkState.ERROR -> stringResource(R.string.conn_esp32_state_error)
|
UsbSerialState.ERROR -> stringResource(R.string.conn_esp32_state_error)
|
||||||
Esp32LinkState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
|
UsbSerialState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
|
||||||
}
|
}
|
||||||
} else when (mqttConnectionState) {
|
} else when (mqttConnectionState) {
|
||||||
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
|
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
|
||||||
@@ -364,16 +362,15 @@ fun DashboardScreen(
|
|||||||
TransportChip(
|
TransportChip(
|
||||||
label = stringResource(R.string.dash_transport_usb_serial),
|
label = stringResource(R.string.dash_transport_usb_serial),
|
||||||
icon = Icons.Default.Usb,
|
icon = Icons.Default.Usb,
|
||||||
active = !esp32Bluetooth,
|
active = activeTransport == TransportType.USB_SERIAL,
|
||||||
hasCable = usbCableConnected,
|
hasCable = usbCableConnected,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TransportChip(
|
TransportChip(
|
||||||
label = stringResource(R.string.dash_transport_bt),
|
label = stringResource(R.string.dash_transport_bt),
|
||||||
icon = Icons.Default.Bluetooth,
|
icon = Icons.Default.Bluetooth,
|
||||||
active = isEsp32 && esp32Bluetooth,
|
active = activeTransport == TransportType.BLUETOOTH,
|
||||||
// Only the ESP32-C5 has a BLE link; the CiT One has none.
|
dimmed = true, // Phase 03 — production BT transport still under discussion
|
||||||
dimmed = !isEsp32,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
|||||||
import androidx.compose.material.icons.filled.Circle
|
import androidx.compose.material.icons.filled.Circle
|
||||||
import androidx.compose.material.icons.filled.Link
|
import androidx.compose.material.icons.filled.Link
|
||||||
import androidx.compose.material.icons.filled.LinkOff
|
import androidx.compose.material.icons.filled.LinkOff
|
||||||
import androidx.compose.material.icons.filled.Map
|
|
||||||
import androidx.compose.material.icons.filled.NotificationsActive
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
import androidx.compose.material.icons.filled.VerticalAlignBottom
|
import androidx.compose.material.icons.filled.VerticalAlignBottom
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
@@ -70,7 +69,7 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
|||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
|
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
|
||||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||||
import com.hawhamburg.micr0bu.domain.cam.CamParser
|
import com.hawhamburg.micr0bu.domain.cam.CamParser
|
||||||
import com.hawhamburg.micr0bu.domain.cam.StationType
|
import com.hawhamburg.micr0bu.domain.cam.StationType
|
||||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||||
@@ -88,15 +87,11 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST) or the raw MQTT topic list
|
* 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).
|
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path), or the V2X live map
|
||||||
*
|
* (MAP, Section 13).
|
||||||
* The live map used to be a third mode here. It is now its own full-screen destination
|
|
||||||
* ([V2xMapScreen]), reached from the map button in this screen's header: sharing the screen with
|
|
||||||
* the alert panel and the TX cards left the map about a third of a phone display tall, which is
|
|
||||||
* not enough to see where anything is relative to anything else.
|
|
||||||
*/
|
*/
|
||||||
private enum class TopicViewMode { LIST, TOPICS }
|
private enum class TopicViewMode { LIST, TOPICS, MAP }
|
||||||
|
|
||||||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||||
|
|
||||||
@@ -121,7 +116,6 @@ private val WarningRedBg = Color(0xFF3A0A0A)
|
|||||||
@Composable
|
@Composable
|
||||||
fun MqttTopicViewerScreen(
|
fun MqttTopicViewerScreen(
|
||||||
viewModel: MqttViewModel = hiltViewModel(),
|
viewModel: MqttViewModel = hiltViewModel(),
|
||||||
onOpenMap: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val connectionState by viewModel.connectionState.collectAsState()
|
val connectionState by viewModel.connectionState.collectAsState()
|
||||||
val topicMessages by viewModel.topicMessages.collectAsState()
|
val topicMessages by viewModel.topicMessages.collectAsState()
|
||||||
@@ -136,7 +130,7 @@ fun MqttTopicViewerScreen(
|
|||||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||||
// Engine road users PLUS roadside units - the engine deliberately does not track RSUs.
|
// Engine road users PLUS roadside units - the engine deliberately does not track RSUs.
|
||||||
val remoteCamPositions by viewModel.stationsInRange.collectAsState()
|
val remoteCamPositions by viewModel.stationsInRange.collectAsState()
|
||||||
val esp32LinkState by viewModel.esp32LinkState.collectAsState()
|
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||||
val camPingerActive by viewModel.camPingerActive.collectAsState()
|
val camPingerActive by viewModel.camPingerActive.collectAsState()
|
||||||
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
|
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
|
||||||
val camPingerHasFix by viewModel.camPingerHasFix.collectAsState()
|
val camPingerHasFix by viewModel.camPingerHasFix.collectAsState()
|
||||||
@@ -161,7 +155,7 @@ fun MqttTopicViewerScreen(
|
|||||||
// DISCONNECTED and using it here made the screen report "offline" while CAMs streamed in over
|
// 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
|
// serial. Everything on this screen that means "is the OBU link up?" follows the serial link
|
||||||
// instead when that hardware is selected.
|
// instead when that hardware is selected.
|
||||||
val effectiveState = if (isEsp32) esp32LinkState.asConnectionState() else connectionState
|
val effectiveState = if (isEsp32) usbSerialState.asConnectionState() else connectionState
|
||||||
val isConnected = effectiveState == MqttConnectionState.CONNECTED
|
val isConnected = effectiveState == MqttConnectionState.CONNECTED
|
||||||
val isConnecting = effectiveState == MqttConnectionState.CONNECTING
|
val isConnecting = effectiveState == MqttConnectionState.CONNECTING
|
||||||
|
|
||||||
@@ -199,25 +193,14 @@ fun MqttTopicViewerScreen(
|
|||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full-screen live map. In the header rather than in the view-mode row below, so it
|
|
||||||
// is reachable from the message detail pane too and does not move around as the
|
|
||||||
// available view modes change with the selected hardware.
|
|
||||||
IconButton(onClick = onOpenMap) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Map,
|
|
||||||
contentDescription = stringResource(R.string.v2x_map_title),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ConnectionChip(effectiveState)
|
ConnectionChip(effectiveState)
|
||||||
Spacer(Modifier.width(2.dp))
|
Spacer(Modifier.width(2.dp))
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
// Route to whichever transport this hardware actually uses.
|
// Route to whichever transport this hardware actually uses.
|
||||||
if (isEsp32) {
|
if (isEsp32) {
|
||||||
if (isConnected || isConnecting) viewModel.disconnectEsp32()
|
if (isConnected || isConnecting) viewModel.disconnectUsbSerial()
|
||||||
else viewModel.connectEsp32()
|
else viewModel.connectUsbSerial()
|
||||||
} else {
|
} else {
|
||||||
if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect()
|
if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect()
|
||||||
}
|
}
|
||||||
@@ -253,7 +236,7 @@ fun MqttTopicViewerScreen(
|
|||||||
isEsp32 = isEsp32,
|
isEsp32 = isEsp32,
|
||||||
denmEvents = denmEvents,
|
denmEvents = denmEvents,
|
||||||
spatIntersections = spatIntersections,
|
spatIntersections = spatIntersections,
|
||||||
esp32LinkState = esp32LinkState,
|
usbSerialState = usbSerialState,
|
||||||
camPingerActive = camPingerActive,
|
camPingerActive = camPingerActive,
|
||||||
camPingerSentCount = camPingerSentCount,
|
camPingerSentCount = camPingerSentCount,
|
||||||
camPingerHasFix = camPingerHasFix,
|
camPingerHasFix = camPingerHasFix,
|
||||||
@@ -294,7 +277,7 @@ private fun TopicListPane(
|
|||||||
isEsp32: Boolean = false,
|
isEsp32: Boolean = false,
|
||||||
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
|
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
|
||||||
spatIntersections: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection> = emptyList(),
|
spatIntersections: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection> = emptyList(),
|
||||||
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||||
camPingerActive: Boolean = false,
|
camPingerActive: Boolean = false,
|
||||||
camPingerSentCount: Int = 0,
|
camPingerSentCount: Int = 0,
|
||||||
camPingerHasFix: Boolean = false,
|
camPingerHasFix: Boolean = false,
|
||||||
@@ -338,7 +321,7 @@ private fun TopicListPane(
|
|||||||
// ── CAM Pinger card — ESP32-C5-only manual bench test, mirrors the DENM card above ──
|
// ── CAM Pinger card — ESP32-C5-only manual bench test, mirrors the DENM card above ──
|
||||||
if (showCamPinger) {
|
if (showCamPinger) {
|
||||||
CamPingerCard(
|
CamPingerCard(
|
||||||
usbConnected = esp32LinkState == Esp32LinkState.CONNECTED,
|
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
|
||||||
pingerActive = camPingerActive,
|
pingerActive = camPingerActive,
|
||||||
sentCount = camPingerSentCount,
|
sentCount = camPingerSentCount,
|
||||||
hasFix = camPingerHasFix,
|
hasFix = camPingerHasFix,
|
||||||
@@ -352,15 +335,12 @@ private fun TopicListPane(
|
|||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── List / Topics toggle ──────────────────────────────────────────────
|
// ── List / Topics / Map toggle ────────────────────────────────────────────────────
|
||||||
// Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
|
// Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
|
||||||
// the road users and hazards, not the transport that carried them. The raw MQTT topic
|
// the road users and hazards, not the transport that carried them. The raw MQTT topic
|
||||||
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
|
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
|
||||||
// to", not instead of, the topic list).
|
// 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.
|
||||||
// The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is
|
|
||||||
// permanently empty, and a toggle offering a single choice is just noise.
|
|
||||||
if (!isEsp32) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -370,20 +350,34 @@ private fun TopicListPane(
|
|||||||
selected = viewMode == TopicViewMode.LIST,
|
selected = viewMode == TopicViewMode.LIST,
|
||||||
) { viewMode = TopicViewMode.LIST }
|
) { viewMode = TopicViewMode.LIST }
|
||||||
|
|
||||||
|
if (!isEsp32) {
|
||||||
ViewModeButton(
|
ViewModeButton(
|
||||||
label = stringResource(R.string.mqtt_view_topics),
|
label = stringResource(R.string.mqtt_view_topics),
|
||||||
selected = viewMode == TopicViewMode.TOPICS,
|
selected = viewMode == TopicViewMode.TOPICS,
|
||||||
) { viewMode = TopicViewMode.TOPICS }
|
) { viewMode = TopicViewMode.TOPICS }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ViewModeButton(
|
||||||
|
label = stringResource(R.string.mqtt_view_map),
|
||||||
|
selected = viewMode == TopicViewMode.MAP,
|
||||||
|
) { viewMode = TopicViewMode.MAP }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Decoded traffic / raw topics ──────────────────────────────────────
|
// ── Decoded traffic / raw topics / live map ───────────────────────────
|
||||||
// TOPICS can still be the saved selection from a CiT One session after switching hardware
|
// TOPICS can still be the saved selection from a CiT One session after switching hardware
|
||||||
// to the ESP32-C5, where that button no longer exists - fall back to the decoded list
|
// to the ESP32-C5, where that button no longer exists - fall back to the decoded list
|
||||||
// rather than stranding the user on a pane they can't navigate away from.
|
// rather than stranding the user on a pane they can't navigate away from.
|
||||||
val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
|
val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
|
||||||
|
|
||||||
if (shownMode == TopicViewMode.LIST) {
|
if (shownMode == TopicViewMode.MAP) {
|
||||||
|
V2xLiveMapView(
|
||||||
|
own = ownCamPosition,
|
||||||
|
remotes = remoteCamPositions,
|
||||||
|
alerts = useCaseAlerts,
|
||||||
|
denms = denmEvents,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else if (shownMode == TopicViewMode.LIST) {
|
||||||
ReceivedCamPane(
|
ReceivedCamPane(
|
||||||
own = ownCamPosition,
|
own = ownCamPosition,
|
||||||
remotes = remoteCamPositions,
|
remotes = remoteCamPositions,
|
||||||
@@ -1219,12 +1213,11 @@ private fun CamPingerCard(
|
|||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.mqtt_cam_pinger_fw_counters,
|
R.string.mqtt_cam_pinger_fw_counters,
|
||||||
s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.rxQueueDrops,
|
s.txFailures, s.oversizeDrops, s.rxCrcErrors,
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0 ||
|
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0)
|
||||||
s.rxQueueDrops > 0
|
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1473,10 +1466,10 @@ private fun prettyPrintJson(raw: String): String {
|
|||||||
* connection UI on this screen already speaks, so one indicator can serve both transports rather
|
* 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.
|
* than duplicating the chip and its colours per hardware type.
|
||||||
*/
|
*/
|
||||||
private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
|
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
|
||||||
Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
|
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||||
Esp32LinkState.DEVICE_ATTACHED,
|
UsbSerialState.DEVICE_ATTACHED,
|
||||||
Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||||
Esp32LinkState.ERROR -> MqttConnectionState.ERROR
|
UsbSerialState.ERROR -> MqttConnectionState.ERROR
|
||||||
Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import com.hawhamburg.micr0bu.R
|
import com.hawhamburg.micr0bu.R
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
|
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.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
|
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||||
@@ -167,19 +165,13 @@ fun ConnectionSettingsScreen(
|
|||||||
onMqttPrefsChange: (MqttPrefs) -> Unit,
|
onMqttPrefsChange: (MqttPrefs) -> Unit,
|
||||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||||
onObuHardwareChange: (ObuHardware) -> Unit = {},
|
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,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
SubScreen(stringResource(R.string.settings_connection), onBack) {
|
SubScreen(stringResource(R.string.settings_connection), onBack) {
|
||||||
SectionCard {
|
SectionCard {
|
||||||
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). The transport
|
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). Everything
|
||||||
// cards below apply to the CiT One; the ESP32-C5 has its own card (USB-C or BLE,
|
// below (transport, USB-C options) only really applies to CiT One; ESP32-C5 uses
|
||||||
// CAM or VAM, signing).
|
// USB Serial exclusively and has no transport choice to make here.
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.settings_obu_hardware),
|
stringResource(R.string.settings_obu_hardware),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -220,48 +212,6 @@ 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) {
|
if (obuHardware == ObuHardware.CIT_ONE) {
|
||||||
SectionCard {
|
SectionCard {
|
||||||
// Active transport selector
|
// Active transport selector
|
||||||
@@ -661,40 +611,6 @@ 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
|
@Composable
|
||||||
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
package com.hawhamburg.micr0bu.ui.screens
|
package com.hawhamburg.micr0bu.ui.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.view.MotionEvent
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.GpsOff
|
import androidx.compose.material.icons.filled.GpsOff
|
||||||
@@ -31,37 +30,25 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import com.hawhamburg.micr0bu.R
|
import com.hawhamburg.micr0bu.R
|
||||||
import com.hawhamburg.micr0bu.domain.cam.Cam
|
import com.hawhamburg.micr0bu.domain.cam.Cam
|
||||||
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
|
||||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.CustomZoomButtonsController
|
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.Marker
|
import org.osmdroid.views.overlay.Marker
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the
|
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus
|
||||||
* ego bike's own position, every currently-tracked remote road user's last-known CAM position,
|
* every currently-tracked remote road user's last-known CAM position, in addition to (not
|
||||||
* every live hazard (DENM) and every signalised intersection heard over SPATEM.
|
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as
|
||||||
|
* [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here
|
||||||
|
* always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]
|
||||||
|
* currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc).
|
||||||
*
|
*
|
||||||
* Marker vocabulary, one shape per message type so the map reads without a legend:
|
* Remote markers are colored by that station's most severe active alert level, if any, so a
|
||||||
* - CAM — teardrop pin, tinted by that station's most severe active alert level
|
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same
|
||||||
* - DENM — hazard warning triangle
|
* severity coloring already used by [UseCaseAlertPanel].
|
||||||
* - SPATEM — traffic light, with the lamp for the intersection's leading phase lit
|
|
||||||
*
|
|
||||||
* Unlike [MapScreen] this has no phone-GNSS-only fallback: [own] always reflects whichever ego
|
|
||||||
* source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] currently trusts (obu_gnss /
|
|
||||||
* phone GNSS / CAM-topic-own — see that class's KDoc).
|
|
||||||
*
|
|
||||||
* **Markers are reused across updates, not rebuilt.** CAMs arrive at up to 10 Hz per station, and
|
|
||||||
* every arrival recomposes this view; the previous version cleared the overlay list and rebuilt
|
|
||||||
* every Marker — decoding and mutating a fresh Drawable per marker per update — which is what
|
|
||||||
* made panning stutter under live traffic. Drawables are now loaded once per level/phase and
|
|
||||||
* shared (osmdroid sets the icon's bounds on each draw, so sharing one instance across markers is
|
|
||||||
* safe), and Marker objects are cached by key. The overlay list is still reordered each update,
|
|
||||||
* which costs nothing: it moves existing references, it does not allocate.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun V2xLiveMapView(
|
fun V2xLiveMapView(
|
||||||
@@ -69,9 +56,6 @@ fun V2xLiveMapView(
|
|||||||
remotes: Map<Long, Cam>,
|
remotes: Map<Long, Cam>,
|
||||||
alerts: List<UseCaseAlert>,
|
alerts: List<UseCaseAlert>,
|
||||||
denms: List<DenmEvent> = emptyList(),
|
denms: List<DenmEvent> = emptyList(),
|
||||||
spats: List<SpatIntersection> = emptyList(),
|
|
||||||
followOwn: Boolean = true,
|
|
||||||
onUserPanned: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -87,17 +71,8 @@ fun V2xLiveMapView(
|
|||||||
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
|
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loaded once and shared by every marker that needs them. mutate() on the remote pin is still
|
|
||||||
// essential: without it all four tinted copies would share one ConstantState and the last
|
|
||||||
// tint applied would recolour every pin on the map.
|
|
||||||
val icons = remember(context) { MapIcons(context) }
|
|
||||||
val markers = remember { mutableMapOf<String, Marker>() }
|
|
||||||
|
|
||||||
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
// Tracks whether the last update already recentred for this follow session, so re-enabling
|
|
||||||
// follow animates once instead of fighting the rider's own panning on every frame.
|
|
||||||
val wasFollowing = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
@@ -114,58 +89,42 @@ fun V2xLiveMapView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
initOsmForV2xMap(ctx)
|
initOsmForV2xMap(ctx)
|
||||||
MapView(ctx).apply {
|
MapView(ctx).apply {
|
||||||
setTileSource(TileSourceFactory.MAPNIK)
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
setMultiTouchControls(true)
|
setMultiTouchControls(true)
|
||||||
// Raster tiles are authored for ~160 dpi; without this they are upscaled by the
|
|
||||||
// display density and labels come out soft on a modern phone.
|
|
||||||
isTilesScaledToDpi = true
|
|
||||||
// The floating +/- buttons sit exactly where the rider's thumb lands and
|
|
||||||
// duplicate pinch-zoom. Pinch and double-tap still work.
|
|
||||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
|
||||||
setMinZoomLevel(4.0)
|
|
||||||
setMaxZoomLevel(20.0)
|
|
||||||
controller.setZoom(17.0)
|
controller.setZoom(17.0)
|
||||||
controller.setCenter(ownGeoPoint)
|
controller.setCenter(ownGeoPoint)
|
||||||
// Any touch means the rider is driving the map; follow-own hands over to them
|
|
||||||
// until they ask for it back. false: the MapView's own gesture handling still
|
|
||||||
// runs, this only observes.
|
|
||||||
setOnTouchListener { _, event ->
|
|
||||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) onUserPanned()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
mapViewRef.value = this
|
mapViewRef.value = this
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { mv ->
|
update = { mv ->
|
||||||
val now = System.currentTimeMillis()
|
mv.overlays.clear()
|
||||||
|
|
||||||
// Intersections we can actually place: SPATEM carries signal state but no geometry
|
// Own position: a centred "you are here" dot, not a pin. Own position is a fact
|
||||||
// (that is MAPEM's job), so the only position available is the sending RSU's own CAM.
|
// about the viewer rather than one of the tracked objects, and when both used
|
||||||
val locatedSpats = spats.mapNotNull { spat ->
|
// osmdroid's identical default pin the two were indistinguishable at a glance.
|
||||||
remotes[spat.stationId]?.let { rsu -> spat to rsu }
|
mv.overlays.add(
|
||||||
}
|
Marker(mv).apply {
|
||||||
// An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it:
|
|
||||||
// two markers on one point, the lower one unreachable.
|
|
||||||
val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet()
|
|
||||||
|
|
||||||
val live = mutableSetOf<String>()
|
|
||||||
|
|
||||||
// Own position: a centred "you are here" dot, not a pin. Own position is a fact about
|
|
||||||
// the viewer rather than one of the tracked objects, and when both used osmdroid's
|
|
||||||
// identical default pin the two were indistinguishable at a glance.
|
|
||||||
markers.marker(mv, KEY_OWN, live).apply {
|
|
||||||
position = ownGeoPoint
|
position = ownGeoPoint
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
icon = icons.own
|
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
|
||||||
title = context.getString(R.string.v2x_map_own_label)
|
title = context.getString(R.string.v2x_map_own_label)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
remotes.forEach { (stationId, cam) ->
|
remotes.forEach { (stationId, cam) ->
|
||||||
if (stationId in spatStationIds) return@forEach
|
|
||||||
val level = alertByStation[stationId]
|
val level = alertByStation[stationId]
|
||||||
val label = when (level) {
|
val label = when (level) {
|
||||||
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
|
||||||
@@ -173,23 +132,32 @@ fun V2xLiveMapView(
|
|||||||
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
|
||||||
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
|
||||||
}
|
}
|
||||||
// Teardrop pin anchored at its tip, tinted by severity, so severity no longer
|
// Teardrop pin anchored at its tip, tinted by severity. Now that these are
|
||||||
// depends on tapping the marker to read its label.
|
// custom drawables, per-instance tinting is possible - severity no longer
|
||||||
markers.marker(mv, "$KEY_CAM$stationId", live).apply {
|
// 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)
|
position = GeoPoint(cam.latitude, cam.longitude)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
icon = icons.remotePin(level)
|
icon = pin
|
||||||
title = label
|
title = label
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hazards and signals are added after the vehicle pins, so they draw on top: a hazard
|
// 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.
|
// hidden behind a CAM pin defeats the point of showing it.
|
||||||
denms.forEach { denm ->
|
denms.forEach { denm ->
|
||||||
markers.marker(mv, "$KEY_DENM${denm.dedupKey}", live).apply {
|
mv.overlays.add(
|
||||||
|
Marker(mv).apply {
|
||||||
position = GeoPoint(denm.latitude, denm.longitude)
|
position = GeoPoint(denm.latitude, denm.longitude)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
icon = icons.denm
|
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
|
||||||
title = denm.causeCode?.let {
|
title = denm.causeCode?.let {
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.v2x_map_denm_labeled,
|
R.string.v2x_map_denm_labeled,
|
||||||
@@ -199,45 +167,10 @@ fun V2xLiveMapView(
|
|||||||
)
|
)
|
||||||
} ?: context.getString(R.string.v2x_map_denm_plain, 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) } ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop markers for stations, hazards and intersections that have expired, then rebuild
|
mv.controller.animateTo(ownGeoPoint)
|
||||||
// the overlay list in draw order from the cached Markers. Reordering moves references;
|
|
||||||
// nothing here allocates a Marker or decodes a Drawable.
|
|
||||||
markers.keys.retainAll { key ->
|
|
||||||
(key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() }
|
|
||||||
}
|
|
||||||
mv.overlays.clear()
|
|
||||||
markers.entries
|
|
||||||
.sortedBy { (key, _) -> key.drawOrder() }
|
|
||||||
.forEach { (_, marker) -> mv.overlays.add(marker) }
|
|
||||||
|
|
||||||
// setCenter, not animateTo: an animation restarted on every CAM never finishes, which
|
|
||||||
// is exactly the judder this used to show under live traffic. The one animated move is
|
|
||||||
// the rider re-enabling follow, where the travel is worth seeing.
|
|
||||||
if (followOwn) {
|
|
||||||
if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint)
|
|
||||||
else mv.controller.animateTo(ownGeoPoint)
|
|
||||||
}
|
|
||||||
wasFollowing.value = followOwn
|
|
||||||
|
|
||||||
mv.invalidate()
|
mv.invalidate()
|
||||||
},
|
},
|
||||||
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
// osmdroid's onDetach() permanently tears the MapView down: afterwards its
|
||||||
@@ -253,98 +186,10 @@ fun V2xLiveMapView(
|
|||||||
//
|
//
|
||||||
// onRelease is the callback that actually means "this View is gone": Compose
|
// onRelease is the callback that actually means "this View is gone": Compose
|
||||||
// guarantees no further update after it.
|
// guarantees no further update after it.
|
||||||
onRelease = {
|
onRelease = { it.onDetach() },
|
||||||
markers.clear()
|
modifier = Modifier.fillMaxSize(),
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -383,10 +228,5 @@ private fun initOsmForV2xMap(context: Context) {
|
|||||||
Configuration.getInstance().apply {
|
Configuration.getInstance().apply {
|
||||||
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
|
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
|
||||||
userAgentValue = context.packageName
|
userAgentValue = context.packageName
|
||||||
// Panning off the edge of the cache is what makes a raster map feel slow: the default
|
|
||||||
// 600 MB cap is plenty, but the default 2 download threads are not when a pan exposes a
|
|
||||||
// screenful of new tiles at once.
|
|
||||||
tileDownloadThreads = 6.toShort()
|
|
||||||
tileFileSystemThreads = 6.toShort()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
package com.hawhamburg.micr0bu.ui.screens
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.MyLocation
|
|
||||||
import androidx.compose.material.icons.filled.Place
|
|
||||||
import androidx.compose.material.icons.filled.Traffic
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.hawhamburg.micr0bu.R
|
|
||||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-screen V2X live map — the map and nothing else, reached from the map button on the V2X
|
|
||||||
* Monitor screen.
|
|
||||||
*
|
|
||||||
* Identical on both hardware paths. Everything drawn here comes from
|
|
||||||
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which already merges the CiT One's MQTT
|
|
||||||
* feed and the ESP32-C5's serial feed into one set of flows, so this screen never has to know
|
|
||||||
* which OBU is connected.
|
|
||||||
*
|
|
||||||
* The chrome is deliberately minimal and floats over the map rather than boxing it in: a back
|
|
||||||
* button, a live count per message type, and a recentre button. The counts double as the map's
|
|
||||||
* legend — each one carries the same icon family as the marker it counts.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun V2xMapScreen(
|
|
||||||
viewModel: MqttViewModel,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
|
||||||
// Road users from the detection engine PLUS roadside units, which it deliberately does not
|
|
||||||
// track - an RSU is what carries the traffic lights below.
|
|
||||||
val stations by viewModel.stationsInRange.collectAsState()
|
|
||||||
val alerts by viewModel.useCaseAlerts.collectAsState()
|
|
||||||
val denms by viewModel.denmEvents.collectAsState()
|
|
||||||
val spats by viewModel.spatIntersections.collectAsState()
|
|
||||||
|
|
||||||
// Follow is on until the rider touches the map, and comes back when they ask for it. Without
|
|
||||||
// the hand-over, every incoming CAM would drag the viewport back to the ego position and the
|
|
||||||
// map could not be panned at all while traffic is flowing.
|
|
||||||
var followOwn by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
// SPATEM carries no geometry of its own, so an intersection can only be placed if its RSU has
|
|
||||||
// also been heard over CAM. Saying so is better than silently dropping it: "the map shows two
|
|
||||||
// of the three lights I can see in the list" is otherwise an unexplained discrepancy.
|
|
||||||
val unlocatedSpats = spats.count { it.stationId !in stations.keys }
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
V2xLiveMapView(
|
|
||||||
own = ownCamPosition,
|
|
||||||
remotes = stations,
|
|
||||||
alerts = alerts,
|
|
||||||
denms = denms,
|
|
||||||
spats = spats,
|
|
||||||
followOwn = followOwn,
|
|
||||||
onUserPanned = { followOwn = false },
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Floating header: back + live counts, which double as the legend ──
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
MapChrome {
|
|
||||||
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.v2x_map_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
|
|
||||||
MapChrome {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
MapCount(
|
|
||||||
icon = Icons.Default.Place,
|
|
||||||
tint = CamPinBlue,
|
|
||||||
count = stations.size,
|
|
||||||
label = stringResource(R.string.v2x_map_legend_cam),
|
|
||||||
)
|
|
||||||
MapCount(
|
|
||||||
icon = Icons.Default.Warning,
|
|
||||||
tint = HazardAmber,
|
|
||||||
count = denms.size,
|
|
||||||
label = stringResource(R.string.v2x_map_legend_denm),
|
|
||||||
)
|
|
||||||
MapCount(
|
|
||||||
icon = Icons.Default.Traffic,
|
|
||||||
tint = SignalGreenDot,
|
|
||||||
count = spats.size,
|
|
||||||
label = stringResource(R.string.v2x_map_legend_spat),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unlocatedSpats > 0) {
|
|
||||||
MapChrome(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.padding(12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.v2x_map_spat_unlocated, unlocatedSpats),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recentre: lit while following, so the button also reports which mode the map is in.
|
|
||||||
FloatingActionButton(
|
|
||||||
onClick = { followOwn = true },
|
|
||||||
containerColor = if (followOwn) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(16.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.MyLocation,
|
|
||||||
contentDescription = stringResource(R.string.v2x_map_follow),
|
|
||||||
tint = if (followOwn) MaterialTheme.colorScheme.onPrimary
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A translucent pill for anything floating over the map, so chrome stays readable over tiles. */
|
|
||||||
@Composable
|
|
||||||
private fun MapChrome(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(18.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
|
||||||
tonalElevation = 3.dp,
|
|
||||||
shadowElevation = 2.dp,
|
|
||||||
modifier = modifier,
|
|
||||||
) { content() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MapCount(icon: ImageVector, tint: Color, count: Int, label: String) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(16.dp))
|
|
||||||
Text(
|
|
||||||
text = count.toString(),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legend tints, matching the marker artwork rather than the theme: these name the drawables on
|
|
||||||
// the map, so they must not shift with light/dark mode the way theme colours do.
|
|
||||||
private val CamPinBlue = Color(0xFF78909C)
|
|
||||||
private val HazardAmber = Color(0xFFFFC107)
|
|
||||||
private val SignalGreenDot = Color(0xFF4CAF50)
|
|
||||||
@@ -13,11 +13,8 @@ import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
|||||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
|
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||||
import com.hawhamburg.micr0bu.data.transport.Esp32Link
|
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||||
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.DenmEvent
|
||||||
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
import com.hawhamburg.micr0bu.domain.denm.DenmParser
|
||||||
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
|
||||||
@@ -48,7 +45,7 @@ class MqttViewModel @Inject constructor(
|
|||||||
private val usbDetector: UsbNetworkDetector,
|
private val usbDetector: UsbNetworkDetector,
|
||||||
private val camUseCaseRepository: CamUseCaseRepository,
|
private val camUseCaseRepository: CamUseCaseRepository,
|
||||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||||
private val esp32Link: Esp32Link,
|
private val usbSerialTransport: UsbSerialTransport,
|
||||||
private val camPinger: CamPinger,
|
private val camPinger: CamPinger,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -83,42 +80,14 @@ class MqttViewModel @Inject constructor(
|
|||||||
/** Auto-detected OBU gateway IP on the USB interface. */
|
/** Auto-detected OBU gateway IP on the USB interface. */
|
||||||
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
|
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
|
||||||
|
|
||||||
/** ESP32-C5 link state, over USB or BLE per [esp32Transport] — see [Esp32Link]. */
|
/** ESP32-C5 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */
|
||||||
val esp32LinkState: StateFlow<Esp32LinkState> = esp32Link.state
|
val usbSerialState: StateFlow<UsbSerialState> = usbSerialTransport.state
|
||||||
|
|
||||||
/** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */
|
/** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */
|
||||||
val espLinkStatus: StateFlow<EspLinkStatus?> = esp32Link.linkStatus
|
val espLinkStatus: StateFlow<EspLinkStatus?> = usbSerialTransport.linkStatus
|
||||||
|
|
||||||
/** Non-zero means CAMs are being built and dropped — see [Esp32Link.send]. */
|
/** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */
|
||||||
val camSendFailures: StateFlow<Int> = esp32Link.consecutiveWriteFailures
|
val camSendFailures: StateFlow<Int> = usbSerialTransport.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) ─────────────────────
|
// ── 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-
|
// The ESP32-C5-path equivalent of the CiT One's manual DENM trigger below — a fixed-
|
||||||
@@ -383,10 +352,10 @@ class MqttViewModel @Inject constructor(
|
|||||||
fun connect() = repo.connect()
|
fun connect() = repo.connect()
|
||||||
fun disconnect() = repo.disconnect()
|
fun disconnect() = repo.disconnect()
|
||||||
|
|
||||||
/** Connect/disconnect the ESP32-C5 link (USB or BLE per [esp32Transport]) — separate from
|
/** Connect/disconnect the ESP32-C5 USB-serial link — separate from [connect]/[disconnect],
|
||||||
* [connect]/[disconnect], which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. */
|
* which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. See [ConnectionSetupScreen]. */
|
||||||
fun connectEsp32() = esp32Link.connect()
|
fun connectUsbSerial() = usbSerialTransport.connect()
|
||||||
fun disconnectEsp32() = esp32Link.disconnect()
|
fun disconnectUsbSerial() = usbSerialTransport.disconnect()
|
||||||
|
|
||||||
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
|
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
|
||||||
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
|
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
|
||||||
@@ -420,7 +389,7 @@ class MqttViewModel @Inject constructor(
|
|||||||
super.onCleared()
|
super.onCleared()
|
||||||
repo.disconnect()
|
repo.disconnect()
|
||||||
camPinger.stop()
|
camPinger.stop()
|
||||||
// Deliberately NOT esp32Link.disconnect(): the link is an app-scoped
|
// Deliberately NOT usbSerialTransport.disconnect(): the transport is an app-scoped
|
||||||
// @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop).
|
// @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
|
// 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
|
// the app from Recents mid-recording — leaving the still-running service beaconing into
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<!--
|
|
||||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
|
||||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
|
||||||
|
|
||||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
|
||||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
|
||||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
|
||||||
|
|
||||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
|
||||||
than covering it.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="30dp"
|
|
||||||
android:height="30dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
|
|
||||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
|
||||||
|
|
||||||
<!-- Housing. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF263238"
|
|
||||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
|
||||||
|
|
||||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF5A2220"
|
|
||||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFC107"
|
|
||||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF1E4D2B"
|
|
||||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<!--
|
|
||||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
|
||||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
|
||||||
|
|
||||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
|
||||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
|
||||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
|
||||||
|
|
||||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
|
||||||
than covering it.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="30dp"
|
|
||||||
android:height="30dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
|
|
||||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
|
||||||
|
|
||||||
<!-- Housing. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF263238"
|
|
||||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
|
||||||
|
|
||||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF5A2220"
|
|
||||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF4A2E1C"
|
|
||||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF1E4D2B"
|
|
||||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<!--
|
|
||||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
|
||||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
|
||||||
|
|
||||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
|
||||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
|
||||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
|
||||||
|
|
||||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
|
||||||
than covering it.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="30dp"
|
|
||||||
android:height="30dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
|
|
||||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
|
||||||
|
|
||||||
<!-- Housing. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF263238"
|
|
||||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
|
||||||
|
|
||||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF5A2220"
|
|
||||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF4A2E1C"
|
|
||||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF4CAF50"
|
|
||||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<!--
|
|
||||||
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
|
|
||||||
the lamp for the intersection's leading phase lit and the other two dimmed.
|
|
||||||
|
|
||||||
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
|
|
||||||
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
|
|
||||||
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
|
|
||||||
|
|
||||||
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
|
|
||||||
than covering it.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="30dp"
|
|
||||||
android:height="30dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
|
|
||||||
<!-- White outline first, so the marker stays legible over dark map features. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFFFF"
|
|
||||||
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
|
|
||||||
|
|
||||||
<!-- Housing. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF263238"
|
|
||||||
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
|
|
||||||
|
|
||||||
<!-- Lamps, top to bottom: red, amber, green. -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFF5252"
|
|
||||||
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF4A2E1C"
|
|
||||||
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF1E4D2B"
|
|
||||||
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
|
|
||||||
</vector>
|
|
||||||
@@ -142,18 +142,12 @@
|
|||||||
<string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
|
<string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
|
||||||
<string name="map_title">Standortkarte</string>
|
<string name="map_title">Standortkarte</string>
|
||||||
<string name="map_location_label">Aktueller Standort</string>
|
<string name="map_location_label">Aktueller Standort</string>
|
||||||
|
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
|
||||||
<string name="v2x_map_own_label">Eigen (Ego)</string>
|
<string name="v2x_map_own_label">Eigen (Ego)</string>
|
||||||
<string name="v2x_map_remote_plain">Extern #%1$d</string>
|
<string name="v2x_map_remote_plain">Extern #%1$d</string>
|
||||||
<string name="v2x_map_remote_info">Extern #%1$d · Info</string>
|
<string name="v2x_map_remote_info">Extern #%1$d · Info</string>
|
||||||
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
|
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
|
||||||
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
|
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
|
||||||
<string name="v2x_map_title">V2X-Live-Karte</string>
|
|
||||||
<string name="v2x_map_back">Zurück</string>
|
|
||||||
<string name="v2x_map_follow">Auf eigene Position zentrieren</string>
|
|
||||||
<string name="v2x_map_legend_cam">Verkehrsteilnehmer (CAM)</string>
|
|
||||||
<string name="v2x_map_legend_denm">Gefahren (DENM)</string>
|
|
||||||
<string name="v2x_map_legend_spat">Signale (SPATEM)</string>
|
|
||||||
<string name="v2x_map_spat_unlocated">%1$d Signal(e) nicht dargestellt - Senderposition unbekannt</string>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<string name="settings_title">Einstellungen</string>
|
<string name="settings_title">Einstellungen</string>
|
||||||
@@ -189,7 +183,7 @@
|
|||||||
<string name="settings_obu_hardware">OBU-Hardware</string>
|
<string name="settings_obu_hardware">OBU-Hardware</string>
|
||||||
<string name="settings_obu_hardware_cit_one">CiT One</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">ESP32-C5</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_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_usb_transport">Aktiver Transport</string>
|
<string name="settings_usb_transport">Aktiver Transport</string>
|
||||||
<string name="settings_transport_usbc">USB-C</string>
|
<string name="settings_transport_usbc">USB-C</string>
|
||||||
<string name="settings_transport_wifi">WLAN</string>
|
<string name="settings_transport_wifi">WLAN</string>
|
||||||
@@ -203,6 +197,7 @@
|
|||||||
<string name="mqtt_no_topics">Noch keine Nachrichten</string>
|
<string name="mqtt_no_topics">Noch keine Nachrichten</string>
|
||||||
<string name="mqtt_view_list">Liste</string>
|
<string name="mqtt_view_list">Liste</string>
|
||||||
<string name="mqtt_view_topics">Topics</string>
|
<string name="mqtt_view_topics">Topics</string>
|
||||||
|
<string name="mqtt_view_map">Karte</string>
|
||||||
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
|
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
|
||||||
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
|
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
|
||||||
|
|
||||||
@@ -318,18 +313,4 @@
|
|||||||
<string name="v2x_spat_rx_title">Kreuzung %1$s · Station %2$d</string>
|
<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_group">SG%1$d</string>
|
||||||
<string name="v2x_spat_countdown">%1$.0f s</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>
|
</resources>
|
||||||
|
|||||||
@@ -143,18 +143,12 @@
|
|||||||
<string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
|
<string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
|
||||||
<string name="map_title">Location Map</string>
|
<string name="map_title">Location Map</string>
|
||||||
<string name="map_location_label">Current Location</string>
|
<string name="map_location_label">Current Location</string>
|
||||||
|
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
|
||||||
<string name="v2x_map_own_label">Own (ego)</string>
|
<string name="v2x_map_own_label">Own (ego)</string>
|
||||||
<string name="v2x_map_remote_plain">Remote #%1$d</string>
|
<string name="v2x_map_remote_plain">Remote #%1$d</string>
|
||||||
<string name="v2x_map_remote_info">Remote #%1$d · Info</string>
|
<string name="v2x_map_remote_info">Remote #%1$d · Info</string>
|
||||||
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
|
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
|
||||||
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
|
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
|
||||||
<string name="v2x_map_title">V2X Live Map</string>
|
|
||||||
<string name="v2x_map_back">Back</string>
|
|
||||||
<string name="v2x_map_follow">Centre on own position</string>
|
|
||||||
<string name="v2x_map_legend_cam">Road users (CAM)</string>
|
|
||||||
<string name="v2x_map_legend_denm">Hazards (DENM)</string>
|
|
||||||
<string name="v2x_map_legend_spat">Signals (SPATEM)</string>
|
|
||||||
<string name="v2x_map_spat_unlocated">%1$d signal(s) not shown - sender position unknown</string>
|
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
@@ -190,7 +184,7 @@
|
|||||||
<string name="settings_obu_hardware">OBU Hardware</string>
|
<string name="settings_obu_hardware">OBU Hardware</string>
|
||||||
<string name="settings_obu_hardware_cit_one">CiT One</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">ESP32-C5</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_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_usb_transport">Active transport</string>
|
<string name="settings_usb_transport">Active transport</string>
|
||||||
<string name="settings_transport_usbc">USB-C</string>
|
<string name="settings_transport_usbc">USB-C</string>
|
||||||
<string name="settings_transport_wifi">Wi-Fi</string>
|
<string name="settings_transport_wifi">Wi-Fi</string>
|
||||||
@@ -204,6 +198,7 @@
|
|||||||
<string name="mqtt_no_topics">No messages yet</string>
|
<string name="mqtt_no_topics">No messages yet</string>
|
||||||
<string name="mqtt_view_list">List</string>
|
<string name="mqtt_view_list">List</string>
|
||||||
<string name="mqtt_view_topics">Topics</string>
|
<string name="mqtt_view_topics">Topics</string>
|
||||||
|
<string name="mqtt_view_map">Map</string>
|
||||||
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
|
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
|
||||||
<string name="mqtt_no_messages">No messages on this topic yet</string>
|
<string name="mqtt_no_messages">No messages on this topic yet</string>
|
||||||
|
|
||||||
@@ -264,7 +259,7 @@
|
|||||||
<string name="mqtt_cam_pinger_active">Pinging - 1 CAM/s over the serial link</string>
|
<string name="mqtt_cam_pinger_active">Pinging - 1 CAM/s over the serial link</string>
|
||||||
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
|
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
|
||||||
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string>
|
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string>
|
||||||
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d · rx queue drop %4$d</string>
|
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d</string>
|
||||||
<string name="mqtt_cam_pinger_loopback">Own TX heard back: %1$d frames · %2$d dBm</string>
|
<string name="mqtt_cam_pinger_loopback">Own TX heard back: %1$d frames · %2$d dBm</string>
|
||||||
<string name="mqtt_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string>
|
<string name="mqtt_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string>
|
||||||
<string name="mqtt_start_pinger">Start Pinger</string>
|
<string name="mqtt_start_pinger">Start Pinger</string>
|
||||||
@@ -325,18 +320,4 @@
|
|||||||
|
|
||||||
<!-- Phase A: Trip Review screen -->
|
<!-- Phase A: Trip Review screen -->
|
||||||
<string name="trip_review_title">Trip Review</string>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -166,21 +166,6 @@ class CamTxPvSerialTest {
|
|||||||
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
|
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `firmware that predates the rx queue drop counter reports zero`() {
|
|
||||||
// 8-byte heartbeat (status + counters + capabilities, no rx queue drops tail).
|
|
||||||
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
|
|
||||||
assertEquals(0, status.rxQueueDrops)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rx queue drops are read little-endian from the 10-byte payload`() {
|
|
||||||
// status=0, oversize=0, txFail=0, rxCrc=0, capabilities=0x01, rxQueueDrops=0x0102 (LE: 02 01)
|
|
||||||
val status = EspLinkStatus.parse("00000000000000010201".hexToBytes())!!
|
|
||||||
assertEquals(0x0102, status.rxQueueDrops)
|
|
||||||
assertTrue(status.supportsCamTxPv)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val mac = "024d49435230".hexToBytes()
|
private val mac = "024d49435230".hexToBytes()
|
||||||
|
|
||||||
private fun vectorAt(tstMs: Long) = GnPositionVector(
|
private fun vectorAt(tstMs: Long) = GnPositionVector(
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Sniffer board and capture tooling
|
||||||
|
|
||||||
|
How to put an ESP32-C5 on the ITS-G5 channel as a passive sniffer, pull its captures onto this
|
||||||
|
PC, and check what is on air. The sniffer firmware itself is the third-party
|
||||||
|
`its-g5-receiver-firmware` checkout beside this repo; only the tooling and these notes are ours.
|
||||||
|
|
||||||
|
| File | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `live_capture.py` | Streams the device's captures into a growing `.pcap` while it runs. The usual choice. |
|
||||||
|
| `dump_pcap.py` | Pulls one capture out of the device's in-memory buffer after the fact. |
|
||||||
|
| `../obu-firmware/test/pcap_gn_tally.py` | Tallies GeoNetworking headers per station over a `.pcap`. |
|
||||||
|
|
||||||
|
One-time: `pip install pyserial` (present in Python 3.11 on the bench PC, so `py -3.11` works).
|
||||||
|
|
||||||
|
## Which port
|
||||||
|
|
||||||
|
The sniffer firmware's console, and with it the pcap stream, goes out **UART0** - the board's
|
||||||
|
USB-bridge port (a CH343, its own COM number), not the native USB-C port. A board with only one
|
||||||
|
USB-C port cannot be used as a sniffer for this reason. On the bench this has been COM5 and, after
|
||||||
|
a re-enumeration, COM8.
|
||||||
|
|
||||||
|
## Live capture (preferred)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd capture
|
||||||
|
py -3.11 live_capture.py COM8
|
||||||
|
```
|
||||||
|
|
||||||
|
It writes `recordings/capture_<timestamp>.pcap` next to itself, flushing after every packet, so
|
||||||
|
the file can be read while it grows. Stop it with Ctrl+C. Use `-o <dir>` to write elsewhere;
|
||||||
|
`recordings/` is gitignored, since captures are large and are data rather than source. Captures
|
||||||
|
taken before 2026-09-14 are still in `its-g5-receiver-firmware/recordings/`; the host tests read
|
||||||
|
both directories.
|
||||||
|
|
||||||
|
### The CR insertion, and why captures used to be corrupt
|
||||||
|
|
||||||
|
ESP-IDF's newlib console converts LF to CRLF on its way out, and that applies to every `0x0a` byte
|
||||||
|
of the **binary** pcap stream, not only to log text. Each inserted CR shifts everything after it,
|
||||||
|
so pcap record headers and captured frames alike come out corrupt, and the file stops being
|
||||||
|
parseable at the first occurrence.
|
||||||
|
|
||||||
|
Measured on 2026-09-14: a 787 KB capture parsed cleanly for only 82 of about 2000 records, and
|
||||||
|
DENMs appeared on nonsense BTP ports because their payloads contain `0x0a` often. `undo_crlf()` in
|
||||||
|
`live_capture.py` reverses it on the raw stream before any framing, which is exact; afterwards a
|
||||||
|
capture parsed to EOF and DENMs read as port 2002 again.
|
||||||
|
|
||||||
|
**Captures taken before 2026-09-14 are truncated at their first corrupted record.** Anything
|
||||||
|
measured from them is worth re-checking. `dump_pcap.py` reads the same console and has not been
|
||||||
|
given the same treatment yet.
|
||||||
|
|
||||||
|
## Checking a capture
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||||
|
```
|
||||||
|
|
||||||
|
One row per station, packet type, BTP port and GN lifetime. For the messages themselves, decode
|
||||||
|
the payloads with `asn1tools` against the modules in `../asn1/` and re-encode them: identical bytes
|
||||||
|
mean the message was read exactly, wrong bytes mean it was not. `obu-firmware/test/check_replay.py`
|
||||||
|
does this over a whole capture.
|
||||||
|
|
||||||
|
## Flashing the sniffer firmware
|
||||||
|
|
||||||
|
From the receiver checkout, with its **pinned** ESP-IDF (not the global 5.5.4 install):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd its-g5-receiver-firmware
|
||||||
|
git submodule update --init --recursive
|
||||||
|
.\esp-idf\install.bat
|
||||||
|
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||||
|
.\esp-idf\export.ps1
|
||||||
|
idf.py set-target esp32c5
|
||||||
|
idf.py -p COM8 -b 921600 flash
|
||||||
|
```
|
||||||
|
|
||||||
|
Its `sdkconfig` for a bare board (nothing wired) needs SPI Ethernet off, and the pcap destination
|
||||||
|
set to Memory, both under `idf.py menuconfig`. Wired variants have ready-made configs in that
|
||||||
|
checkout: `sdkconfig.proto-w5500`, `sdkconfig.proto-enc28j60`, `sdkconfig.proto-spi-eppp`.
|
||||||
|
|
||||||
|
To reflash a board that already has a built image, without a toolchain terminal:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd its-g5-receiver-firmware\build
|
||||||
|
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM8 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x1e000 ota_data_initial.bin 0x20000 its-g5-receiver-firmware.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pulling a capture after the fact
|
||||||
|
|
||||||
|
Only for the Memory destination, and the buffer is small (`SNIFFER_PCAP_MEMORY_SIZE`, 4096 bytes
|
||||||
|
by default) - a smoke test, not a session. In the device console (`idf.py -p COM8 monitor`, exit
|
||||||
|
with Ctrl+T then Ctrl+X):
|
||||||
|
|
||||||
|
```
|
||||||
|
sniffer -P
|
||||||
|
sniffer --stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, with the port free:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.11 dump_pcap.py COM8
|
||||||
|
```
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pulls a capture off the ITS-G5 receiver's in-memory pcap buffer over the existing USB serial
|
||||||
|
connection and saves it as a real .pcap file on this machine.
|
||||||
|
|
||||||
|
Requires the firmware to be built with:
|
||||||
|
Example Configuration -> Select destination to store pcap file -> Memory
|
||||||
|
|
||||||
|
Usage (typical):
|
||||||
|
1. Close idf.py monitor (only one program can hold the COM port at a time).
|
||||||
|
2. Run a capture on the device: `sniffer -P` ... let it run ... `sniffer --stop`
|
||||||
|
3. python dump_pcap.py COM5
|
||||||
|
|
||||||
|
The device has no access to this computer's filesystem, so it can't write here directly. Instead,
|
||||||
|
`pcap --dump` streams the raw pcap bytes back over the same serial link, wrapped in plain-text
|
||||||
|
markers ("===PCAP-DUMP-START:<len>===" ... raw bytes ... "===PCAP-DUMP-END==="). This script finds
|
||||||
|
those markers and writes just the raw bytes out as a .pcap file.
|
||||||
|
|
||||||
|
Install dependency once: pip install pyserial
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
START_RE = re.compile(rb"===PCAP-DUMP-START:(\d+)===\n")
|
||||||
|
END_MARKER = b"\n===PCAP-DUMP-END===\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||||
|
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||||
|
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||||
|
parser.add_argument("-t", "--timeout", type=float, default=15.0, help="Seconds to wait for the dump to start")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
import os
|
||||||
|
os.makedirs(args.outdir, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Opening {args.port} @ {args.baud}...")
|
||||||
|
with serial.Serial(args.port, args.baud, timeout=1) as ser:
|
||||||
|
# Nudge the console in case there's stale input, then request the dump.
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
ser.write(b"\r\n")
|
||||||
|
ser.write(b"pcap -f dump --dump\r\n")
|
||||||
|
|
||||||
|
print("Waiting for dump to start...")
|
||||||
|
buf = b""
|
||||||
|
match = None
|
||||||
|
deadline = datetime.datetime.now() + datetime.timedelta(seconds=args.timeout)
|
||||||
|
while datetime.datetime.now() < deadline:
|
||||||
|
chunk = ser.read(256)
|
||||||
|
if chunk:
|
||||||
|
buf += chunk
|
||||||
|
match = START_RE.search(buf)
|
||||||
|
if match:
|
||||||
|
break
|
||||||
|
if not match:
|
||||||
|
print("Timed out waiting for '===PCAP-DUMP-START:...===' marker.\n"
|
||||||
|
"Check that: the firmware is built with the Memory pcap destination, a capture was\n"
|
||||||
|
"actually taken ('sniffer -P' then 'sniffer --stop'), and no other program (like\n"
|
||||||
|
"idf.py monitor) is holding the serial port open.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
length = int(match.group(1))
|
||||||
|
print(f"Dump starting, {length} bytes expected.")
|
||||||
|
|
||||||
|
# Anything after the marker in our buffer is already part of the payload.
|
||||||
|
payload = buf[match.end():]
|
||||||
|
remaining = length - len(payload)
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = ser.read(min(remaining, 4096))
|
||||||
|
if not chunk:
|
||||||
|
print(f"Serial read timed out with {remaining} bytes still missing.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
payload += chunk
|
||||||
|
remaining -= len(chunk)
|
||||||
|
|
||||||
|
# Drain (and sanity-check) the trailing end marker, but don't fail hard if it's not exact.
|
||||||
|
tail = ser.read(len(END_MARKER))
|
||||||
|
if tail != END_MARKER:
|
||||||
|
print("Warning: end marker didn't match exactly - payload may still be fine.", file=sys.stderr)
|
||||||
|
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||||
|
with open(outpath, "wb") as f:
|
||||||
|
f.write(payload)
|
||||||
|
|
||||||
|
print(f"Saved {len(payload)} bytes to {outpath}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Continuously listens on the ITS-G5 receiver's serial console and writes every captured packet into a
|
||||||
|
live-growing .pcap file, with no console commands needed on the device side.
|
||||||
|
|
||||||
|
The firmware streams every packet it captures out over the same serial connection the console runs on,
|
||||||
|
automatically, as soon as the sniffer is running (which happens on boot by default). Each packet is framed
|
||||||
|
with plain-text markers so this script can pull the binary pcap bytes out of the stream even though
|
||||||
|
regular log lines are interleaved with it:
|
||||||
|
|
||||||
|
===PCAP-LIVE-HEADER:<len>===\\n<24 raw bytes>\\n (sent once, the pcap global header)
|
||||||
|
===PCAP-LIVE-PKT:<len>===\\n<raw bytes>\\n (sent once per captured packet)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python live_capture.py COM5
|
||||||
|
|
||||||
|
Runs until you press Ctrl+C. Writes to recordings/capture_<timestamp>.pcap, flushing after every packet
|
||||||
|
so you can open the file in Wireshark while it's still being written (use "File > Open" again, or
|
||||||
|
Wireshark's own "Follow" won't auto-refresh but re-opening will show the latest packets).
|
||||||
|
|
||||||
|
Install dependency once: pip install pyserial
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# \r? because the ESP console emits CRLF: on Windows the markers arrive as
|
||||||
|
# "===PCAP-LIVE-PKT:310===\r\n", which never matched a bare \n and left the capture silently
|
||||||
|
# empty while the device was streaming perfectly well.
|
||||||
|
HEADER_RE = re.compile(rb"===PCAP-LIVE-HEADER:(\d+)===\r?\n")
|
||||||
|
PKT_RE = re.compile(rb"===PCAP-LIVE-PKT:(\d+)===\r?\n")
|
||||||
|
|
||||||
|
LINKTYPE_ETHERNET = 1
|
||||||
|
LINKTYPE_IEEE802_11_RADIOTAP = 127
|
||||||
|
|
||||||
|
|
||||||
|
def mac_str(b):
|
||||||
|
return ":".join(f"{x:02x}" for x in b)
|
||||||
|
|
||||||
|
|
||||||
|
def build_default_pcap_header(link_type):
|
||||||
|
"""Synthesizes the same 24-byte global pcap header the firmware would have sent, for when we
|
||||||
|
connect after the device's one-time header already went out (see the race note in main())."""
|
||||||
|
header = bytearray(24)
|
||||||
|
header[0:4] = bytes([0xD4, 0xC3, 0xB2, 0xA1]) # magic (LE bytes of 0xA1B2C3D4)
|
||||||
|
header[4:6] = (2).to_bytes(2, "little") # major version
|
||||||
|
header[6:8] = (4).to_bytes(2, "little") # minor version
|
||||||
|
header[16:20] = (0x40000).to_bytes(4, "little") # snaplen
|
||||||
|
header[20:24] = link_type.to_bytes(4, "little")
|
||||||
|
return bytes(header)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_packet(link_type, record_bytes, index):
|
||||||
|
"""Best-effort human-readable one-line summary of a captured packet, for live feedback.
|
||||||
|
record_bytes is the raw 16-byte pcap record header followed by the captured frame."""
|
||||||
|
seconds = int.from_bytes(record_bytes[0:4], "little")
|
||||||
|
microseconds = int.from_bytes(record_bytes[4:8], "little")
|
||||||
|
cap_len = int.from_bytes(record_bytes[8:12], "little")
|
||||||
|
frame = record_bytes[16:]
|
||||||
|
ts = f"{seconds}.{microseconds:06d}"
|
||||||
|
|
||||||
|
if link_type == LINKTYPE_IEEE802_11_RADIOTAP and len(frame) >= 24:
|
||||||
|
radiotap_len = int.from_bytes(frame[2:4], "little")
|
||||||
|
rssi = frame[8] - 256 if frame[8] >= 128 else frame[8]
|
||||||
|
station_id = int.from_bytes(frame[16:24], "little")
|
||||||
|
mac_frame = frame[radiotap_len:]
|
||||||
|
if len(mac_frame) >= 16:
|
||||||
|
dst = mac_str(mac_frame[4:10])
|
||||||
|
src = mac_str(mac_frame[10:16])
|
||||||
|
else:
|
||||||
|
dst = src = "?"
|
||||||
|
station = f"{station_id:012x}" if station_id else "unknown"
|
||||||
|
return (f"#{index:<5} [{ts}] len={cap_len:<5} rssi={rssi:>4}dBm "
|
||||||
|
f"station={station} {src} -> {dst}")
|
||||||
|
|
||||||
|
if link_type == LINKTYPE_ETHERNET and len(frame) >= 14:
|
||||||
|
dst = mac_str(frame[0:6])
|
||||||
|
src = mac_str(frame[6:12])
|
||||||
|
ethertype = int.from_bytes(frame[12:14], "big")
|
||||||
|
return f"#{index:<5} [{ts}] len={cap_len:<5} eth {src} -> {dst} type=0x{ethertype:04x}"
|
||||||
|
|
||||||
|
return f"#{index:<5} [{ts}] len={cap_len:<5} (unrecognized frame format)"
|
||||||
|
|
||||||
|
|
||||||
|
def undo_crlf(chunk, state):
|
||||||
|
"""Undo the CR the device console inserts before every LF.
|
||||||
|
|
||||||
|
ESP-IDF's newlib console converts LF to CRLF on its way out, and that happens to every 0x0A
|
||||||
|
byte of the binary pcap stream too, not only to log text. Each inserted CR shifts everything
|
||||||
|
after it, so pcap record headers and captured frames alike come out corrupt. This is the
|
||||||
|
"byte inserted mid-frame" seen in older recordings; with DENM traffic on air it wrecks most
|
||||||
|
of a capture (measured 2026-09-14: a 787 KB file parsed cleanly for only 82 records).
|
||||||
|
|
||||||
|
Dropping one CR immediately before each LF undoes it exactly, provided it is done on the raw
|
||||||
|
stream before any framing and a trailing CR is carried across read boundaries. CR and LF are
|
||||||
|
written as byte values here so the transformation cannot be confused with an escape.
|
||||||
|
"""
|
||||||
|
CR, LF = bytes([13]), bytes([10])
|
||||||
|
if state["pending_cr"]:
|
||||||
|
chunk = CR + chunk
|
||||||
|
state["pending_cr"] = False
|
||||||
|
if chunk.endswith(CR):
|
||||||
|
chunk = chunk[:-1]
|
||||||
|
state["pending_cr"] = True
|
||||||
|
return chunk.replace(CR + LF, LF)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
|
||||||
|
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
|
||||||
|
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.makedirs(args.outdir, exist_ok=True)
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
|
||||||
|
|
||||||
|
print(f"Opening {args.port} @ {args.baud}...")
|
||||||
|
print(f"Writing live capture to {outpath}")
|
||||||
|
print("Press Ctrl+C to stop.")
|
||||||
|
|
||||||
|
header_written = False
|
||||||
|
link_type = None
|
||||||
|
packet_count = 0
|
||||||
|
buf = b""
|
||||||
|
total_bytes = 0
|
||||||
|
last_status = time.monotonic()
|
||||||
|
printed_raw_preview = False
|
||||||
|
|
||||||
|
crlf_state = {"pending_cr": False}
|
||||||
|
|
||||||
|
with serial.Serial(args.port, args.baud, timeout=1) as ser, open(outpath, "wb") as outfile:
|
||||||
|
def read_bytes(n):
|
||||||
|
return undo_crlf(ser.read(n), crlf_state)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = read_bytes(256)
|
||||||
|
if chunk:
|
||||||
|
buf += chunk
|
||||||
|
total_bytes += len(chunk)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_status >= 2:
|
||||||
|
last_status = now
|
||||||
|
print(f"[diagnostic] {total_bytes} raw bytes received so far, "
|
||||||
|
f"{packet_count} packets recognized, header_written={header_written}")
|
||||||
|
if total_bytes > 0 and not printed_raw_preview and not header_written and not PKT_RE.search(buf):
|
||||||
|
# We're getting bytes but none of them look like our markers - show a preview
|
||||||
|
# so we can tell whether this is plain log text (markers just haven't shown up
|
||||||
|
# yet), garbage (baud/port mismatch), or something else entirely.
|
||||||
|
preview = buf[:200]
|
||||||
|
print(f"[diagnostic] no markers matched yet - raw preview: {preview!r}")
|
||||||
|
printed_raw_preview = True
|
||||||
|
elif total_bytes == 0:
|
||||||
|
print("[diagnostic] zero bytes received from the port at all - this points at "
|
||||||
|
"the wrong COM port, another program holding the port, or a port that "
|
||||||
|
"isn't actually wired to the console/sniffer output.")
|
||||||
|
|
||||||
|
# The device only sends the global header once, right when the sniffer first starts
|
||||||
|
# (typically within a second or two of boot). If this script connects even slightly
|
||||||
|
# late - very likely right after a fresh flash, since esptool itself resets the board -
|
||||||
|
# that header is already gone before we ever see it. Rather than blocking forever
|
||||||
|
# waiting for a header that's never coming, look for whichever marker shows up first.
|
||||||
|
header_match = None if header_written else HEADER_RE.search(buf)
|
||||||
|
pkt_match = PKT_RE.search(buf)
|
||||||
|
|
||||||
|
if header_match and (not pkt_match or header_match.start() < pkt_match.start()):
|
||||||
|
length = int(header_match.group(1))
|
||||||
|
buf = buf[header_match.end():]
|
||||||
|
while len(buf) < length:
|
||||||
|
buf += read_bytes(length - len(buf))
|
||||||
|
header_bytes = buf[:length]
|
||||||
|
outfile.write(header_bytes)
|
||||||
|
outfile.flush()
|
||||||
|
buf = buf[length:]
|
||||||
|
header_written = True
|
||||||
|
if length >= 24:
|
||||||
|
link_type = int.from_bytes(header_bytes[20:24], "little")
|
||||||
|
print(f"Got pcap global header (link type {link_type}) - device is streaming.\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not header_written and pkt_match:
|
||||||
|
link_type = LINKTYPE_IEEE802_11_RADIOTAP
|
||||||
|
outfile.write(build_default_pcap_header(link_type))
|
||||||
|
outfile.flush()
|
||||||
|
header_written = True
|
||||||
|
print("Note: missed the device's one-time pcap header (it was likely sent before "
|
||||||
|
"this script connected, e.g. right after a flash/reset) - assuming WLAN "
|
||||||
|
"radiotap capture and writing a default header instead.\n")
|
||||||
|
# fall through and process pkt_match below, don't discard this packet
|
||||||
|
|
||||||
|
if not pkt_match:
|
||||||
|
# Keep the buffer from growing unbounded while waiting for a marker, but don't
|
||||||
|
# discard anything - a marker could be split across reads.
|
||||||
|
if len(buf) > 65536:
|
||||||
|
buf = buf[-4096:]
|
||||||
|
continue
|
||||||
|
|
||||||
|
length = int(pkt_match.group(1))
|
||||||
|
buf = buf[pkt_match.end():]
|
||||||
|
while len(buf) < length:
|
||||||
|
buf += read_bytes(length - len(buf))
|
||||||
|
record_bytes = buf[:length]
|
||||||
|
outfile.write(record_bytes)
|
||||||
|
outfile.flush()
|
||||||
|
buf = buf[length:]
|
||||||
|
packet_count += 1
|
||||||
|
print(summarize_packet(link_type, record_bytes, packet_count))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\nStopped. {packet_count} packets saved to {outpath}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# 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` (the colleague's repository, kept beside
|
|
||||||
this one, gitignored). vanetza-idf is taken from `microbu-esp32c5/external/vanetza-idf`. 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 (`microbu-esp32c5/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.
|
|
||||||
+50
-123
@@ -1,7 +1,7 @@
|
|||||||
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||||
|
|
||||||
Build and flash steps are under "Build and flash" below. (`docs/04-transmit-setup.md`,
|
Started. See `docs/04-transmit-setup.md` in the project root for build/flash
|
||||||
referenced here and in the sources, is not in the repo.)
|
steps and how to validate this against your own sniffer.
|
||||||
|
|
||||||
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4)
|
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4)
|
||||||
|
|
||||||
@@ -29,123 +29,6 @@ referenced an older source path under `micrOBU_workspace/v2x-obu-esp32c5/`,
|
|||||||
which makes `idf.py fullclean` refuse to run). If that error reappears, delete
|
which makes `idf.py fullclean` refuse to run). If that error reappears, delete
|
||||||
`build/` manually rather than fighting it.
|
`build/` manually rather than fighting it.
|
||||||
|
|
||||||
## Build and flash
|
|
||||||
|
|
||||||
The firmware needs no button, phone or serial connection to start. On every power-up or reset,
|
|
||||||
`app_main` sets up the radio and starts `tx_task`, which starts driving the simulated route and
|
|
||||||
sending CAMs on 5900 MHz. A board flashed with this image starts beaconing on its own as soon as it
|
|
||||||
gets power.
|
|
||||||
|
|
||||||
In a fresh PowerShell window:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null
|
|
||||||
. C:\Espressif\frameworks\esp-idf-v5.5.4\export.ps1
|
|
||||||
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
|
||||||
```
|
|
||||||
|
|
||||||
1. **Check what the board is running before you flash it.** Both this project and `obu-firmware`
|
|
||||||
name their image `obu_firmware.bin`, so the file name tells you nothing. Read the app
|
|
||||||
descriptor instead (replace `COMx` with the board's port):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python -m esptool --chip esp32c5 -p COMx read_flash 0x10000 0x100 $env:TEMP\desc.bin
|
|
||||||
$b = [IO.File]::ReadAllBytes("$env:TEMP\desc.bin")
|
|
||||||
function S($o,$n){ [Text.Encoding]::ASCII.GetString($b,$o,$n).Trim([char]0) }
|
|
||||||
"time=" + (S 0x70 16) + " date=" + (S 0x80 16) + " idf=" + (S 0x90 32)
|
|
||||||
```
|
|
||||||
|
|
||||||
`idf=v6.1...` means the board runs the production OBU (`obu-firmware`). Don't flash this beacon
|
|
||||||
over it. `idf=v5.5.4` means this transmitter, or another bench image.
|
|
||||||
|
|
||||||
2. **Build:**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
idf.py build
|
|
||||||
```
|
|
||||||
|
|
||||||
A rebuild after small changes takes about 1-2 minutes. The output is
|
|
||||||
`build\obu_firmware.bin`.
|
|
||||||
|
|
||||||
3. **Flash** (the board must be on its UART bridge port or its native USB port):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
idf.py -p COMx flash
|
|
||||||
```
|
|
||||||
|
|
||||||
The flash is good when esptool prints `Hash of data verified.`, then resets the board.
|
|
||||||
|
|
||||||
4. **Check that it's transmitting:**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
idf.py -p COMx monitor
|
|
||||||
```
|
|
||||||
|
|
||||||
(Exit with `Ctrl+]`.) About 1.4 s after reset you should see:
|
|
||||||
|
|
||||||
```
|
|
||||||
W obu-tx: OCB @ 5900 MHz - CAM beacon armed, driving a 103-point street loop
|
|
||||||
I obu-tx: CAM sent (119 bytes) @ 5900 MHz genDeltaT=1087 pos=53.5531770,10.0220980 50.0 km/h heading 77.0 pt1
|
|
||||||
```
|
|
||||||
|
|
||||||
After that, a `CAM sent` line appears about 3 times a second, with the position, speed and
|
|
||||||
heading changing. These lines only mean each frame was
|
|
||||||
handed to the radio. To confirm the frames actually went out, capture them with a second
|
|
||||||
ESP32-C5 running the receiver firmware.
|
|
||||||
|
|
||||||
Don't open the console of the production OBU (COM3) while the phone is attached. Opening the port
|
|
||||||
resets that board and drops the phone's USB link. The beacon boards have no phone attached, so
|
|
||||||
this doesn't apply to them.
|
|
||||||
|
|
||||||
## Simulated drive
|
|
||||||
|
|
||||||
The beacon pretends to be a car driving a loop through St. Georg / Berliner Tor in Hamburg, on
|
|
||||||
the real streets. The route comes from six waypoints, which you set in `tools/make_route.py`.
|
|
||||||
|
|
||||||
**Changing the route:** edit `WAYPOINTS` in `tools/make_route.py`, then run
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
py -3.11 tools/make_route.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The script asks the OSRM demo server (router.project-osrm.org) for a legal driving route through the
|
|
||||||
waypoints in order and back to the first, then writes three files:
|
|
||||||
|
|
||||||
- `main/route_points.h`: the street geometry, thinned to points no more than 1.5 m off the line
|
|
||||||
(currently 103 points).
|
|
||||||
- `tools/route_osrm.json`: OSRM's raw answer. `--offline` rebuilds the header from it without the
|
|
||||||
network.
|
|
||||||
- `tools/route_map.html`: the route on an OpenStreetMap map. Open it in a browser and check it
|
|
||||||
before building.
|
|
||||||
|
|
||||||
Then build and flash as above. Route data (c) OpenStreetMap contributors, ODbL; routing by OSRM.
|
|
||||||
|
|
||||||
**Things to know about the routing:**
|
|
||||||
- OSRM follows one-way streets and turn bans, so the loop can be longer than the waypoints suggest.
|
|
||||||
The current one is 5.2 km, with two turn-round detours: a loop via Borgfelder Straße and
|
|
||||||
Anckelmannsplatz between wp2 and wp3, and one round Nagelsweg, Norderstraße and Repsoldstraße
|
|
||||||
between wp5 and wp6. To avoid a detour, move the waypoint on either side of it.
|
|
||||||
- Each waypoint is sent with the direction towards the next one. Without it, points on divided
|
|
||||||
roads (Beim Strohhause, for example) snap to the carriageway going the other way, and the loop
|
|
||||||
grows to 8.4 km of U-turns.
|
|
||||||
|
|
||||||
**How the car drives** (`main/route.c`):
|
|
||||||
- **Speed:** it cruises at 50 km/h (`CRUISE_MPS` in `main/main.c`). Each bend gets a speed limit
|
|
||||||
from its radius, keeping sideways acceleration at 2 m/s², so a 90° junction turn is taken at
|
|
||||||
about 15 km/h and a gentle curve barely slows the car. It never drops below 10 km/h
|
|
||||||
(`MIN_CORNER_MPS`). It brakes at 2 m/s² and accelerates at 1.5 m/s², planning braking across as
|
|
||||||
many points as a bend needs. A lap takes about 7.7 min, averaging 40 km/h.
|
|
||||||
- **Heading:** the compass bearing of the current straight piece. It changes gradually through
|
|
||||||
curves but jumps at sharp junction turns.
|
|
||||||
- **When CAMs are sent:** following ETSI EN 302 637-2, the state is checked every 100 ms. A CAM goes
|
|
||||||
out when the heading changed by more than 4°, the position by more than 4 m, or the speed by more
|
|
||||||
than 0.5 m/s since the last one, and at least once a second. That's about 3 CAMs a second at
|
|
||||||
50 km/h.
|
|
||||||
- **What's filled in:** position, speed and heading go into both the CAM and the GeoNetworking
|
|
||||||
source position vector. `genDeltaT` is milliseconds since boot.
|
|
||||||
- **Testing:** `route.c` only uses standard headers, so you can compile it on the PC with MSYS2 gcc
|
|
||||||
and simulate a lap.
|
|
||||||
|
|
||||||
## CAM encoding
|
## CAM encoding
|
||||||
|
|
||||||
`main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a
|
`main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a
|
||||||
@@ -161,12 +44,56 @@ hazard-light GPIO is grounded. No location/alacarte containers.
|
|||||||
- `main/main.c` - entry point, the `phy_11p_set`/`phy_change_channel(5900,...)`
|
- `main/main.c` - entry point, the `phy_11p_set`/`phy_change_channel(5900,...)`
|
||||||
register hack, GPIO polling, TX loop
|
register hack, GPIO polling, TX loop
|
||||||
- `main/denm.c` / `.h` - ASN.1 UPER encoding of a minimal DENM
|
- `main/denm.c` / `.h` - ASN.1 UPER encoding of a minimal DENM
|
||||||
- `main/route.c` / `.h` - simulated drive round the route loop
|
|
||||||
- `main/route_points.h` - the route, generated by `tools/make_route.py`
|
|
||||||
- `main/geonet.c` / `.h` - GeoNetworking Basic/Common/SHB headers + BTP-B
|
- `main/geonet.c` / `.h` - GeoNetworking Basic/Common/SHB headers + BTP-B
|
||||||
- `main/dot11p.c` / `.h` - 802.11 OCB (QoS Data, broadcast) frame + LLC/SNAP
|
- `main/dot11p.c` / `.h` - 802.11 OCB (QoS Data, broadcast) frame + LLC/SNAP
|
||||||
|
|
||||||
Known gaps, tracked as TODOs in the source: no real GNSS (the CAM position comes
|
Known gaps, tracked as TODOs in the source: no real GNSS (lat/long hardcoded
|
||||||
from the simulated drive above), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
|
||||||
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
|
||||||
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
|
||||||
|
|
||||||
|
## Running it as a bench beacon
|
||||||
|
|
||||||
|
This firmware needs no phone: it beacons a CAM every second by itself
|
||||||
|
(`TX_INTERVAL_MS`) from station `0x0BADC0DE` (195936478), stationType 5
|
||||||
|
(passengerCar), at the hardcoded bench position, under the fixed MAC
|
||||||
|
`02:00:00:00:00:01`, on 5900 MHz. That makes it the quickest way to put known,
|
||||||
|
repeatable traffic on air, and it is how the 4-bit `yawRateConfidence` encoding
|
||||||
|
was confirmed over the air on 2026-09-14.
|
||||||
|
|
||||||
|
A board with only one USB-C port is fine. This firmware's console is on UART0,
|
||||||
|
so such a board shows no log output, but nothing here needs the console.
|
||||||
|
|
||||||
|
Flash it from the toolchain terminal (ESP-IDF 5.5.4, see the table above):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
|
||||||
|
idf.py -p COM10 -b 921600 flash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or flash the existing build without any toolchain terminal:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd obu-cam-transmistter\build
|
||||||
|
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM10 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 2MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x10000 obu_firmware.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
It starts beaconing as soon as it boots, so there is nothing to start by hand,
|
||||||
|
and unplugging it is how you stop it.
|
||||||
|
|
||||||
|
**It transmits under the same MAC as the phone's CAM pinger**, so on air the two
|
||||||
|
are told apart by station ID (195936478 here, 999999 for the pinger), never by
|
||||||
|
source address.
|
||||||
|
|
||||||
|
To see what it is sending, capture on the sniffer board and decode:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd capture
|
||||||
|
py -3.11 live_capture.py COM8
|
||||||
|
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
|
||||||
|
```
|
||||||
|
|
||||||
|
The tally lists it as SHB / port 2001 / lifetime `0x05`. For the message itself,
|
||||||
|
decode the payload with `asn1tools` against `asn1/cam_1_4_1.asn` +
|
||||||
|
`asn1/cdd_1_3_1_1.asn`; re-encoding must return the identical bytes. On
|
||||||
|
2026-09-14, 72 of 72 frames did.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# wifi_patches.c is intentionally NOT in this list anymore - superseded by
|
# wifi_patches.c is intentionally NOT in this list anymore - superseded by
|
||||||
# tx_custom.c (see that file for why). Left on disk, unused, for history.
|
# tx_custom.c (see that file for why). Left on disk, unused, for history.
|
||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c" "route.c"
|
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c"
|
||||||
INCLUDE_DIRS "."
|
INCLUDE_DIRS "."
|
||||||
REQUIRES esp_event esp_timer esp_netif nvs_flash driver esp_phy
|
REQUIRES esp_event esp_netif nvs_flash driver esp_phy
|
||||||
PRIV_REQUIRES esp_wifi
|
PRIV_REQUIRES esp_wifi
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||||
const uint8_t mac[6], uint8_t station_type,
|
const uint8_t mac[6], uint8_t station_type,
|
||||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||||
uint16_t speed_cm_s, uint16_t heading_ddeg,
|
|
||||||
uint16_t btp_dest_port,
|
uint16_t btp_dest_port,
|
||||||
uint8_t *out, size_t out_len)
|
uint8_t *out, size_t out_len)
|
||||||
{
|
{
|
||||||
@@ -69,12 +68,12 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
|||||||
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
|
||||||
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
|
||||||
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
|
||||||
// PAI(1 bit) + Speed(15 bits, signed, 0.01 m/s), packed into 2 bytes. PAI stays 0: the
|
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false,
|
||||||
// position has no accuracy estimate behind it.
|
// speed 0 - which is actually correct semantics for a STATIONARY
|
||||||
uint16_t spd = speed_cm_s > 0x7FFF ? 0x7FFF : speed_cm_s;
|
// vehicle beacon, not just a placeholder.
|
||||||
*p++ = (uint8_t)(spd >> 8); *p++ = (uint8_t)(spd & 0xFF);
|
*p++ = 0x00; *p++ = 0x00;
|
||||||
// Heading (16 bits, 0.1 degree units, clockwise from north)
|
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable
|
||||||
*p++ = (uint8_t)(heading_ddeg >> 8); *p++ = (uint8_t)(heading_ddeg & 0xFF);
|
*p++ = 0x00; *p++ = 0x00;
|
||||||
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
|
||||||
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
|
||||||
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
|
||||||
|
|||||||
@@ -31,10 +31,6 @@
|
|||||||
// working (same extended header shape as CAM). Fine for a single-vehicle
|
// working (same extended header shape as CAM). Fine for a single-vehicle
|
||||||
// beacon; revisit if you need real multi-hop forwarding later.
|
// beacon; revisit if you need real multi-hop forwarding later.
|
||||||
//
|
//
|
||||||
// `speed_cm_s` (0.01 m/s) and `heading_ddeg` (0.1 deg) also go into the Source Long Position
|
|
||||||
// Vector; pass the same values as the CAM's high-frequency container. Speed is a 15-bit field, so
|
|
||||||
// values above 32767 are clamped.
|
|
||||||
//
|
|
||||||
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
// `btp_dest_port` is the BTP-B destination port for the service being carried
|
||||||
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
|
||||||
//
|
//
|
||||||
@@ -42,7 +38,6 @@
|
|||||||
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
|
||||||
const uint8_t mac[6], uint8_t station_type,
|
const uint8_t mac[6], uint8_t station_type,
|
||||||
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
|
||||||
uint16_t speed_cm_s, uint16_t heading_ddeg,
|
|
||||||
uint16_t btp_dest_port,
|
uint16_t btp_dest_port,
|
||||||
uint8_t *out, size_t out_len);
|
uint8_t *out, size_t out_len);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <math.h>
|
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
#include "esp_timer.h"
|
|
||||||
#include "driver/gpio.h"
|
#include "driver/gpio.h"
|
||||||
#include "esp_wifi.h"
|
#include "esp_wifi.h"
|
||||||
#include "esp_event.h"
|
#include "esp_event.h"
|
||||||
@@ -18,17 +14,13 @@
|
|||||||
#include "geonet.h"
|
#include "geonet.h"
|
||||||
#include "dot11p.h"
|
#include "dot11p.h"
|
||||||
#include "tx_custom.h"
|
#include "tx_custom.h"
|
||||||
#include "route.h"
|
|
||||||
#include "route_points.h"
|
|
||||||
|
|
||||||
static const char *TAG = "obu-tx";
|
static const char *TAG = "obu-tx";
|
||||||
|
|
||||||
// CAM beacon for a simulated car driving round a block in Hamburg (see route.c). The CAM
|
// CAM beacon: transmit a Cooperative Awareness Message every TX_INTERVAL_MS,
|
||||||
// generation rules follow ETSI EN 302 637-2 clause 6.1.3: every CHECK_INTERVAL_MS the car's state
|
// unconditionally (no hazard-light gating - CAM is a continuous beacon, unlike
|
||||||
// is compared with the last CAM sent, and a new CAM goes out when the heading changed by more than
|
// the event-triggered DENM). Matches the working Rust reference
|
||||||
// 4 degrees, the position by more than 4 m, the speed by more than 0.5 m/s, or 1 s has passed.
|
// (esp32-c_its-companion, feat/tx-cam), which beacons CAM on 5900 MHz.
|
||||||
// There is no hazard-light gating - CAM is a continuous beacon, unlike the event-triggered DENM.
|
|
||||||
// Transmits on 5900 MHz like the working Rust reference (esp32-c_its-companion, feat/tx-cam).
|
|
||||||
|
|
||||||
// ISOLATION TEST for whether tx_custom.c is the blocker.
|
// ISOLATION TEST for whether tx_custom.c is the blocker.
|
||||||
// 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a
|
// 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a
|
||||||
@@ -63,19 +55,15 @@ static const char *TAG = "obu-tx";
|
|||||||
#define VEHICLE_LENGTH_DM 40 // VehicleLengthValue, 10cm steps (4.0 m)
|
#define VEHICLE_LENGTH_DM 40 // VehicleLengthValue, 10cm steps (4.0 m)
|
||||||
#define VEHICLE_WIDTH_DM 18 // VehicleWidth, 10cm steps (1.8 m)
|
#define VEHICLE_WIDTH_DM 18 // VehicleWidth, 10cm steps (1.8 m)
|
||||||
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
|
||||||
#define CHECK_INTERVAL_MS 100 // T_CheckCamGen: how often the generation rules are evaluated
|
#define TX_INTERVAL_MS 1000 // CAM beacon period (1 Hz; ITS allows 1-10 Hz)
|
||||||
#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
|
|
||||||
|
|
||||||
// ---- Simulated drive ----
|
// Bench location, hardcoded since there's no GNSS module wired in yet and
|
||||||
// route_points (main/route_points.h) is the street geometry of a driving loop through six waypoints
|
// the unit is genuinely stationary here: 53°33'16.8"N 10°01'20.6"E, in
|
||||||
// in St. Georg, generated by tools/make_route.py from OpenStreetMap via OSRM. To change the route,
|
// 1/10-microdegree units (decimal_degrees * 10,000,000). Replace with real
|
||||||
// edit WAYPOINTS in that script and rerun it. No GNSS is wired in; replace with real fixes once
|
// GNSS output once you have a fix source; until then this beats 0/0
|
||||||
// there is one.
|
// ("Null Island"), which is an obvious placeholder-tell on any map.
|
||||||
#define CRUISE_MPS (50.0 / 3.6) // 50 km/h, the urban limit
|
#define BENCH_LATITUDE_TENMICRODEG 535546667
|
||||||
#define MIN_CORNER_MPS (10.0 / 3.6) // slowest the car goes, for hairpins and U-turns
|
#define BENCH_LONGITUDE_TENMICRODEG 100223889
|
||||||
|
|
||||||
// Single source of truth for the pseudonym/link-layer address: used both as
|
// Single source of truth for the pseudonym/link-layer address: used both as
|
||||||
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
|
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
|
||||||
@@ -90,28 +78,33 @@ static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
|
|||||||
extern void phy_11p_set(int enable, int unused);
|
extern void phy_11p_set(int enable, int unused);
|
||||||
extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, int unused);
|
extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, int unused);
|
||||||
|
|
||||||
static void send_cam(const route_state_t *car, uint16_t gen_delta)
|
static void send_cam(void)
|
||||||
{
|
{
|
||||||
|
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No RTC/GNSS time here,
|
||||||
|
// so use a free-running ms counter that advances one beacon-interval per
|
||||||
|
// send. It wraps at 65536, which is exactly the field's defined behaviour.
|
||||||
|
static uint16_t gen_delta = 0;
|
||||||
|
|
||||||
uint8_t frame[300];
|
uint8_t frame[300];
|
||||||
cam_fields_t fields = {
|
cam_fields_t fields = {
|
||||||
.station_id = STATION_ID,
|
.station_id = STATION_ID,
|
||||||
.station_type = STATION_TYPE,
|
.station_type = STATION_TYPE,
|
||||||
.generation_delta_time = gen_delta,
|
.generation_delta_time = gen_delta,
|
||||||
.latitude_tenmicrodeg = car->latitude_tenmicrodeg,
|
.latitude_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG,
|
||||||
.longitude_tenmicrodeg = car->longitude_tenmicrodeg,
|
.longitude_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG,
|
||||||
.speed_cm_s = car->speed_cm_s,
|
.speed_cm_s = 0, // stationary
|
||||||
.heading_ddeg = car->heading_ddeg,
|
.heading_ddeg = 3601, // HeadingValue unavailable (no heading source)
|
||||||
.vehicle_length_dm = VEHICLE_LENGTH_DM,
|
.vehicle_length_dm = VEHICLE_LENGTH_DM,
|
||||||
.vehicle_width_dm = VEHICLE_WIDTH_DM,
|
.vehicle_width_dm = VEHICLE_WIDTH_DM,
|
||||||
};
|
};
|
||||||
|
gen_delta += TX_INTERVAL_MS;
|
||||||
|
|
||||||
uint8_t cam_payload[96];
|
uint8_t cam_payload[96];
|
||||||
int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload));
|
int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload));
|
||||||
|
|
||||||
uint8_t gn_payload[160];
|
uint8_t gn_payload[160];
|
||||||
int gn_len = geonet_wrap_shb(cam_payload, cam_len, pseudonym_mac, STATION_TYPE,
|
int gn_len = geonet_wrap_shb(cam_payload, cam_len, pseudonym_mac, STATION_TYPE,
|
||||||
car->latitude_tenmicrodeg, car->longitude_tenmicrodeg,
|
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
||||||
car->speed_cm_s, car->heading_ddeg,
|
|
||||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||||
|
|
||||||
// qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS
|
// qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS
|
||||||
@@ -134,10 +127,7 @@ static void send_cam(const route_state_t *car, uint16_t gen_delta)
|
|||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err);
|
ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err);
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz genDeltaT=%u pos=%.7f,%.7f %.1f km/h heading %.1f pt%d",
|
ESP_LOGI(TAG, "CAM sent via STANDARD tx (%d bytes) @ %d MHz genDeltaT=%u", frame_len, TX_FREQ_MHZ, gen_delta);
|
||||||
frame_len, TX_FREQ_MHZ, gen_delta,
|
|
||||||
car->latitude_tenmicrodeg / 1e7, car->longitude_tenmicrodeg / 1e7,
|
|
||||||
car->speed_cm_s * 0.036, car->heading_ddeg / 10.0, car->segment + 1);
|
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
// tx_custom path: submits to the driver's internal HMAC TX path,
|
// tx_custom path: submits to the driver's internal HMAC TX path,
|
||||||
@@ -161,49 +151,12 @@ static void send_cam(const route_state_t *car, uint16_t gen_delta)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool cam_due(const route_state_t *car, const route_state_t *last, int64_t since_last_ms)
|
|
||||||
{
|
|
||||||
if (since_last_ms >= CAM_MAX_INTERVAL_MS) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
int dh = abs((int)car->heading_ddeg - (int)last->heading_ddeg);
|
|
||||||
if (dh > 1800) {
|
|
||||||
dh = 3600 - dh;
|
|
||||||
}
|
|
||||||
if (dh > CAM_HEADING_DDEG) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (abs((int)car->speed_cm_s - (int)last->speed_cm_s) > CAM_SPEED_CM_S) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Flat-earth distance is plenty for a 4 m threshold.
|
|
||||||
double north_m = (car->latitude_tenmicrodeg - last->latitude_tenmicrodeg) * 0.0111194930;
|
|
||||||
double east_m = (car->longitude_tenmicrodeg - last->longitude_tenmicrodeg) * 0.0111194930
|
|
||||||
* cos(car->latitude_tenmicrodeg / 1e7 * M_PI / 180.0);
|
|
||||||
return north_m * north_m + east_m * east_m > CAM_POSITION_M * CAM_POSITION_M;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void tx_task(void *arg)
|
static void tx_task(void *arg)
|
||||||
{
|
{
|
||||||
route_state_t car;
|
|
||||||
route_state_t last_sent;
|
|
||||||
int64_t last_sent_ms = 0;
|
|
||||||
bool sent_any = false;
|
|
||||||
TickType_t wake = xTaskGetTickCount();
|
|
||||||
|
|
||||||
route_step(0.0, &car);
|
|
||||||
while (1) {
|
while (1) {
|
||||||
int64_t now_ms = esp_timer_get_time() / 1000;
|
// CAM is a continuous beacon - send every interval, unconditionally.
|
||||||
if (!sent_any || cam_due(&car, &last_sent, now_ms - last_sent_ms)) {
|
send_cam();
|
||||||
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No real clock here, so use
|
vTaskDelay(pdMS_TO_TICKS(TX_INTERVAL_MS));
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,13 +255,8 @@ void app_main(void)
|
|||||||
phy_change_channel(TX_FREQ_MHZ, 1, 0, 0);
|
phy_change_channel(TX_FREQ_MHZ, 1, 0, 0);
|
||||||
ESP_LOGI(TAG, "phy_change_channel returned");
|
ESP_LOGI(TAG, "phy_change_channel returned");
|
||||||
|
|
||||||
if (route_init(route_points, sizeof(route_points) / sizeof(route_points[0]),
|
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, transmitting every %d ms",
|
||||||
CRUISE_MPS, MIN_CORNER_MPS) != 0) {
|
TX_FREQ_MHZ, TX_INTERVAL_MS);
|
||||||
ESP_LOGE(TAG, "route_init failed - check route_points");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, driving a %d-point street loop",
|
|
||||||
TX_FREQ_MHZ, (int)(sizeof(route_points) / sizeof(route_points[0])));
|
|
||||||
|
|
||||||
xTaskCreate(tx_task, "tx_task", 4096, NULL, 5, NULL);
|
xTaskCreate(tx_task, "tx_task", 4096, NULL, 5, NULL);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
#include "route.h"
|
|
||||||
#include <math.h>
|
|
||||||
|
|
||||||
#define DEG_TO_RAD (M_PI / 180.0)
|
|
||||||
#define METRES_PER_DEG 111194.93 // mean Earth radius 6371 km; a block is small enough for a flat projection
|
|
||||||
#define ACCEL_MPS2 1.5 // pulling away from a corner
|
|
||||||
#define DECEL_MPS2 2.0 // braking ahead of a corner
|
|
||||||
#define LATERAL_MPS2 2.0 // sideways acceleration a normal driver takes a bend at
|
|
||||||
#define STRAIGHT_DEG 3.0 // kinks gentler than this are digitising noise, not bends
|
|
||||||
#define BEND_SPAN_M 15.0 // longest segment counted towards a bend's radius (see route_init)
|
|
||||||
|
|
||||||
static const route_point_t *s_pts;
|
|
||||||
static int s_n;
|
|
||||||
static double s_len[ROUTE_MAX_POINTS]; // segment i runs from point i to point (i+1) % n
|
|
||||||
static double s_bearing_deg[ROUTE_MAX_POINTS];
|
|
||||||
static double s_corner_mps[ROUTE_MAX_POINTS]; // speed limit at point i, where segment i starts
|
|
||||||
static double s_cruise_mps;
|
|
||||||
static int s_seg;
|
|
||||||
static double s_pos_m; // distance along the current segment
|
|
||||||
|
|
||||||
static double segment_speed(int seg, double pos_m)
|
|
||||||
{
|
|
||||||
double v = s_cruise_mps;
|
|
||||||
double pull_away = sqrt(s_corner_mps[seg] * s_corner_mps[seg] + 2.0 * ACCEL_MPS2 * pos_m);
|
|
||||||
int next = (seg + 1) % s_n;
|
|
||||||
double braking = sqrt(s_corner_mps[next] * s_corner_mps[next]
|
|
||||||
+ 2.0 * DECEL_MPS2 * (s_len[seg] - pos_m));
|
|
||||||
if (pull_away < v) v = pull_away;
|
|
||||||
if (braking < v) v = braking;
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
int route_init(const route_point_t *points, int n, double cruise_mps, double min_corner_mps)
|
|
||||||
{
|
|
||||||
if (n < 2 || n > ROUTE_MAX_POINTS) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
s_pts = points;
|
|
||||||
s_n = n;
|
|
||||||
s_cruise_mps = cruise_mps;
|
|
||||||
|
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
const route_point_t *a = &points[i];
|
|
||||||
const route_point_t *b = &points[(i + 1) % n];
|
|
||||||
double mid_lat = (a->latitude_tenmicrodeg + (double)b->latitude_tenmicrodeg) / 2e7;
|
|
||||||
double north_m = (b->latitude_tenmicrodeg - a->latitude_tenmicrodeg) / 1e7 * METRES_PER_DEG;
|
|
||||||
double east_m = (b->longitude_tenmicrodeg - a->longitude_tenmicrodeg) / 1e7 * METRES_PER_DEG
|
|
||||||
* cos(mid_lat * DEG_TO_RAD);
|
|
||||||
s_len[i] = sqrt(north_m * north_m + east_m * east_m);
|
|
||||||
if (s_len[i] < 0.01) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
double bearing = atan2(east_m, north_m) / DEG_TO_RAD;
|
|
||||||
s_bearing_deg[i] = bearing < 0 ? bearing + 360.0 : bearing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Speed limit at each point from how tight the bend there is. A polyline bend of angle theta
|
|
||||||
// between segments of length L approximates an arc of radius L / theta, and a car takes a
|
|
||||||
// radius R at sqrt(a_lat * R). L is capped at BEND_SPAN_M: at a junction the two streets can be
|
|
||||||
// hundreds of metres long, but the car still turns within the width of the crossing.
|
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
double turn = fabs(s_bearing_deg[i] - s_bearing_deg[(i + n - 1) % n]);
|
|
||||||
if (turn > 180.0) {
|
|
||||||
turn = 360.0 - turn;
|
|
||||||
}
|
|
||||||
double v = cruise_mps;
|
|
||||||
if (turn > STRAIGHT_DEG) {
|
|
||||||
double span = s_len[(i + n - 1) % n] < s_len[i] ? s_len[(i + n - 1) % n] : s_len[i];
|
|
||||||
if (span > BEND_SPAN_M) {
|
|
||||||
span = BEND_SPAN_M;
|
|
||||||
}
|
|
||||||
v = sqrt(LATERAL_MPS2 * span / (turn * DEG_TO_RAD));
|
|
||||||
}
|
|
||||||
if (v > cruise_mps) v = cruise_mps;
|
|
||||||
if (v < min_corner_mps) v = min_corner_mps;
|
|
||||||
s_corner_mps[i] = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A point's limit also has to respect the bends after it (the car must be able to brake for
|
|
||||||
// them within the segments in between) and before it (it can only have sped up so much since).
|
|
||||||
// segment_speed only looks at the two ends of a segment, so settle this here. Limits only ever
|
|
||||||
// go down, so repeating the two passes until nothing changes terminates.
|
|
||||||
for (int changed = 1; changed;) {
|
|
||||||
changed = 0;
|
|
||||||
for (int k = 0; k < 2 * n; k++) {
|
|
||||||
int i = (2 * n - 1 - k) % n; // backwards: braking
|
|
||||||
int next = (i + 1) % n;
|
|
||||||
double v = sqrt(s_corner_mps[next] * s_corner_mps[next] + 2.0 * DECEL_MPS2 * s_len[i]);
|
|
||||||
if (v < s_corner_mps[i] - 1e-9) { s_corner_mps[i] = v; changed = 1; }
|
|
||||||
}
|
|
||||||
for (int k = 0; k < 2 * n; k++) {
|
|
||||||
int i = k % n; // forwards: accelerating
|
|
||||||
int next = (i + 1) % n;
|
|
||||||
double v = sqrt(s_corner_mps[i] * s_corner_mps[i] + 2.0 * ACCEL_MPS2 * s_len[i]);
|
|
||||||
if (v < s_corner_mps[next] - 1e-9) { s_corner_mps[next] = v; changed = 1; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s_seg = 0;
|
|
||||||
s_pos_m = 0.0;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void route_step(double dt_s, route_state_t *out)
|
|
||||||
{
|
|
||||||
// Advance with the speed at the start of the step; at 100 ms steps the error is well under a metre.
|
|
||||||
double d = segment_speed(s_seg, s_pos_m) * dt_s;
|
|
||||||
while (s_pos_m + d >= s_len[s_seg]) {
|
|
||||||
d -= s_len[s_seg] - s_pos_m;
|
|
||||||
s_seg = (s_seg + 1) % s_n;
|
|
||||||
s_pos_m = 0.0;
|
|
||||||
}
|
|
||||||
s_pos_m += d;
|
|
||||||
|
|
||||||
const route_point_t *a = &s_pts[s_seg];
|
|
||||||
const route_point_t *b = &s_pts[(s_seg + 1) % s_n];
|
|
||||||
double f = s_pos_m / s_len[s_seg];
|
|
||||||
out->latitude_tenmicrodeg = (int32_t)lround(a->latitude_tenmicrodeg
|
|
||||||
+ f * (b->latitude_tenmicrodeg - a->latitude_tenmicrodeg));
|
|
||||||
out->longitude_tenmicrodeg = (int32_t)lround(a->longitude_tenmicrodeg
|
|
||||||
+ f * (b->longitude_tenmicrodeg - a->longitude_tenmicrodeg));
|
|
||||||
out->speed_cm_s = (uint16_t)lround(segment_speed(s_seg, s_pos_m) * 100.0);
|
|
||||||
out->heading_ddeg = (uint16_t)(lround(s_bearing_deg[s_seg] * 10.0) % 3600);
|
|
||||||
out->segment = s_seg;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#ifndef ROUTE_H
|
|
||||||
#define ROUTE_H
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
// Simulated drive around a closed loop of waypoints, so the beacon looks like a
|
|
||||||
// car going round the block instead of a parked one.
|
|
||||||
//
|
|
||||||
// The car follows straight lines between the points and goes from the last one
|
|
||||||
// back to the first, forever. For street-following, the points are the street
|
|
||||||
// geometry from tools/make_route.py (main/route_points.h). Speed is a function
|
|
||||||
// of where the car is on a segment: it cruises, brakes ahead of each bend down to
|
|
||||||
// the speed that bend allows, and accelerates away after it. Heading is the
|
|
||||||
// bearing of the current segment.
|
|
||||||
//
|
|
||||||
// Uses only standard headers, so it also compiles on the host for testing.
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int32_t latitude_tenmicrodeg; // 1/10 microdegree, same units as the CAM
|
|
||||||
int32_t longitude_tenmicrodeg;
|
|
||||||
} route_point_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int32_t latitude_tenmicrodeg;
|
|
||||||
int32_t longitude_tenmicrodeg;
|
|
||||||
uint16_t speed_cm_s; // SpeedValue units (0.01 m/s)
|
|
||||||
uint16_t heading_ddeg; // HeadingValue units (0.1 deg, 0..3599, 0 = north, clockwise)
|
|
||||||
int segment; // index of the route point the car last passed
|
|
||||||
} route_state_t;
|
|
||||||
|
|
||||||
#define ROUTE_MAX_POINTS 512 // keep in step with MAX_POINTS in tools/make_route.py
|
|
||||||
|
|
||||||
// `points` must stay valid for as long as the route is used. Returns 0, or -1
|
|
||||||
// if n is out of range (2..ROUTE_MAX_POINTS) or a segment has zero length.
|
|
||||||
// min_corner_mps is the slowest the car ever goes (hairpins, U-turns).
|
|
||||||
int route_init(const route_point_t *points, int n, double cruise_mps, double min_corner_mps);
|
|
||||||
|
|
||||||
// Moves the car on by dt_s seconds and writes its new position into `out`.
|
|
||||||
void route_step(double dt_s, route_state_t *out);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// GENERATED by tools/make_route.py - do not edit by hand; change WAYPOINTS there and rerun.
|
|
||||||
// Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.
|
|
||||||
//
|
|
||||||
// Driving loop through 6 waypoints: 5179 m (legs 147 m, 1837 m, 381 m, 819 m, 1234 m, 761 m), 103 points after
|
|
||||||
// simplifying to 1.5 m. Streets: Beim Strohhause, Berlinertordamm, Berliner Tor, Bei der Hauptfeuerwache, Westphalensweg, Berliner Tor, Berlinertordamm, Borgfelder Straße, Anckelmannstraße, Anckelmannsplatz, Bürgerweide, Wallstraße, Lübeckertordamm, Steindamm, Kreuzweg, Adenauerallee, Nagelsweg, Norderstraße, Repsoldstraße, Kurt-Schumacher-Allee, Kreuzweg, Adenauerallee, Kurt-Schumacher-Allee, Beim Strohhause.
|
|
||||||
#ifndef ROUTE_POINTS_H
|
|
||||||
#define ROUTE_POINTS_H
|
|
||||||
#include "route.h"
|
|
||||||
|
|
||||||
static const route_point_t route_points[] = {
|
|
||||||
{ 535531770, 100220980 },
|
|
||||||
{ 535534470, 100240600 },
|
|
||||||
{ 535535790, 100244160 },
|
|
||||||
{ 535536400, 100244520 },
|
|
||||||
{ 535537130, 100244160 },
|
|
||||||
{ 535538480, 100242840 },
|
|
||||||
{ 535538720, 100242140 },
|
|
||||||
{ 535540390, 100240200 },
|
|
||||||
{ 535548880, 100233930 },
|
|
||||||
{ 535549470, 100235130 },
|
|
||||||
{ 535554140, 100253280 },
|
|
||||||
{ 535553570, 100254580 },
|
|
||||||
{ 535546070, 100251700 },
|
|
||||||
{ 535543130, 100250020 },
|
|
||||||
{ 535542570, 100249130 },
|
|
||||||
{ 535542760, 100247800 },
|
|
||||||
{ 535539980, 100244600 },
|
|
||||||
{ 535538720, 100242140 },
|
|
||||||
{ 535538140, 100241960 },
|
|
||||||
{ 535535720, 100243380 },
|
|
||||||
{ 535535300, 100244470 },
|
|
||||||
{ 535535090, 100246580 },
|
|
||||||
{ 535536830, 100264460 },
|
|
||||||
{ 535537600, 100270920 },
|
|
||||||
{ 535538520, 100275220 },
|
|
||||||
{ 535541110, 100290830 },
|
|
||||||
{ 535540170, 100291550 },
|
|
||||||
{ 535539890, 100294430 },
|
|
||||||
{ 535539580, 100295330 },
|
|
||||||
{ 535532290, 100299300 },
|
|
||||||
{ 535527980, 100302450 },
|
|
||||||
{ 535525130, 100291880 },
|
|
||||||
{ 535524220, 100289760 },
|
|
||||||
{ 535522900, 100287820 },
|
|
||||||
{ 535522890, 100285300 },
|
|
||||||
{ 535522610, 100282810 },
|
|
||||||
{ 535520620, 100276050 },
|
|
||||||
{ 535519820, 100271130 },
|
|
||||||
{ 535519670, 100269030 },
|
|
||||||
{ 535520220, 100267120 },
|
|
||||||
{ 535520930, 100262840 },
|
|
||||||
{ 535521850, 100260020 },
|
|
||||||
{ 535522840, 100258210 },
|
|
||||||
{ 535527790, 100257880 },
|
|
||||||
{ 535538650, 100261370 },
|
|
||||||
{ 535551660, 100266470 },
|
|
||||||
{ 535555790, 100268750 },
|
|
||||||
{ 535559620, 100271540 },
|
|
||||||
{ 535561120, 100272050 },
|
|
||||||
{ 535562430, 100271250 },
|
|
||||||
{ 535563830, 100269390 },
|
|
||||||
{ 535569610, 100260190 },
|
|
||||||
{ 535579530, 100242810 },
|
|
||||||
{ 535584090, 100237660 },
|
|
||||||
{ 535585850, 100234670 },
|
|
||||||
{ 535584970, 100230060 },
|
|
||||||
{ 535584000, 100227260 },
|
|
||||||
{ 535580730, 100222030 },
|
|
||||||
{ 535579100, 100218590 },
|
|
||||||
{ 535574500, 100208430 },
|
|
||||||
{ 535570560, 100199010 },
|
|
||||||
{ 535568130, 100194820 },
|
|
||||||
{ 535564910, 100187620 },
|
|
||||||
{ 535563990, 100184630 },
|
|
||||||
{ 535562540, 100181160 },
|
|
||||||
{ 535559780, 100176310 },
|
|
||||||
{ 535542180, 100136840 },
|
|
||||||
{ 535539720, 100133430 },
|
|
||||||
{ 535537350, 100132010 },
|
|
||||||
{ 535534760, 100131390 },
|
|
||||||
{ 535523840, 100132690 },
|
|
||||||
{ 535524980, 100155090 },
|
|
||||||
{ 535524890, 100156360 },
|
|
||||||
{ 535524440, 100157650 },
|
|
||||||
{ 535523030, 100158530 },
|
|
||||||
{ 535517610, 100158580 },
|
|
||||||
{ 535504440, 100168810 },
|
|
||||||
{ 535501360, 100156400 },
|
|
||||||
{ 535499250, 100144880 },
|
|
||||||
{ 535498470, 100133880 },
|
|
||||||
{ 535498630, 100126150 },
|
|
||||||
{ 535499110, 100121980 },
|
|
||||||
{ 535504260, 100117230 },
|
|
||||||
{ 535505720, 100115500 },
|
|
||||||
{ 535507630, 100112310 },
|
|
||||||
{ 535508400, 100111560 },
|
|
||||||
{ 535510000, 100110940 },
|
|
||||||
{ 535511390, 100119560 },
|
|
||||||
{ 535512440, 100122280 },
|
|
||||||
{ 535514510, 100132770 },
|
|
||||||
{ 535515020, 100134120 },
|
|
||||||
{ 535516630, 100135630 },
|
|
||||||
{ 535518260, 100136100 },
|
|
||||||
{ 535523950, 100134870 },
|
|
||||||
{ 535524980, 100155090 },
|
|
||||||
{ 535524890, 100156360 },
|
|
||||||
{ 535524440, 100157650 },
|
|
||||||
{ 535523470, 100158460 },
|
|
||||||
{ 535518560, 100158480 },
|
|
||||||
{ 535519070, 100162430 },
|
|
||||||
{ 535522260, 100178080 },
|
|
||||||
{ 535527880, 100197650 },
|
|
||||||
{ 535530060, 100209020 },
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
"""Turn the beacon's waypoints into a street-following route for main/route_points.h.
|
|
||||||
|
|
||||||
Asks the OSRM demo server (router.project-osrm.org, OpenStreetMap data) for a driving route that
|
|
||||||
visits WAYPOINTS in order and returns to the first, thins the street geometry out, and writes:
|
|
||||||
|
|
||||||
main/route_points.h the C array the firmware drives (commit this)
|
|
||||||
tools/route_osrm.json the raw OSRM answer, so the header can be regenerated offline (--offline)
|
|
||||||
tools/route_map.html the route over an OpenStreetMap map, to check it before flashing
|
|
||||||
|
|
||||||
Each waypoint also gets a bearing (the direction towards the next waypoint), so OSRM snaps it onto
|
|
||||||
the carriageway going that way. Without it, points on divided roads land on the wrong side and
|
|
||||||
every leg grows a U-turn detour.
|
|
||||||
|
|
||||||
Usage (Python 3.8+, standard library only):
|
|
||||||
py tools/make_route.py fetch from OSRM, then write all three files
|
|
||||||
py -3 tools/make_route.py --offline rebuild from the saved route_osrm.json
|
|
||||||
|
|
||||||
Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import pathlib
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
# (latitude, longitude) in decimal degrees, in driving order. The route closes back to the first.
|
|
||||||
WAYPOINTS = [
|
|
||||||
(53.553309, 10.022043),
|
|
||||||
(53.553611, 10.024146),
|
|
||||||
(53.556164, 10.027121),
|
|
||||||
(53.558310, 10.023362),
|
|
||||||
(53.554062, 10.013515),
|
|
||||||
(53.551540, 10.013398),
|
|
||||||
]
|
|
||||||
BEARING_TOLERANCE_DEG = 60 # how far the road's direction may differ from the waypoint bearing
|
|
||||||
SIMPLIFY_M = 1.5 # drop points that move the line by less than this
|
|
||||||
MAX_POINTS = 512 # must match ROUTE_MAX_POINTS in main/route.h
|
|
||||||
|
|
||||||
HERE = pathlib.Path(__file__).resolve().parent
|
|
||||||
PROJECT = HERE.parent
|
|
||||||
OSRM_JSON = HERE / "route_osrm.json"
|
|
||||||
HEADER = PROJECT / "main" / "route_points.h"
|
|
||||||
MAP_HTML = HERE / "route_map.html"
|
|
||||||
METRES_PER_DEG = 111194.93
|
|
||||||
|
|
||||||
|
|
||||||
def to_xy(lat, lon, lat0):
|
|
||||||
return (lon * METRES_PER_DEG * math.cos(math.radians(lat0)), lat * METRES_PER_DEG)
|
|
||||||
|
|
||||||
|
|
||||||
def bearing(a, b):
|
|
||||||
north = b[0] - a[0]
|
|
||||||
east = (b[1] - a[1]) * math.cos(math.radians(a[0]))
|
|
||||||
return math.degrees(math.atan2(east, north)) % 360
|
|
||||||
|
|
||||||
|
|
||||||
def fetch():
|
|
||||||
n = len(WAYPOINTS)
|
|
||||||
pts = WAYPOINTS + [WAYPOINTS[0]]
|
|
||||||
bearings = [round(bearing(WAYPOINTS[i], WAYPOINTS[(i + 1) % n])) for i in range(n)]
|
|
||||||
bearings.append(bearings[0])
|
|
||||||
url = ("https://router.project-osrm.org/route/v1/driving/"
|
|
||||||
+ ";".join(f"{lon},{lat}" for lat, lon in pts)
|
|
||||||
+ "?overview=full&geometries=geojson&steps=true&bearings="
|
|
||||||
+ ";".join(f"{b},{BEARING_TOLERANCE_DEG}" for b in bearings))
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "MicrOBU-route-tool"})
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
||||||
data = json.load(resp)
|
|
||||||
if data.get("code") != "Ok":
|
|
||||||
raise SystemExit(f"OSRM error: {data.get('code')} {data.get('message')}")
|
|
||||||
OSRM_JSON.write_text(json.dumps(data, indent=1), encoding="utf-8")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def simplify(xy, tol):
|
|
||||||
"""Douglas-Peucker, iterative. Keeps the first and last point."""
|
|
||||||
keep = [False] * len(xy)
|
|
||||||
keep[0] = keep[-1] = True
|
|
||||||
stack = [(0, len(xy) - 1)]
|
|
||||||
while stack:
|
|
||||||
a, b = stack.pop()
|
|
||||||
(ax, ay), (bx, by) = xy[a], xy[b]
|
|
||||||
dx, dy = bx - ax, by - ay
|
|
||||||
seg2 = dx * dx + dy * dy
|
|
||||||
worst, worst_d = -1, tol
|
|
||||||
for i in range(a + 1, b):
|
|
||||||
px, py = xy[i]
|
|
||||||
if seg2 == 0:
|
|
||||||
d = math.hypot(px - ax, py - ay)
|
|
||||||
else:
|
|
||||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg2))
|
|
||||||
d = math.hypot(px - ax - t * dx, py - ay - t * dy)
|
|
||||||
if d > worst_d:
|
|
||||||
worst, worst_d = i, d
|
|
||||||
if worst >= 0:
|
|
||||||
keep[worst] = True
|
|
||||||
stack += [(a, worst), (worst, b)]
|
|
||||||
return [i for i, k in enumerate(keep) if k]
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--offline", action="store_true", help="use the saved route_osrm.json")
|
|
||||||
args = ap.parse_args()
|
|
||||||
data = json.loads(OSRM_JSON.read_text(encoding="utf-8")) if args.offline else fetch()
|
|
||||||
route = data["routes"][0]
|
|
||||||
|
|
||||||
# GeoJSON is [lon, lat]; round to the CAM's 1/10-microdegree grid and drop repeats.
|
|
||||||
raw = []
|
|
||||||
for lon, lat in route["geometry"]["coordinates"]:
|
|
||||||
p = (round(lat * 1e7), round(lon * 1e7))
|
|
||||||
if not raw or p != raw[-1]:
|
|
||||||
raw.append(p)
|
|
||||||
if raw[0] == raw[-1]:
|
|
||||||
raw.pop()
|
|
||||||
closed = raw + [raw[0]]
|
|
||||||
lat0 = closed[0][0] / 1e7
|
|
||||||
xy = [to_xy(p[0] / 1e7, p[1] / 1e7, lat0) for p in closed]
|
|
||||||
pts = [closed[i] for i in simplify(xy, SIMPLIFY_M)][:-1]
|
|
||||||
if len(pts) > MAX_POINTS:
|
|
||||||
raise SystemExit(f"{len(pts)} points is more than MAX_POINTS={MAX_POINTS}; raise SIMPLIFY_M")
|
|
||||||
|
|
||||||
streets = []
|
|
||||||
for leg in route["legs"]:
|
|
||||||
for step in leg["steps"]:
|
|
||||||
if step["name"] and (not streets or streets[-1] != step["name"]):
|
|
||||||
streets.append(step["name"])
|
|
||||||
legs = ", ".join(f"{round(l['distance'])} m" for l in route["legs"])
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
"// GENERATED by tools/make_route.py - do not edit by hand; change WAYPOINTS there and rerun.",
|
|
||||||
"// Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.",
|
|
||||||
"//",
|
|
||||||
f"// Driving loop through {len(WAYPOINTS)} waypoints: {round(route['distance'])} m "
|
|
||||||
f"(legs {legs}), {len(pts)} points after",
|
|
||||||
f"// simplifying to {SIMPLIFY_M} m. Streets: {', '.join(streets)}.",
|
|
||||||
"#ifndef ROUTE_POINTS_H",
|
|
||||||
"#define ROUTE_POINTS_H",
|
|
||||||
'#include "route.h"',
|
|
||||||
"",
|
|
||||||
"static const route_point_t route_points[] = {",
|
|
||||||
]
|
|
||||||
lines += [f" {{ {lat}, {lon} }}," for lat, lon in pts]
|
|
||||||
lines += ["};", "", "#endif", ""]
|
|
||||||
HEADER.write_text("\n".join(lines), encoding="utf-8", newline="\n")
|
|
||||||
|
|
||||||
MAP_HTML.write_text(f"""<!doctype html><meta charset="utf-8"><title>Beacon route</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
||||||
<style>html,body,#m{{height:100%;margin:0}}</style><div id="m"></div><script>
|
|
||||||
const pts={json.dumps([[p[0] / 1e7, p[1] / 1e7] for p in pts])};
|
|
||||||
const wps={json.dumps(WAYPOINTS)};
|
|
||||||
const m=L.map('m');
|
|
||||||
L.tileLayer('https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,
|
|
||||||
attribution:'© OpenStreetMap contributors'}}).addTo(m);
|
|
||||||
const line=L.polyline(pts.concat([pts[0]]),{{color:'#d33',weight:4}}).addTo(m);
|
|
||||||
wps.forEach((w,i)=>L.marker(w,{{title:'wp'+(i+1)}}).bindTooltip('wp'+(i+1),{{permanent:true}}).addTo(m));
|
|
||||||
L.circleMarker(pts[0],{{radius:7,color:'#060'}}).bindTooltip('start').addTo(m);
|
|
||||||
m.fitBounds(line.getBounds(),{{padding:[20,20]}});
|
|
||||||
</script>
|
|
||||||
""", encoding="utf-8")
|
|
||||||
|
|
||||||
print(f"{round(route['distance'])} m, legs {legs}")
|
|
||||||
print(f"{len(route['geometry']['coordinates'])} OSRM points -> {len(pts)} after simplifying")
|
|
||||||
print(f"wrote {HEADER.relative_to(PROJECT)}, {OSRM_JSON.relative_to(PROJECT)}, "
|
|
||||||
f"{MAP_HTML.relative_to(PROJECT)}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!doctype html><meta charset="utf-8"><title>Beacon route</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
||||||
<style>html,body,#m{height:100%;margin:0}</style><div id="m"></div><script>
|
|
||||||
const pts=[[53.553177, 10.022098], [53.553447, 10.02406], [53.553579, 10.024416], [53.55364, 10.024452], [53.553713, 10.024416], [53.553848, 10.024284], [53.553872, 10.024214], [53.554039, 10.02402], [53.554888, 10.023393], [53.554947, 10.023513], [53.555414, 10.025328], [53.555357, 10.025458], [53.554607, 10.02517], [53.554313, 10.025002], [53.554257, 10.024913], [53.554276, 10.02478], [53.553998, 10.02446], [53.553872, 10.024214], [53.553814, 10.024196], [53.553572, 10.024338], [53.55353, 10.024447], [53.553509, 10.024658], [53.553683, 10.026446], [53.55376, 10.027092], [53.553852, 10.027522], [53.554111, 10.029083], [53.554017, 10.029155], [53.553989, 10.029443], [53.553958, 10.029533], [53.553229, 10.02993], [53.552798, 10.030245], [53.552513, 10.029188], [53.552422, 10.028976], [53.55229, 10.028782], [53.552289, 10.02853], [53.552261, 10.028281], [53.552062, 10.027605], [53.551982, 10.027113], [53.551967, 10.026903], [53.552022, 10.026712], [53.552093, 10.026284], [53.552185, 10.026002], [53.552284, 10.025821], [53.552779, 10.025788], [53.553865, 10.026137], [53.555166, 10.026647], [53.555579, 10.026875], [53.555962, 10.027154], [53.556112, 10.027205], [53.556243, 10.027125], [53.556383, 10.026939], [53.556961, 10.026019], [53.557953, 10.024281], [53.558409, 10.023766], [53.558585, 10.023467], [53.558497, 10.023006], [53.5584, 10.022726], [53.558073, 10.022203], [53.55791, 10.021859], [53.55745, 10.020843], [53.557056, 10.019901], [53.556813, 10.019482], [53.556491, 10.018762], [53.556399, 10.018463], [53.556254, 10.018116], [53.555978, 10.017631], [53.554218, 10.013684], [53.553972, 10.013343], [53.553735, 10.013201], [53.553476, 10.013139], [53.552384, 10.013269], [53.552498, 10.015509], [53.552489, 10.015636], [53.552444, 10.015765], [53.552303, 10.015853], [53.551761, 10.015858], [53.550444, 10.016881], [53.550136, 10.01564], [53.549925, 10.014488], [53.549847, 10.013388], [53.549863, 10.012615], [53.549911, 10.012198], [53.550426, 10.011723], [53.550572, 10.01155], [53.550763, 10.011231], [53.55084, 10.011156], [53.551, 10.011094], [53.551139, 10.011956], [53.551244, 10.012228], [53.551451, 10.013277], [53.551502, 10.013412], [53.551663, 10.013563], [53.551826, 10.01361], [53.552395, 10.013487], [53.552498, 10.015509], [53.552489, 10.015636], [53.552444, 10.015765], [53.552347, 10.015846], [53.551856, 10.015848], [53.551907, 10.016243], [53.552226, 10.017808], [53.552788, 10.019765], [53.553006, 10.020902]];
|
|
||||||
const wps=[[53.553309, 10.022043], [53.553611, 10.024146], [53.556164, 10.027121], [53.55831, 10.023362], [53.554062, 10.013515], [53.55154, 10.013398]];
|
|
||||||
const m=L.map('m');
|
|
||||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,
|
|
||||||
attribution:'© OpenStreetMap contributors'}).addTo(m);
|
|
||||||
const line=L.polyline(pts.concat([pts[0]]),{color:'#d33',weight:4}).addTo(m);
|
|
||||||
wps.forEach((w,i)=>L.marker(w,{title:'wp'+(i+1)}).bindTooltip('wp'+(i+1),{permanent:true}).addTo(m));
|
|
||||||
L.circleMarker(pts[0],{radius:7,color:'#060'}).bindTooltip('start').addTo(m);
|
|
||||||
m.fitBounds(line.getBounds(),{padding:[20,20]});
|
|
||||||
</script>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,9 @@
|
|||||||
cmake_minimum_required(VERSION 3.22)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
# 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, its own git repository beside this one and gitignored here). 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 is taken from the colleague's tree rather than vendored (it is ~90 MB). Override with
|
|
||||||
# -DVANETZA_IDF_DIR=... if it lives elsewhere.
|
|
||||||
if(NOT VANETZA_IDF_DIR)
|
|
||||||
set(VANETZA_IDF_DIR "${CMAKE_CURRENT_LIST_DIR}/../microbu-esp32c5/external/vanetza-idf")
|
|
||||||
endif()
|
|
||||||
if(NOT EXISTS "${VANETZA_IDF_DIR}/idf_component.yml")
|
|
||||||
message(FATAL_ERROR "vanetza-idf not found at ${VANETZA_IDF_DIR}: clone microbu-esp32c5 beside this "
|
|
||||||
"repository or pass -DVANETZA_IDF_DIR=<path to external/vanetza-idf>")
|
|
||||||
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)
|
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)
|
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()
|
|
||||||
|
|||||||
+11
-37
@@ -2,38 +2,24 @@
|
|||||||
|
|
||||||
## Two toolchains - use a dedicated terminal for each
|
## Two toolchains - use a dedicated terminal for each
|
||||||
|
|
||||||
Since 2026-09-23 this project builds against ESP-IDF **6.0.2** exactly
|
This project builds against the receiver-firmware's pinned ESP-IDF **6.1**.
|
||||||
(`C:\Espressif\frameworks\esp-idf-v6.0.2`): it is the vanetza-idf port (see
|
The separate `obu-cam-transmistter` project builds against the global ESP-IDF
|
||||||
NOTES.md), and vanetza-idf's `radio_c5.cmake` refuses any other version because
|
**5.5.4**. Exporting both in one PowerShell window fails: the second export
|
||||||
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
|
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
|
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`.
|
clear the state with `$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null`.
|
||||||
|
|
||||||
The build also needs the colleague's `microbu-esp32c5` checkout beside this
|
|
||||||
repository (gitignored here): vanetza-idf is taken from its
|
|
||||||
`external/vanetza-idf`. Pass `-DVANETZA_IDF_DIR=<path>` to `idf.py` if it lives
|
|
||||||
elsewhere. The first build downloads `espressif/esp-boost` into
|
|
||||||
`managed_components/`.
|
|
||||||
|
|
||||||
## Every new PowerShell session
|
## Every new PowerShell session
|
||||||
|
|
||||||
|
Activate the toolchain (obu-firmware has no esp-idf of its own — reuse the
|
||||||
|
receiver firmware's already-installed checkout):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||||
$env:IDF_TOOLS_PATH = "C:\Espressif"
|
C:\Users\Ashin\Documents\micrOBU_workspace\its-g5-receiver-firmware\esp-idf\export.ps1
|
||||||
C:\Espressif\frameworks\esp-idf-v6.0.2\export.ps1
|
idf.py --version
|
||||||
idf.py --version # v6.0.2
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Going back to the previous firmware
|
|
||||||
|
|
||||||
`firmware-backups/` in the repository root (gitignored) holds a full-flash image
|
|
||||||
of the COM3 board as it was before the port, with the esptool command to write
|
|
||||||
it back in its README.txt.
|
|
||||||
|
|
||||||
## Build & flash
|
## Build & flash
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -111,22 +97,10 @@ Work down this list — each step isolates the layer below it.
|
|||||||
CAMs reach the ESP32 but `esp_wifi_80211_tx` rejects them — a radio problem,
|
CAMs reach the ESP32 but `esp_wifi_80211_tx` rejects them — a radio problem,
|
||||||
not a link problem.
|
not a link problem.
|
||||||
|
|
||||||
## 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
|
## Notes
|
||||||
|
|
||||||
- No `git submodule update` needed here — obu-firmware has no pinned
|
- No `git submodule update` needed here — obu-firmware has no pinned
|
||||||
submodule of its own, unlike its-g5-receiver-firmware.
|
submodule of its own, unlike its-g5-receiver-firmware.
|
||||||
- Don't use the global "ESP-IDF 5.5 PowerShell" shortcut or the receiver
|
- Don't use the global "ESP-IDF 5.5 PowerShell" shortcut — always export from
|
||||||
firmware's 6.1 checkout — export from `esp-idf-v6.0.2`, the version the
|
the receiver-firmware's pinned checkout, since this firmware's undocumented
|
||||||
vanetza-idf radio's undocumented driver internals were verified against.
|
PHY/driver internals were verified against that specific build.
|
||||||
- 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.
|
|
||||||
|
|||||||
+1
-54
@@ -1,57 +1,4 @@
|
|||||||
# obu-firmware
|
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
|
||||||
|
|
||||||
## 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`, its own git repository kept beside this one and gitignored here).
|
|
||||||
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
|
Started. See `docs/04-transmit-setup.md` in the project root for build/flash
|
||||||
steps and how to validate this against your own sniffer.
|
steps and how to validate this against your own sniffer.
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
# C++ station (from microbu-esp32c5/firmware/main) plus the C files of the previous firmware that
|
# wifi_patches.c is intentionally NOT in this list anymore - superseded by
|
||||||
# still do a job here:
|
# tx_custom.c (see that file for why). Left on disk, unused, for history.
|
||||||
# gn_unwrap.c - raw receive path: 802.11/LLC-SNAP/GeoNetworking/BTP-B down to the ITS payload,
|
|
||||||
# for every frame on air, signed or not (station.cpp, forward_raw).
|
|
||||||
# geonet.c - unsigned transmit path: GN SHB + BTP-B exactly as the previous firmware built it
|
|
||||||
# (station.cpp, unsecured_request).
|
|
||||||
#
|
#
|
||||||
# Left on disk and NOT built, like cam.c before them:
|
# cam.c is ALSO intentionally not in this list anymore (Phase 03): CAM is now built on the
|
||||||
# main.c, serial_link.c/.h - the previous firmware's entry point and phone link (frame types
|
# phone and sent down over serial_link, so this firmware never encodes CAM itself. cam.c/.h are
|
||||||
# 0x01-0x05). Superseded by app_main.cpp and the station-link protocol.
|
# left on disk as the byte-exact reference the Kotlin encoder was ported from - do not delete.
|
||||||
# dot11p.c/.h - previous 802.11 framing; c5_radio.cpp frames through vanetza-idf now.
|
#
|
||||||
# Still compiled by the host tests in test/host.
|
# serial_link.c/.h - binary phone<->ESP32 framing over the native USB Serial/JTAG port
|
||||||
# tx_custom.c/.h - previous copy of the OpenTrafficMap raw TX; otm_tx_custom.c replaces it.
|
# (Phase 03; changed from a GPIO UART1 wire to native USB because neither
|
||||||
# denm.c/.h, cam.c/.h, wifi_patches.c - reference only, as before.
|
# USB-C port on the ESP32-C5-WIFI6-KIT was actually routed to that UART).
|
||||||
idf_component_register(SRCS "app_main.cpp" "board_controls.cpp" "simple_ble.cpp" "c5_radio.cpp" "otm_tx_custom.c"
|
# gn_unwrap.c/.h - strips 802.11/LLC-SNAP/GeoNetworking/BTP-B off received frames down to CAM
|
||||||
"link_protocol.cpp" "serial_link.cpp" "station.cpp" "station_test.cpp"
|
# UPER bytes, for forwarding to the phone over serial_link.
|
||||||
"link_service.cpp" "test_channel.cpp"
|
idf_component_register(
|
||||||
"gn_unwrap.c" "geonet.c"
|
SRCS "main.c" "denm.c" "geonet.c" "dot11p.c" "tx_custom.c" "serial_link.c" "gn_unwrap.c"
|
||||||
INCLUDE_DIRS "."
|
INCLUDE_DIRS "."
|
||||||
LDFRAGMENTS "linker.lf"
|
REQUIRES esp_event esp_netif nvs_flash driver esp_phy esp_driver_gpio esp_driver_usb_serial_jtag
|
||||||
REQUIRES vanetza-idf esp_wifi esp_phy esp_event bt esp_driver_gpio esp_driver_usb_serial_jtag nvs_flash esp_timer)
|
PRIV_REQUIRES esp_wifi
|
||||||
target_compile_features(${COMPONENT_LIB} PRIVATE cxx_std_17)
|
)
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
menu "micrOBU firmware"
|
|
||||||
config MICROBU_TEST_CHANNEL
|
|
||||||
bool "Test channel on the serial link (software lower tester, ETSI campaign hooks)"
|
|
||||||
default y
|
|
||||||
help
|
|
||||||
Serves serial frame type 0x11: mirror or divert every AL_DATA.request the stack
|
|
||||||
makes to the tester, inject AL_DATA.indication, GN-DATA.request for the
|
|
||||||
Security ATS. Not part of the phone interface; disable for a production image.
|
|
||||||
config MICROBU_LOG_OVER_LINK
|
|
||||||
bool "Also carry ESP_LOG output as LOG frames (0x7F) on the native USB link"
|
|
||||||
default n
|
|
||||||
help
|
|
||||||
The colleague's single-port board has no other place for its log. This board keeps
|
|
||||||
the console on UART0 (the CH343 bridge port), so the phone's link carries no text.
|
|
||||||
config MICROBU_BLE_USB_IDLE_MS
|
|
||||||
int "USB link idle time before BLE advertising resumes (ms)"
|
|
||||||
range 1000 60000
|
|
||||||
default 3000
|
|
||||||
help
|
|
||||||
BLE and the 5.9 GHz radio share the C5's single RF front end. While the phone is
|
|
||||||
using the USB link (any frame received within this window), BLE advertising is
|
|
||||||
stopped so it cannot take airtime from ITS-G5. An existing BLE connection is left
|
|
||||||
alone. The phone sends a PoTi update with every GNSS fix, which keeps the window
|
|
||||||
open for as long as it is connected over USB.
|
|
||||||
config MICROBU_TX_LED_GPIO
|
|
||||||
int "Active-low activity LED GPIO (-1: none)"
|
|
||||||
range -1 28
|
|
||||||
default -1
|
|
||||||
help
|
|
||||||
The colleague's XIAO ESP32-C5 has its yellow user LED on GPIO27. On this project's
|
|
||||||
board GPIO27 is not a plain LED, so the indicator is off unless configured.
|
|
||||||
config MICROBU_TX_LED_BLINK_MS
|
|
||||||
int "LED indication pulse (ms)"
|
|
||||||
range 10 2000
|
|
||||||
default 100
|
|
||||||
config MICROBU_LED_INVERTED_RX
|
|
||||||
bool "Inverted LED mode for receiver/bridge (normally ON, blink OFF on packet)"
|
|
||||||
default n
|
|
||||||
endmenu
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
// micrOBU firmware: the ESP32-C5 half of the VRU ITS-S on vanetza-idf.
|
|
||||||
// One station task owns stack, security entity and radio; the serial reader task only
|
|
||||||
// enqueues frames. Link messages: implementation/station-link/README.md.
|
|
||||||
#include "link_protocol.hpp"
|
|
||||||
#include "link_service.hpp"
|
|
||||||
#include "board_controls.hpp"
|
|
||||||
#include "simple_ble.hpp"
|
|
||||||
#include "serial_link.hpp"
|
|
||||||
#include "station.hpp"
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
#include "test_channel.hpp"
|
|
||||||
#endif
|
|
||||||
#include <esp_log.h>
|
|
||||||
#include <esp_task_wdt.h>
|
|
||||||
#include <esp_timer.h>
|
|
||||||
#include <nvs_flash.h>
|
|
||||||
#include <freertos/FreeRTOS.h>
|
|
||||||
#include <freertos/queue.h>
|
|
||||||
#include <freertos/task.h>
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <new>
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
const char* TAG = "microbu";
|
|
||||||
|
|
||||||
/// @brief Represents one frame received from a transport, to be queued for the station task.
|
|
||||||
struct Incoming {
|
|
||||||
microbu::LinkTransport transport;
|
|
||||||
microbu::serial::Frame frame;
|
|
||||||
};
|
|
||||||
|
|
||||||
QueueHandle_t frames = nullptr; // FIFO of Incoming* (8 bytes each); frame bytes never enter the queue itself
|
|
||||||
|
|
||||||
/// @brief Attempts to heap-allocate an Incoming so it can be pushed onto the frames queue.
|
|
||||||
/// @param transport Transport the frame arrived on.
|
|
||||||
/// @param frame Decoded frame to take ownership of.
|
|
||||||
/// @return Owning pointer to push onto the queue, or nullptr if allocation failed.
|
|
||||||
Incoming* try_new_incoming(microbu::LinkTransport transport, microbu::serial::Frame frame) noexcept {
|
|
||||||
try {
|
|
||||||
// std::move here steals frame's payload buffer into the heap Incoming (no byte copy);
|
|
||||||
// it has nothing to do with the queue below, which only ever transports the resulting pointer.
|
|
||||||
return new Incoming {transport, std::move(frame)};
|
|
||||||
} catch (const std::bad_alloc&) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @brief Dispatches one queued frame to the link service (or the test channel); logs and drops it on failure.
|
|
||||||
void handle_incoming(microbu::Station& station, microbu::LinkService& link, const Incoming& incoming) {
|
|
||||||
try {
|
|
||||||
if (incoming.frame.type == microbu::serial::FrameType::LINK)
|
|
||||||
link.handle(incoming.frame.payload, incoming.transport);
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
else if (incoming.transport == microbu::LinkTransport::serial &&
|
|
||||||
incoming.frame.type == microbu::serial::FrameType::TEST)
|
|
||||||
microbu::serial::write(microbu::serial::FrameType::TEST,
|
|
||||||
microbu::test_channel_execute(station, incoming.frame.payload));
|
|
||||||
#endif
|
|
||||||
} catch (const std::bad_alloc&) {
|
|
||||||
ESP_LOGE(TAG, "out of memory while handling a frame");
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
ESP_LOGE(TAG, "frame handling failed: %s", e.what());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @brief MicrOBU: BLE and ITS-G5 share the C5's one RF front end. While the phone is talking over
|
|
||||||
/// USB there is no reason for BLE to take airtime, so advertising stops until the USB link has been
|
|
||||||
/// quiet for CONFIG_MICROBU_BLE_USB_IDLE_MS. A phone already connected over BLE is left connected.
|
|
||||||
void pause_ble_while_usb_in_use() {
|
|
||||||
const auto last = microbu::serial::last_frame_ms();
|
|
||||||
const auto now = static_cast<std::uint32_t>(esp_timer_get_time() / 1000);
|
|
||||||
const bool usb_in_use = last != 0 && now - last < CONFIG_MICROBU_BLE_USB_IDLE_MS;
|
|
||||||
microbu::ble::set_advertising_allowed(!usb_in_use);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @brief FreeRTOS task body: owns the station, link service and board controls. Drains the incoming queue.
|
|
||||||
void station_task(void*) {
|
|
||||||
microbu::Station station;
|
|
||||||
microbu::LinkService link(station);
|
|
||||||
microbu::BoardControls controls;
|
|
||||||
station.on_disseminated([&controls] { controls.blink(); });
|
|
||||||
station.on_received([&controls] { controls.blink(); });
|
|
||||||
ESP_LOGI(TAG, "station task ready; the phone/PC configures the station over BLE or USB Serial-JTAG");
|
|
||||||
for (;;) { /// main loop: drain the incoming queue, tick the station and link service, tick the controls
|
|
||||||
Incoming* incoming = nullptr;
|
|
||||||
// MicrOBU: at least one tick. At CONFIG_FREERTOS_HZ=100 pdMS_TO_TICKS(5) is 0, so the wait
|
|
||||||
// never blocked: this priority-10 task spun on the single core, starved every lower-priority
|
|
||||||
// task and the idle task (task watchdog), for as long as the board ran.
|
|
||||||
if (xQueueReceive(frames, &incoming, std::max<TickType_t>(1, pdMS_TO_TICKS(5))) == pdTRUE && incoming) {
|
|
||||||
handle_incoming(station, link, *incoming);
|
|
||||||
delete incoming;
|
|
||||||
}
|
|
||||||
station.tick();
|
|
||||||
link.tick();
|
|
||||||
controls.tick();
|
|
||||||
pause_ble_while_usb_in_use();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// @brief Firmware entry point: initializes NVS, watchdog, serial/BLE transports, and starts the station task.
|
|
||||||
extern "C" void app_main() {
|
|
||||||
// NVS: the credential store (and the Wi-Fi driver's calibration data)
|
|
||||||
esp_err_t nvs = nvs_flash_init();
|
|
||||||
if (nvs == ESP_ERR_NVS_NO_FREE_PAGES || nvs == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
|
||||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
|
||||||
nvs = nvs_flash_init();
|
|
||||||
}
|
|
||||||
ESP_ERROR_CHECK(nvs);
|
|
||||||
// Signing and verification take tens of milliseconds each on the C5. This keeps the idle
|
|
||||||
// watchdog from reporting a busy station task (5 s default) while a credential bundle applies.
|
|
||||||
esp_task_wdt_config_t watchdog = {};
|
|
||||||
watchdog.timeout_ms = 30000;
|
|
||||||
watchdog.idle_core_mask = (1 << portNUM_PROCESSORS) - 1;
|
|
||||||
watchdog.trigger_panic = false; // log, don't reboot, on timeout
|
|
||||||
esp_task_wdt_reconfigure(&watchdog);
|
|
||||||
|
|
||||||
frames = xQueueCreate(32, sizeof(Incoming*));
|
|
||||||
microbu::serial::start([](microbu::serial::Frame frame) {
|
|
||||||
auto* copy = try_new_incoming(microbu::LinkTransport::serial, std::move(frame));
|
|
||||||
if (!copy) return;
|
|
||||||
// xQueueSend copies the 8-byte pointer value into the queue, not *copy itself.
|
|
||||||
// station_task then pops pointers FIFO and owns/deletes whatever it receives.
|
|
||||||
if (xQueueSend(frames, ©, 0) != pdTRUE) delete copy; // the phone retries on a missing RESULT
|
|
||||||
});
|
|
||||||
if (!microbu::ble::start([](microbu::link::Bytes message) {
|
|
||||||
microbu::serial::Frame frame {microbu::serial::FrameType::LINK, std::move(message)};
|
|
||||||
auto* copy = try_new_incoming(microbu::LinkTransport::ble, std::move(frame));
|
|
||||||
if (!copy) return;
|
|
||||||
if (xQueueSend(frames, ©, 0) != pdTRUE) delete copy; // same hand-off as the serial callback above
|
|
||||||
})) {
|
|
||||||
ESP_LOGE(TAG, "BLE station link failed to start. USB remains available");
|
|
||||||
}
|
|
||||||
ESP_LOGI(TAG, "MicrOBU obu-firmware on vanetza-idf, station-link v1 + V2X_RX, console on UART0%s",
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
", test channel enabled");
|
|
||||||
#else
|
|
||||||
"");
|
|
||||||
#endif
|
|
||||||
xTaskCreate(station_task, "station", 12288, nullptr, 10, nullptr);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
#include "board_controls.hpp"
|
|
||||||
|
|
||||||
#include <driver/gpio.h>
|
|
||||||
#include <esp_log.h>
|
|
||||||
#include <esp_timer.h>
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
namespace {
|
|
||||||
const char* TAG = "board";
|
|
||||||
constexpr std::int64_t blink_us = CONFIG_MICROBU_TX_LED_BLINK_MS * 1000LL;
|
|
||||||
}
|
|
||||||
|
|
||||||
BoardControls::BoardControls() {
|
|
||||||
#if CONFIG_MICROBU_TX_LED_GPIO < 0
|
|
||||||
ESP_LOGI(TAG, "no activity LED configured");
|
|
||||||
#else
|
|
||||||
gpio_config_t led = {};
|
|
||||||
led.pin_bit_mask = 1ULL << CONFIG_MICROBU_TX_LED_GPIO;
|
|
||||||
led.mode = GPIO_MODE_OUTPUT;
|
|
||||||
led.pull_up_en = GPIO_PULLUP_DISABLE;
|
|
||||||
led.pull_down_en = GPIO_PULLDOWN_DISABLE;
|
|
||||||
led.intr_type = GPIO_INTR_DISABLE;
|
|
||||||
ESP_ERROR_CHECK(gpio_config(&led));
|
|
||||||
#if CONFIG_MICROBU_LED_INVERTED_RX
|
|
||||||
mode_ = LedMode::inverted_rx;
|
|
||||||
#else
|
|
||||||
mode_ = LedMode::normal_tx;
|
|
||||||
#endif
|
|
||||||
apply_led_state();
|
|
||||||
ESP_LOGI(TAG, "LED GPIO %d (mode: %s)", CONFIG_MICROBU_TX_LED_GPIO,
|
|
||||||
mode_ == LedMode::inverted_rx ? "inverted_rx (normally ON)" : "normal_tx (normally OFF)");
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void BoardControls::apply_led_state() {
|
|
||||||
#if CONFIG_MICROBU_TX_LED_GPIO >= 0
|
|
||||||
// The XIAO ESP32-C5 user LED is wired active-low (0 = ON, 1 = OFF).
|
|
||||||
// In normal_tx mode: default OFF (1), pulsing ON (0).
|
|
||||||
// In inverted_rx mode: default ON (0), pulsing OFF (1).
|
|
||||||
bool led_illuminated = false;
|
|
||||||
if (mode_ == LedMode::normal_tx) {
|
|
||||||
led_illuminated = pulsing_;
|
|
||||||
} else {
|
|
||||||
led_illuminated = !pulsing_;
|
|
||||||
}
|
|
||||||
gpio_set_level(static_cast<gpio_num_t>(CONFIG_MICROBU_TX_LED_GPIO), led_illuminated ? 0 : 1);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void BoardControls::blink() {
|
|
||||||
pulse_until_us_ = esp_timer_get_time() + blink_us;
|
|
||||||
if (!pulsing_) {
|
|
||||||
pulsing_ = true;
|
|
||||||
apply_led_state();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void BoardControls::tick() {
|
|
||||||
if (pulsing_ && esp_timer_get_time() >= pulse_until_us_) {
|
|
||||||
pulsing_ = false;
|
|
||||||
apply_led_state();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
|
|
||||||
/// XIAO ESP32-C5 active-low user LED: a visual indicator for TX/RX activity.
|
|
||||||
class BoardControls {
|
|
||||||
public:
|
|
||||||
/// @brief Configures the LED GPIO and sets its idle state.
|
|
||||||
BoardControls();
|
|
||||||
|
|
||||||
enum class LedMode {
|
|
||||||
normal_tx, // Normally off, pulses on upon frame dissemination
|
|
||||||
inverted_rx // Normally on, pulses off upon frame reception
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @brief Triggers visual indication (blink on in normal_tx mode, blink off in inverted_rx mode).
|
|
||||||
void blink();
|
|
||||||
/// @brief Updates LED state and checks indication timers; call periodically.
|
|
||||||
void tick();
|
|
||||||
|
|
||||||
private:
|
|
||||||
void apply_led_state();
|
|
||||||
|
|
||||||
LedMode mode_ = LedMode::normal_tx;
|
|
||||||
bool pulsing_ = false;
|
|
||||||
std::int64_t pulse_until_us_ = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
#include "c5_radio.hpp"
|
|
||||||
#include "otm_tx_custom.h"
|
|
||||||
#include <vanetza_idf/its_g5_frame.hpp>
|
|
||||||
#include <esp_event.h>
|
|
||||||
#include <esp_log.h>
|
|
||||||
#include <esp_wifi.h>
|
|
||||||
#include <hal/modem_syscon_ll.h>
|
|
||||||
#include <esp_timer.h>
|
|
||||||
#include <freertos/FreeRTOS.h>
|
|
||||||
#include <freertos/queue.h>
|
|
||||||
#include <algorithm>
|
|
||||||
#include <atomic>
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstring>
|
|
||||||
#include <iterator>
|
|
||||||
#include <limits>
|
|
||||||
#include <mutex>
|
|
||||||
|
|
||||||
extern "C" { //all of these arentt in the esp-idf public api
|
|
||||||
// phy_11p_set/phy_change_channel: undocumented esp_phy/lib/esp32c5/libphy.a entry points, not
|
|
||||||
// declared in any Espressif header.
|
|
||||||
// The call sites and argument values below (phy_11p_set(1, 0), phy_change_channel(freq, 1, 0, 0))
|
|
||||||
// are copied from OpenTrafficMap's its-g5-receiver-firmware_txenabled, main/cmd_sniffer.c
|
|
||||||
// (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled, community reverse
|
|
||||||
// engineering, no stated license).
|
|
||||||
|
|
||||||
void phy_11p_set(int enable, int arg2);
|
|
||||||
void phy_change_channel(int freq_mhz, int arg2_ignored, int arg3_ignored, int arg4);
|
|
||||||
|
|
||||||
// phy_get_cca/phy_set_cca: register 0x600a701c[7:0] holds the configured CCA energy
|
|
||||||
// detection threshold (defaults to 191 = 0xBF = -65 dBm in 8-bit two's complement).
|
|
||||||
// phy_get_cca() reads this configured threshold.
|
|
||||||
int phy_get_cca(void);
|
|
||||||
void phy_set_cca(int enable, int threshold);
|
|
||||||
|
|
||||||
// phy_get_cca_cnt/phy_set_cca_cnt: register 0x600a7c58 arms the 27-bit hardware CCA
|
|
||||||
// cycle counters (0x600a7c5c = total cycles, 0x600a7c60 = busy cycles; confirmed on
|
|
||||||
// hardware -- out[0] free-runs at ~40 MHz, out[1] stays near zero on a quiet channel).
|
|
||||||
// phy_get_cca_cnt returns bit 27 (busy/status bit) and writes both counters to out[2].
|
|
||||||
int phy_get_cca_cnt(std::int32_t out[2]);
|
|
||||||
void phy_set_cca_cnt(std::int32_t val, bool enable);
|
|
||||||
void phy_enable_cca(void);
|
|
||||||
void phy_disable_cca(void);
|
|
||||||
int phy_get_noise_floor(void);
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
namespace {
|
|
||||||
const char* TAG = "c5_radio";
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class C5Radio::Impl {
|
|
||||||
public:
|
|
||||||
/// @brief Raw received frame metadata and bytes from the promiscuous RX callback.
|
|
||||||
struct Raw {
|
|
||||||
std::uint16_t length;
|
|
||||||
std::int8_t rssi;
|
|
||||||
std::uint32_t timestamp;
|
|
||||||
std::uint8_t bytes[2346]; // max 802.11 frame size
|
|
||||||
};
|
|
||||||
C5RadioConfig config;
|
|
||||||
QueueHandle_t queue = nullptr;
|
|
||||||
bool initialized = false, started = false, own_event_loop = false;
|
|
||||||
std::uint16_t sequence = 0;
|
|
||||||
std::atomic<std::uint32_t> dropped {0};
|
|
||||||
static Impl* active;
|
|
||||||
static std::mutex callback_mutex;
|
|
||||||
|
|
||||||
explicit Impl(C5RadioConfig c) : config(c) {}
|
|
||||||
|
|
||||||
static void receive(void* buffer, wifi_promiscuous_pkt_type_t type) {
|
|
||||||
if (!buffer || type != WIFI_PKT_DATA) return;
|
|
||||||
const auto* packet = static_cast<const wifi_promiscuous_pkt_t*>(buffer);
|
|
||||||
if (packet->rx_ctrl.rx_state != 0) return;
|
|
||||||
const auto length = packet->rx_ctrl.sig_len;
|
|
||||||
std::lock_guard<std::mutex> lock(callback_mutex);
|
|
||||||
if (!active || !active->queue) return;
|
|
||||||
// MicrOBU: a frame too short to hold any GN packet is not ITS traffic, so it is ignored
|
|
||||||
// rather than counted; dropped_frames() then means what the phone shows it as: lost frames.
|
|
||||||
if (length < 38) return;
|
|
||||||
if (length > sizeof(Raw::bytes)) { ++active->dropped; return; }
|
|
||||||
// MicrOBU: static, not a local. Raw is ~2.4 KB and this runs on the Wi-Fi driver's own task,
|
|
||||||
// several frames deep, on a stack of roughly 3.5 KB: the previous firmware hit exactly this
|
|
||||||
// with an 800-byte buffer (obu-firmware commit 04b0076). Safe as a static because only that
|
|
||||||
// one task calls this, and xQueueSend copies it out before the next call.
|
|
||||||
static Raw raw;
|
|
||||||
raw.length = length;
|
|
||||||
raw.rssi = packet->rx_ctrl.rssi;
|
|
||||||
raw.timestamp = packet->rx_ctrl.timestamp;
|
|
||||||
std::memcpy(raw.bytes, packet->payload, length);
|
|
||||||
if (xQueueSend(active->queue, &raw, 0) != pdTRUE) ++active->dropped;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
C5Radio::Impl* C5Radio::Impl::active = nullptr;
|
|
||||||
std::mutex C5Radio::Impl::callback_mutex;
|
|
||||||
|
|
||||||
C5Radio::C5Radio(C5RadioConfig c) : impl_(std::make_unique<Impl>(c)) {}
|
|
||||||
C5Radio::~C5Radio() { stop(); }
|
|
||||||
|
|
||||||
esp_err_t C5Radio::start() {
|
|
||||||
auto& p = *impl_;
|
|
||||||
const auto& c = p.config;
|
|
||||||
if (p.initialized) return ESP_ERR_INVALID_STATE;
|
|
||||||
// Reject channel/power/queue config outside the supported ITS-G5 range
|
|
||||||
if (c.channel_number < 172 || c.channel_number > 184 || c.channel_number % 2 ||
|
|
||||||
!std::isfinite(c.transmit_power_dbm) || c.transmit_power_dbm < 2 || c.transmit_power_dbm > 23 ||
|
|
||||||
std::floor(c.transmit_power_dbm * 4) != c.transmit_power_dbm * 4 ||
|
|
||||||
c.receive_queue_length == 0 || c.receive_queue_length > 32) {
|
|
||||||
return ESP_ERR_INVALID_ARG;
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(Impl::callback_mutex);
|
|
||||||
if (Impl::active) return ESP_ERR_INVALID_STATE;
|
|
||||||
p.queue = xQueueCreate(c.receive_queue_length, sizeof(Impl::Raw));
|
|
||||||
if (!p.queue) return ESP_ERR_NO_MEM;
|
|
||||||
Impl::active = &p;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto result = esp_event_loop_create_default();
|
|
||||||
p.own_event_loop = (result == ESP_OK);
|
|
||||||
if (result != ESP_OK && result != ESP_ERR_INVALID_STATE) {
|
|
||||||
stop();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Establish modem FE clock for 802.11p OFDM
|
|
||||||
modem_syscon_ll_enable_fe_40m_clock(&MODEM_SYSCON, true);
|
|
||||||
|
|
||||||
wifi_init_config_t wifi = WIFI_INIT_CONFIG_DEFAULT();
|
|
||||||
wifi.nvs_enable = 0;
|
|
||||||
result = esp_wifi_init(&wifi);
|
|
||||||
if (result != ESP_OK) {
|
|
||||||
stop();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
p.initialized = true;
|
|
||||||
|
|
||||||
auto attempt = [&](esp_err_t r) { if (result == ESP_OK) result = r; };
|
|
||||||
attempt(esp_wifi_set_storage(WIFI_STORAGE_RAM));
|
|
||||||
attempt(esp_wifi_set_mode(WIFI_MODE_STA));
|
|
||||||
if (result == ESP_OK) {
|
|
||||||
result = esp_wifi_start();
|
|
||||||
p.started = (result == ESP_OK);
|
|
||||||
}
|
|
||||||
if (result != ESP_OK) {
|
|
||||||
stop();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MicrOBU, from the previous firmware: under the default WIFI_COUNTRY_POLICY_AUTO the driver's
|
|
||||||
// 5 GHz table does not authorise transmission on the ITS band, which there left RX working and
|
|
||||||
// TX silent. The colleague's board transmits without this (esp_wifi_80211_tx_custom goes around
|
|
||||||
// that gate), so it is belt and braces here: manual policy, every 5 GHz channel enabled. Not
|
|
||||||
// fatal if refused.
|
|
||||||
wifi_country_t country = {};
|
|
||||||
country.cc[0] = 'U'; country.cc[1] = 'S';
|
|
||||||
country.schan = 1;
|
|
||||||
country.nchan = 11;
|
|
||||||
country.policy = WIFI_COUNTRY_POLICY_MANUAL;
|
|
||||||
country.wifi_5g_channel_mask = 0x1FFFFFFE;
|
|
||||||
if (const auto e = esp_wifi_set_country(&country); e != ESP_OK)
|
|
||||||
ESP_LOGW(TAG, "esp_wifi_set_country(MANUAL) failed: %s (continuing)", esp_err_to_name(e));
|
|
||||||
attempt(esp_wifi_set_band_mode(WIFI_BAND_MODE_5G_ONLY));
|
|
||||||
attempt(esp_wifi_set_ps(WIFI_PS_NONE));
|
|
||||||
attempt(esp_wifi_set_max_tx_power(static_cast<std::int8_t>(c.transmit_power_dbm * 4)));
|
|
||||||
|
|
||||||
wifi_promiscuous_filter_t filter {};
|
|
||||||
filter.filter_mask = WIFI_PROMIS_FILTER_MASK_DATA;
|
|
||||||
attempt(esp_wifi_set_promiscuous_filter(&filter));
|
|
||||||
attempt(esp_wifi_set_promiscuous_rx_cb(Impl::receive));
|
|
||||||
attempt(esp_wifi_set_promiscuous(true));
|
|
||||||
if (result != ESP_OK) {
|
|
||||||
stop();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10 MHz channel bandwidth (ITS-G5 / 802.11p)
|
|
||||||
phy_11p_set(1, 0);
|
|
||||||
phy_change_channel(5000 + 5 * c.channel_number, 1, 0, 0); // = 5900 MHz
|
|
||||||
|
|
||||||
// Enable and arm hardware CCA counters (40 MHz baseband clock timebase) for DCC
|
|
||||||
phy_enable_cca();
|
|
||||||
phy_set_cca_cnt(0x07FFFFFF, true);
|
|
||||||
|
|
||||||
// MicrOBU, from the previous firmware: the power request is a ceiling, not a promise. The driver
|
|
||||||
// clamps it to its calibrated table, and 5900 MHz is above the chip's rated range, so log what
|
|
||||||
// the driver admits to rather than what was asked for.
|
|
||||||
std::int8_t power_q = 0;
|
|
||||||
if (esp_wifi_get_max_tx_power(&power_q) == ESP_OK) {
|
|
||||||
ESP_LOGI(TAG, "tx power: %d quarter-dBm = %d.%02d dBm (%.2f requested)",
|
|
||||||
power_q, power_q / 4, (power_q % 4) * 25, c.transmit_power_dbm);
|
|
||||||
}
|
|
||||||
|
|
||||||
ESP_LOGI(TAG, "ITS-G5 802.11p radio started on channel %u (5900 MHz), %s",
|
|
||||||
unsigned(c.channel_number), c.laboratory_transmission ? "TX/RX" : "RX only");
|
|
||||||
return ESP_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
void C5Radio::stop() {
|
|
||||||
if (!impl_) return;
|
|
||||||
auto& p = *impl_;
|
|
||||||
if (p.started) esp_wifi_set_promiscuous(false);
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(Impl::callback_mutex);
|
|
||||||
if (Impl::active == &p) Impl::active = nullptr;
|
|
||||||
if (p.queue) {
|
|
||||||
vQueueDelete(p.queue);
|
|
||||||
p.queue = nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p.started) esp_wifi_stop();
|
|
||||||
if (p.initialized) esp_wifi_deinit();
|
|
||||||
if (p.own_event_loop) esp_event_loop_delete_default();
|
|
||||||
p.started = p.initialized = p.own_event_loop = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
vanetza_idf::Result C5Radio::request(vanetza_idf::AlDataRequest request) {
|
|
||||||
auto& p = *impl_;
|
|
||||||
if (!p.started) return vanetza_idf::Result::rejected;
|
|
||||||
if (!p.config.laboratory_transmission) return vanetza_idf::Result::unsupported;
|
|
||||||
|
|
||||||
if (request.bandwidth_mhz != 10 || request.channel_number != p.config.channel_number ||
|
|
||||||
request.transceiver_id != 0 || request.transceiver_mode || request.datastream_id ||
|
|
||||||
request.transmit_power_dbm != p.config.transmit_power_dbm) {
|
|
||||||
return vanetza_idf::Result::unsupported;
|
|
||||||
}
|
|
||||||
|
|
||||||
constexpr wifi_phy_rate_t rates[] = {
|
|
||||||
WIFI_PHY_RATE_6M, WIFI_PHY_RATE_9M, WIFI_PHY_RATE_12M,
|
|
||||||
WIFI_PHY_RATE_18M, WIFI_PHY_RATE_24M, WIFI_PHY_RATE_36M,
|
|
||||||
WIFI_PHY_RATE_48M, WIFI_PHY_RATE_54M
|
|
||||||
};
|
|
||||||
const auto index = static_cast<unsigned>(request.mcs);
|
|
||||||
if (index >= std::size(rates)) return vanetza_idf::Result::invalid_argument;
|
|
||||||
|
|
||||||
vanetza::ByteBuffer bytes;
|
|
||||||
const auto encoded = vanetza_idf::its_g5::encode_frame(request, p.sequence, bytes);
|
|
||||||
if (encoded != vanetza_idf::Result::accepted) {
|
|
||||||
ESP_LOGE(TAG, "encode_frame failed: %d", int(encoded));
|
|
||||||
return encoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.sequence = (p.sequence + 1) & 4095;
|
|
||||||
wifi_tx_rate_config_t rate {};
|
|
||||||
rate.phymode = WIFI_PHY_MODE_11A;
|
|
||||||
rate.rate = rates[index];
|
|
||||||
|
|
||||||
// Transmit frame via 802.11p driver
|
|
||||||
const auto result = esp_wifi_80211_tx_custom(
|
|
||||||
WIFI_IF_STA, bytes.data(), bytes.size(), false,
|
|
||||||
&rate, WIFI_BAND_5G, WIFI_BW20);
|
|
||||||
|
|
||||||
if (result != ESP_OK) {
|
|
||||||
ESP_LOGW(TAG, "esp_wifi_80211_tx_custom failed: %s (0x%x)", esp_err_to_name(result), result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result == ESP_OK ? vanetza_idf::Result::accepted :
|
|
||||||
result == ESP_ERR_NO_MEM ? vanetza_idf::Result::resource_limit :
|
|
||||||
vanetza_idf::Result::rejected;
|
|
||||||
}
|
|
||||||
|
|
||||||
void C5Radio::poll(const Receive& receive, const Capture& capture) {
|
|
||||||
auto& p = *impl_;
|
|
||||||
if (!p.queue) return;
|
|
||||||
Impl::Raw raw {};
|
|
||||||
for (unsigned i = 0; i < p.config.receive_queue_length && xQueueReceive(p.queue, &raw, 0) == pdTRUE; ++i) {
|
|
||||||
if (capture) {
|
|
||||||
capture(vanetza::ByteBuffer(raw.bytes, raw.bytes + raw.length), raw.rssi, raw.timestamp);
|
|
||||||
}
|
|
||||||
vanetza_idf::AlDataIndication ind;
|
|
||||||
if (vanetza_idf::its_g5::decode_frame(raw.bytes, raw.length, true, ind) != vanetza_idf::Result::accepted) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ind.channel_number = p.config.channel_number;
|
|
||||||
ind.received_power_dbm = raw.rssi;
|
|
||||||
if (receive) receive(std::move(ind));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::uint32_t C5Radio::dropped_frames() const {
|
|
||||||
return impl_->dropped.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
esp_err_t C5Radio::transmit_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
|
|
||||||
unsigned count, unsigned interval_ms, std::size_t payload_len) {
|
|
||||||
auto& p = *impl_;
|
|
||||||
if (!p.started) return ESP_ERR_INVALID_STATE;
|
|
||||||
if (channel < 172 || channel > 184 || channel % 2 != 0) return ESP_ERR_INVALID_ARG;
|
|
||||||
if (power_dbm < 2.0 || power_dbm > 20.0) return ESP_ERR_INVALID_ARG;
|
|
||||||
if (mcs > 7) return ESP_ERR_INVALID_ARG;
|
|
||||||
if (count == 0) return ESP_OK;
|
|
||||||
|
|
||||||
constexpr wifi_phy_rate_t rates[] = {
|
|
||||||
WIFI_PHY_RATE_6M, WIFI_PHY_RATE_9M, WIFI_PHY_RATE_12M,
|
|
||||||
WIFI_PHY_RATE_18M, WIFI_PHY_RATE_24M, WIFI_PHY_RATE_36M,
|
|
||||||
WIFI_PHY_RATE_48M, WIFI_PHY_RATE_54M
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dynamically retune channel or adjust TX power if different from running config
|
|
||||||
if (channel != p.config.channel_number) {
|
|
||||||
phy_11p_set(1, 0);
|
|
||||||
phy_change_channel(5000 + 5 * channel, 1, 0, 0);
|
|
||||||
p.config.channel_number = channel;
|
|
||||||
}
|
|
||||||
const auto power_quarter_db = static_cast<std::int8_t>(std::round(power_dbm * 4.0)); // esp_wifi power is in 0.25 dBm units
|
|
||||||
esp_wifi_set_max_tx_power(power_quarter_db);
|
|
||||||
p.config.transmit_power_dbm = power_dbm;
|
|
||||||
|
|
||||||
wifi_tx_rate_config_t rate {};
|
|
||||||
rate.phymode = WIFI_PHY_MODE_11A;
|
|
||||||
rate.rate = rates[mcs];
|
|
||||||
|
|
||||||
// Assemble a standard IEEE 802.11 QoS data / LLC frame (EtherType 0x8947 GeoNetworking)
|
|
||||||
// Header: Frame Control (0x0088 QoS Data), Duration (0x0000), Addr1 (Broadcast FF..FF),
|
|
||||||
// Addr2 (Source 02:00:00:00:00:01), Addr3 (BSSID FF..FF), Sequence, QoS Control (0x0000),
|
|
||||||
// LLC/SNAP header (AA AA 03 00 00 00 89 47).
|
|
||||||
std::vector<std::uint8_t> frame;
|
|
||||||
const std::size_t actual_payload = std::clamp<std::size_t>(payload_len, 32, 1400);
|
|
||||||
frame.reserve(34 + actual_payload);
|
|
||||||
|
|
||||||
// MAC Header (26 bytes with QoS)
|
|
||||||
frame.push_back(0x88); frame.push_back(0x00); // Frame Control: QoS Data
|
|
||||||
frame.push_back(0x00); frame.push_back(0x00); // Duration
|
|
||||||
for (int i = 0; i < 6; ++i) frame.push_back(0xFF); // RA / Destination: Broadcast
|
|
||||||
frame.push_back(0x02); frame.push_back(0x00); frame.push_back(0x00);
|
|
||||||
frame.push_back(0x00); frame.push_back(0x00); frame.push_back(0x01); // TA / Source
|
|
||||||
for (int i = 0; i < 6; ++i) frame.push_back(0xFF); // BSSID: Broadcast
|
|
||||||
frame.push_back(0x00); frame.push_back(0x00); // Sequence (updated per frame)
|
|
||||||
frame.push_back(0x00); frame.push_back(0x00); // QoS Control
|
|
||||||
|
|
||||||
// LLC/SNAP header (8 bytes)
|
|
||||||
frame.push_back(0xAA); frame.push_back(0xAA); frame.push_back(0x03);
|
|
||||||
frame.push_back(0x00); frame.push_back(0x00); frame.push_back(0x00);
|
|
||||||
frame.push_back(0x89); frame.push_back(0x47); // EtherType 0x8947 (GeoNetworking)
|
|
||||||
|
|
||||||
// Test payload with identifiable sequence numbers
|
|
||||||
const std::size_t header_len = frame.size();
|
|
||||||
frame.resize(header_len + actual_payload, 0x5A);
|
|
||||||
|
|
||||||
esp_err_t last_err = ESP_OK;
|
|
||||||
for (unsigned i = 0; i < count; ++i) {
|
|
||||||
p.sequence = (p.sequence + 1) & 4095;
|
|
||||||
frame[22] = static_cast<std::uint8_t>((p.sequence << 4) & 0xF0);
|
|
||||||
frame[23] = static_cast<std::uint8_t>((p.sequence >> 4) & 0xFF);
|
|
||||||
|
|
||||||
// Put burst counter inside payload
|
|
||||||
frame[header_len + 0] = static_cast<std::uint8_t>(i & 0xFF);
|
|
||||||
frame[header_len + 1] = static_cast<std::uint8_t>((i >> 8) & 0xFF);
|
|
||||||
|
|
||||||
esp_err_t err = esp_wifi_80211_tx_custom(
|
|
||||||
WIFI_IF_STA, frame.data(), frame.size(), false,
|
|
||||||
&rate, WIFI_BAND_5G, WIFI_BW20);
|
|
||||||
|
|
||||||
if (err == ESP_ERR_NO_MEM) {
|
|
||||||
// Buffer briefly full: yield task to allow DMA descriptors to clear
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(2));
|
|
||||||
err = esp_wifi_80211_tx_custom(
|
|
||||||
WIFI_IF_STA, frame.data(), frame.size(), false,
|
|
||||||
&rate, WIFI_BAND_5G, WIFI_BW20);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err != ESP_OK) {
|
|
||||||
last_err = err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto delay_ms = std::max<unsigned>(interval_ms, 2);
|
|
||||||
if (i + 1 < count) {
|
|
||||||
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return last_err;
|
|
||||||
}
|
|
||||||
|
|
||||||
CcaSampleResult C5Radio::sample_cca(unsigned duration_ms) {
|
|
||||||
CcaSampleResult result;
|
|
||||||
auto& p = *impl_;
|
|
||||||
if (!p.started) return result;
|
|
||||||
|
|
||||||
result.noise_floor_dbm = phy_get_noise_floor();
|
|
||||||
|
|
||||||
// Enable CCA hardware and arm the 27-bit cycle counters with full window (0x07FFFFFF).
|
|
||||||
phy_enable_cca();
|
|
||||||
phy_set_cca_cnt(0x07FFFFFF, true);
|
|
||||||
|
|
||||||
std::int32_t cca_cnt_before[2] = {};
|
|
||||||
phy_get_cca_cnt(cca_cnt_before);
|
|
||||||
|
|
||||||
const auto t_start = esp_timer_get_time();
|
|
||||||
const auto t_deadline = t_start + static_cast<std::int64_t>(duration_ms) * 1000;
|
|
||||||
std::int64_t last_t = t_start;
|
|
||||||
result.min_delta_us = std::numeric_limits<std::uint32_t>::max();
|
|
||||||
|
|
||||||
while (esp_timer_get_time() < t_deadline) {
|
|
||||||
std::int32_t cur_cnt[2] = {};
|
|
||||||
const auto status = phy_get_cca_cnt(cur_cnt);
|
|
||||||
const auto cca_threshold = phy_get_cca();
|
|
||||||
const auto now = esp_timer_get_time();
|
|
||||||
|
|
||||||
if (result.samples == 0) {
|
|
||||||
result.first_cca = cca_threshold;
|
|
||||||
} else {
|
|
||||||
const auto delta = static_cast<std::uint32_t>(now - last_t);
|
|
||||||
result.min_delta_us = std::min(result.min_delta_us, delta);
|
|
||||||
result.max_delta_us = std::max(result.max_delta_us, delta);
|
|
||||||
}
|
|
||||||
last_t = now;
|
|
||||||
result.last_cca = cca_threshold;
|
|
||||||
if (status) ++result.busy_count;
|
|
||||||
++result.samples;
|
|
||||||
|
|
||||||
// Cooperative yielding: prevent starving IDLE task, esp_timer, and bb_wdt
|
|
||||||
// on the single-core C5 during multi-millisecond polling windows.
|
|
||||||
if ((result.samples & 0x3F) == 0) {
|
|
||||||
taskYIELD();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.duration_us = static_cast<std::uint32_t>(esp_timer_get_time() - t_start);
|
|
||||||
std::int32_t cca_cnt_after[2] = {};
|
|
||||||
result.cca_status = phy_get_cca_cnt(cca_cnt_after);
|
|
||||||
|
|
||||||
// Both words are 27-bit hardware counters (mask 0x07FFFFFF).
|
|
||||||
constexpr std::int32_t mask27 = 0x07FFFFFF;
|
|
||||||
auto delta27 = [](std::int32_t after, std::int32_t before) -> std::int32_t {
|
|
||||||
std::int32_t diff = (after & mask27) - (before & mask27);
|
|
||||||
if (diff < 0) diff += (mask27 + 1);
|
|
||||||
return diff;
|
|
||||||
};
|
|
||||||
result.cca_total_cycles_delta = delta27(cca_cnt_after[0], cca_cnt_before[0]);
|
|
||||||
result.cca_busy_cycles_delta = delta27(cca_cnt_after[1], cca_cnt_before[1]);
|
|
||||||
|
|
||||||
if (result.samples < 2) result.min_delta_us = 0;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
#endif // CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
|
|
||||||
CcaCounters C5Radio::read_cca_counters() const {
|
|
||||||
CcaCounters c;
|
|
||||||
std::int32_t out[2] = {};
|
|
||||||
phy_get_cca_cnt(out);
|
|
||||||
constexpr std::int32_t mask27 = 0x07FFFFFF; // remomve the busy/status bit (bit 27) from the 27-bit hardware counters
|
|
||||||
c.total_cycles = static_cast<std::uint32_t>(out[0] & mask27);
|
|
||||||
c.busy_cycles = static_cast<std::uint32_t>(out[1] & mask27);
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
double C5Radio::calculate_cbr(const CcaCounters& current, const CcaCounters& previous) {
|
|
||||||
constexpr std::uint32_t counter_range = 1u << 27;
|
|
||||||
auto delta27 = [](std::uint32_t after, std::uint32_t before) -> std::uint32_t {
|
|
||||||
if (after >= before) {
|
|
||||||
return after - before;
|
|
||||||
}
|
|
||||||
// Counter wrapped from 2^27 - 1 back to zero.
|
|
||||||
return after + counter_range - before;
|
|
||||||
};
|
|
||||||
|
|
||||||
const std::uint32_t dt = delta27(current.total_cycles, previous.total_cycles);
|
|
||||||
const std::uint32_t db = delta27(current.busy_cycles, previous.busy_cycles);
|
|
||||||
|
|
||||||
if (dt == 0) return 0.0;
|
|
||||||
return static_cast<double>(db) / static_cast<double>(dt);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <vanetza_idf/access.hpp>
|
|
||||||
#include <esp_err.h>
|
|
||||||
#include <functional>
|
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
|
|
||||||
struct C5RadioConfig {
|
|
||||||
std::uint16_t channel_number = 180;
|
|
||||||
double transmit_power_dbm = 10.0;
|
|
||||||
unsigned receive_queue_length = 16;
|
|
||||||
bool laboratory_transmission = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
/// Result of polling the PHY's own (undocumented) CCA state as fast as possible for a
|
|
||||||
/// fixed window (feasibility probe for EN 303 797 clause 4.6.2)
|
|
||||||
/// to determine if the platform can observe channel-busy state at >=1 kHz at all.
|
|
||||||
struct CcaSampleResult {
|
|
||||||
std::uint32_t samples = 0;
|
|
||||||
std::uint32_t duration_us = 0;
|
|
||||||
std::uint32_t min_delta_us = 0;
|
|
||||||
std::uint32_t max_delta_us = 0;
|
|
||||||
std::uint32_t busy_count = 0;
|
|
||||||
std::int32_t first_cca = 0;
|
|
||||||
std::int32_t last_cca = 0;
|
|
||||||
std::int32_t noise_floor_dbm = 0;
|
|
||||||
std::int32_t cca_total_cycles_delta = 0; // out[0]/0x600a7c5c: 40 MHz free-running counter
|
|
||||||
std::int32_t cca_busy_cycles_delta = 0; // out[1]/0x600a7c60: increments while channel busy
|
|
||||||
std::int32_t cca_status = 0;
|
|
||||||
};
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Snapshot of the ESP32-C5 27-bit hardware PHY counters for ETSI TS 102 687 / EN 303 797 DCC.
|
|
||||||
/// total_cycles: 40 MHz FE clock cycles (register 0x600a7c5c[26:0]).
|
|
||||||
/// busy_cycles: cycles during which received RF energy exceeded the CCA threshold (register 0x600a7c60[26:0]).
|
|
||||||
struct CcaCounters {
|
|
||||||
std::uint32_t total_cycles = 0;
|
|
||||||
std::uint32_t busy_cycles = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ITS-G5 802.11p Radio Access Adapter for ESP32-C5 (EN 303 797 Annex B.2).
|
|
||||||
* Direct Wi-Fi promiscuous RX mode on 5.9 GHz (ITS-G5 Channel 180, 10 MHz BW)
|
|
||||||
* and 802.11p frame transmission via esp_wifi_80211_tx_custom.
|
|
||||||
*/
|
|
||||||
class C5Radio final : public vanetza_idf::Access {
|
|
||||||
public:
|
|
||||||
using Receive = std::function<void(vanetza_idf::AlDataIndication)>;
|
|
||||||
using Capture = std::function<void(const vanetza::ByteBuffer&, int, std::uint32_t)>;
|
|
||||||
|
|
||||||
/// @brief Constructs a radio adapter with the given configuration (not yet started).
|
|
||||||
/// @param config Radio configuration; defaults to channel 180, 10 dBm, 16-deep RX queue.
|
|
||||||
explicit C5Radio(C5RadioConfig config = {});
|
|
||||||
/// @brief Stops the radio and releases all resources.
|
|
||||||
~C5Radio() override;
|
|
||||||
C5Radio(const C5Radio&) = delete;
|
|
||||||
C5Radio& operator=(const C5Radio&) = delete;
|
|
||||||
|
|
||||||
/// @brief Initializes Wi-Fi in promiscuous 802.11p mode and starts the radio.
|
|
||||||
/// @return ESP_OK on success, otherwise an ESP-IDF error code.
|
|
||||||
esp_err_t start();
|
|
||||||
/// @brief Stops the radio and releases all resources.
|
|
||||||
void stop();
|
|
||||||
|
|
||||||
// vanetza_idf::Access
|
|
||||||
/// @brief Encodes and transmits an ITS-G5 frame requested by the access layer.
|
|
||||||
/// @param request Frame and transmit parameters from the access layer.
|
|
||||||
/// @return Result of the transmit attempt.
|
|
||||||
vanetza_idf::Result request(vanetza_idf::AlDataRequest request) override;
|
|
||||||
|
|
||||||
/// @brief Dispatches queued received frames to the owning task callback.
|
|
||||||
/// @param receive Invoked once per queued frame with its decoded indication.
|
|
||||||
/// @param capture Optional; invoked with the raw frame bytes, RSSI and timestamp for diagnostics.
|
|
||||||
void poll(const Receive& receive, const Capture& capture = {});
|
|
||||||
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
/// @brief Sends a burst of raw 802.11p test frames for RF characterization.
|
|
||||||
/// @param channel ITS-G5 channel number to transmit on.
|
|
||||||
/// @param power_dbm Transmit power in dBm.
|
|
||||||
/// @param mcs 802.11p modulation and coding scheme index.
|
|
||||||
/// @param count Number of frames to send.
|
|
||||||
/// @param interval_ms Interval between frames in milliseconds.
|
|
||||||
/// @param payload_len Length of each frame's payload in bytes.
|
|
||||||
/// @return ESP_OK if all frames were sent, otherwise the last transmit error.
|
|
||||||
esp_err_t transmit_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
|
|
||||||
unsigned count, unsigned interval_ms, std::size_t payload_len);
|
|
||||||
|
|
||||||
/// @brief Polls the CCA state as fast as possible for a fixed window (see CcaSampleResult); requires start().
|
|
||||||
/// @note This is a diagnostic tool/ feasibility probe for EN 303 797 clause 4.6.2 to determine if the platform can observe channel-busy state at >=1 kHz at all.
|
|
||||||
/// @param duration_ms Duration of the sampling window in milliseconds.
|
|
||||||
/// @return Sampling statistics and counter deltas over the window.
|
|
||||||
CcaSampleResult sample_cca(unsigned duration_ms);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// @brief Non-blocking read of the hardware CCA counters for periodic DCC evaluation.
|
|
||||||
/// Continuous 40 MHz hardware integration satisfies EN 303 797 Profile-1 (>=1 kHz) with zero CPU busy-wait.
|
|
||||||
/// @return Snapshot of total and busy cycle counts.
|
|
||||||
CcaCounters read_cca_counters() const;
|
|
||||||
|
|
||||||
/// @brief Calculates Channel Busy Ratio (CBR) between two counter snapshots: delta(busy) / delta(total).
|
|
||||||
/// @param current More recent counter snapshot.
|
|
||||||
/// @param previous Earlier counter snapshot.
|
|
||||||
/// @return CBR in [0, 1].
|
|
||||||
static double calculate_cbr(const CcaCounters& current, const CcaCounters& previous);
|
|
||||||
|
|
||||||
/// @brief Number of frames dropped since start() due to queue overflow or invalid length.
|
|
||||||
/// @return Dropped frame count.
|
|
||||||
std::uint32_t dropped_frames() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
class Impl;
|
|
||||||
std::unique_ptr<Impl> impl_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
#include "link_protocol.hpp"
|
|
||||||
|
|
||||||
namespace microbu::link {
|
|
||||||
|
|
||||||
bool encode(const Message& message, Bytes& out) {
|
|
||||||
if (header_size + message.body.size() > maximum_message) return false;
|
|
||||||
out.clear();
|
|
||||||
out.reserve(header_size + message.body.size());
|
|
||||||
out.push_back(static_cast<std::uint8_t>(message.header.opcode));
|
|
||||||
out.push_back(message.header.flags);
|
|
||||||
out.push_back(message.header.sequence & 0xFF);
|
|
||||||
out.push_back(message.header.sequence >> 8);
|
|
||||||
out.insert(out.end(), message.body.begin(), message.body.end());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool decode(const Bytes& octets, Message& out) {
|
|
||||||
if (octets.size() < header_size || octets.size() > maximum_message) return false;
|
|
||||||
out.header.opcode = static_cast<Opcode>(octets[0]);
|
|
||||||
out.header.flags = octets[1];
|
|
||||||
out.header.sequence = octets[2] | (octets[3] << 8);
|
|
||||||
out.body.assign(octets.begin() + header_size, octets.end());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
void put_area(Writer& w, const DestinationArea& a) {
|
|
||||||
w.u8(a.shape); w.i32(a.latitude); w.i32(a.longitude); w.u16(a.distance_a); w.u16(a.distance_b); w.u16(a.angle);
|
|
||||||
}
|
|
||||||
DestinationArea get_area(Reader& r) {
|
|
||||||
DestinationArea a;
|
|
||||||
a.shape = r.u8(); a.latitude = r.i32(); a.longitude = r.i32(); a.distance_a = r.u16(); a.distance_b = r.u16(); a.angle = r.u16();
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// STATION_CONFIGURE
|
|
||||||
Bytes encode(const StationConfigure& c) {
|
|
||||||
Writer w;
|
|
||||||
w.u8(c.station_type); w.u8(c.security); w.u8(c.address_configuration); w.bytes(c.mid, 6); w.u8(c.beaconing);
|
|
||||||
w.u16(c.channel_number); w.u8(c.transmit_power_dbm); w.u8(c.radio); w.u8(c.default_traffic_class); w.u8(c.default_lifetime);
|
|
||||||
return w.out;
|
|
||||||
}
|
|
||||||
bool decode(const Bytes& body, StationConfigure& c) {
|
|
||||||
Reader r(body);
|
|
||||||
c.station_type = r.u8(); c.security = r.u8(); c.address_configuration = r.u8(); r.bytes(c.mid, 6); c.beaconing = r.u8();
|
|
||||||
c.channel_number = r.u16(); c.transmit_power_dbm = r.u8(); c.radio = r.u8(); c.default_traffic_class = r.u8(); c.default_lifetime = r.u8();
|
|
||||||
return r.done() && c.security <= 1 && c.address_configuration <= 1 && c.beaconing <= 1 && c.radio <= 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POTI_UPDATE
|
|
||||||
Bytes encode(const PotiUpdate& p) {
|
|
||||||
Writer w;
|
|
||||||
w.u64(p.timestamp_ms); w.i32(p.latitude); w.i32(p.longitude); w.u16(p.semi_major_cm); w.u16(p.semi_minor_cm);
|
|
||||||
w.u16(p.orientation_deci_degree); w.u8(p.flags); w.i32(p.altitude_cm); w.u16(p.speed_cm_s); w.u16(p.heading_deci_degree);
|
|
||||||
return w.out;
|
|
||||||
}
|
|
||||||
bool decode(const Bytes& body, PotiUpdate& p) {
|
|
||||||
Reader r(body);
|
|
||||||
p.timestamp_ms = r.u64(); p.latitude = r.i32(); p.longitude = r.i32(); p.semi_major_cm = r.u16(); p.semi_minor_cm = r.u16();
|
|
||||||
p.orientation_deci_degree = r.u16(); p.flags = r.u8(); p.altitude_cm = r.i32(); p.speed_cm_s = r.u16(); p.heading_deci_degree = r.u16();
|
|
||||||
return r.done();
|
|
||||||
}
|
|
||||||
|
|
||||||
// BTP_DATA_REQUEST
|
|
||||||
Bytes encode(const BtpDataRequest& q) {
|
|
||||||
Writer w;
|
|
||||||
w.u8(q.btp_type); w.u16(q.destination_port); w.u16(q.destination_port_info); w.u8(q.gn_packet_transport_type);
|
|
||||||
w.u8(q.gn_communication_profile); w.u8(q.gn_security_profile); w.u8(q.gn_traffic_class); w.u8(q.gn_maximum_packet_lifetime);
|
|
||||||
w.u8(q.gn_maximum_hop_limit); w.u16(q.gn_repetition_interval_ms); w.u16(q.gn_repetition_maximum_ms); w.u32(q.its_aid);
|
|
||||||
w.u8(static_cast<std::uint8_t>(q.permissions.size())); w.bytes(q.permissions);
|
|
||||||
w.u8(static_cast<std::uint8_t>(q.context.size())); w.bytes(q.context);
|
|
||||||
if (q.gn_packet_transport_type == 3) put_area(w, q.area.value_or(DestinationArea {}));
|
|
||||||
w.u16(static_cast<std::uint16_t>(q.fl_sdu.size())); w.bytes(q.fl_sdu);
|
|
||||||
return w.out;
|
|
||||||
}
|
|
||||||
bool decode(const Bytes& body, BtpDataRequest& q) {
|
|
||||||
Reader r(body);
|
|
||||||
q.btp_type = r.u8(); q.destination_port = r.u16(); q.destination_port_info = r.u16(); q.gn_packet_transport_type = r.u8();
|
|
||||||
q.gn_communication_profile = r.u8(); q.gn_security_profile = r.u8(); q.gn_traffic_class = r.u8(); q.gn_maximum_packet_lifetime = r.u8();
|
|
||||||
q.gn_maximum_hop_limit = r.u8(); q.gn_repetition_interval_ms = r.u16(); q.gn_repetition_maximum_ms = r.u16(); q.its_aid = r.u32();
|
|
||||||
q.permissions = r.bytes(r.u8());
|
|
||||||
q.context = r.bytes(r.u8());
|
|
||||||
q.area.reset();
|
|
||||||
if (q.gn_packet_transport_type == 3) q.area = get_area(r);
|
|
||||||
q.fl_sdu = r.bytes(r.u16());
|
|
||||||
return r.done() && q.btp_type <= 1 && q.permissions.size() <= 31;
|
|
||||||
}
|
|
||||||
|
|
||||||
// BTP_DATA_INDICATION
|
|
||||||
Bytes encode(const BtpDataIndication& i) {
|
|
||||||
Writer w;
|
|
||||||
w.u8(i.btp_type); w.u16(i.destination_port); w.u16(i.destination_port_info); w.u8(i.gn_packet_transport_type);
|
|
||||||
w.u8(i.gn_traffic_class); w.u8(i.gn_remaining_packet_lifetime); w.u8(i.gn_remaining_hop_limit);
|
|
||||||
w.bytes(i.source_gn_address, 8); w.u32(i.source_timestamp); w.i32(i.source_latitude); w.i32(i.source_longitude);
|
|
||||||
w.u8(i.security_report); w.u32(i.its_aid);
|
|
||||||
w.u8(static_cast<std::uint8_t>(i.permissions.size())); w.bytes(i.permissions);
|
|
||||||
w.u8(i.certificate_present ? 1 : 0); w.bytes(i.certificate_id, 8);
|
|
||||||
w.u8(i.area ? 1 : 0);
|
|
||||||
if (i.area) put_area(w, *i.area);
|
|
||||||
w.u16(static_cast<std::uint16_t>(i.received_fl_sdu.size())); w.bytes(i.received_fl_sdu);
|
|
||||||
return w.out;
|
|
||||||
}
|
|
||||||
bool decode(const Bytes& body, BtpDataIndication& i) {
|
|
||||||
Reader r(body);
|
|
||||||
i.btp_type = r.u8(); i.destination_port = r.u16(); i.destination_port_info = r.u16(); i.gn_packet_transport_type = r.u8();
|
|
||||||
i.gn_traffic_class = r.u8(); i.gn_remaining_packet_lifetime = r.u8(); i.gn_remaining_hop_limit = r.u8();
|
|
||||||
r.bytes(i.source_gn_address, 8); i.source_timestamp = r.u32(); i.source_latitude = r.i32(); i.source_longitude = r.i32();
|
|
||||||
i.security_report = r.u8(); i.its_aid = r.u32();
|
|
||||||
i.permissions = r.bytes(r.u8());
|
|
||||||
i.certificate_present = r.u8() != 0; r.bytes(i.certificate_id, 8);
|
|
||||||
i.area.reset();
|
|
||||||
if (r.u8()) i.area = get_area(r);
|
|
||||||
i.received_fl_sdu = r.bytes(r.u16());
|
|
||||||
return r.done();
|
|
||||||
}
|
|
||||||
|
|
||||||
// CREDENTIALS_PROVISION
|
|
||||||
Bytes encode(const CredentialsProvision& c) {
|
|
||||||
Writer w;
|
|
||||||
w.u16(c.total_length); w.u16(c.offset); w.u8(static_cast<std::uint8_t>(c.segment.size())); w.bytes(c.segment);
|
|
||||||
return w.out;
|
|
||||||
}
|
|
||||||
bool decode(const Bytes& body, CredentialsProvision& c) {
|
|
||||||
Reader r(body);
|
|
||||||
c.total_length = r.u16(); c.offset = r.u16(); c.segment = r.bytes(r.u8());
|
|
||||||
return r.done() && !c.segment.empty() && c.offset + c.segment.size() <= c.total_length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SF_IDCHANGE_EVENT
|
|
||||||
Bytes encode(const IdChangeEvent& e) {
|
|
||||||
Writer w;
|
|
||||||
w.u64(e.subscription); w.u8(e.command); w.bytes(e.id, 8);
|
|
||||||
w.u8(static_cast<std::uint8_t>(e.subscriber_data.size())); w.bytes(e.subscriber_data);
|
|
||||||
return w.out;
|
|
||||||
}
|
|
||||||
bool decode(const Bytes& body, IdChangeEvent& e) {
|
|
||||||
Reader r(body);
|
|
||||||
e.subscription = r.u64(); e.command = r.u8(); r.bytes(e.id, 8); e.subscriber_data = r.bytes(r.u8());
|
|
||||||
return r.done() && e.command <= 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
Bytes encode(const IdChangeEventResponse& e) { Writer w; w.u64(e.subscription); w.u8(e.return_code); return w.out; }
|
|
||||||
bool decode(const Bytes& body, IdChangeEventResponse& e) {
|
|
||||||
Reader r(body);
|
|
||||||
e.subscription = r.u64(); e.return_code = r.u8();
|
|
||||||
return r.done() && e.return_code <= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// STATUS
|
|
||||||
Bytes encode(const Status& s) {
|
|
||||||
Writer w;
|
|
||||||
w.u32(s.uptime_ms); w.u8(s.configured); w.bytes(s.gn_address, 8); w.bytes(s.identifier, 8); w.u8(s.change_pending); w.u8(s.tickets);
|
|
||||||
w.u32(s.signed_messages); w.u32(s.refused_no_ticket); w.u32(s.refused_change_pending); w.u32(s.refused_permission);
|
|
||||||
w.u32(s.sign_failed); w.u32(s.verified); w.u32(s.rejected);
|
|
||||||
w.u32(s.requests_accepted); w.u32(s.requests_refused); w.u32(s.indications);
|
|
||||||
w.u32(s.radio_submitted); w.u32(s.radio_failed); w.u32(s.radio_received); w.u32(s.radio_dropped);
|
|
||||||
w.u32(s.link_rx_frames); w.u32(s.link_crc_errors); w.u32(s.link_malformed); w.u32(s.poti_updates);
|
|
||||||
w.u64(s.its_time_ms);
|
|
||||||
return w.out;
|
|
||||||
}
|
|
||||||
bool decode(const Bytes& body, Status& s) {
|
|
||||||
Reader r(body);
|
|
||||||
s.uptime_ms = r.u32(); s.configured = r.u8(); r.bytes(s.gn_address, 8); r.bytes(s.identifier, 8); s.change_pending = r.u8(); s.tickets = r.u8();
|
|
||||||
s.signed_messages = r.u32(); s.refused_no_ticket = r.u32(); s.refused_change_pending = r.u32(); s.refused_permission = r.u32();
|
|
||||||
s.sign_failed = r.u32(); s.verified = r.u32(); s.rejected = r.u32();
|
|
||||||
s.requests_accepted = r.u32(); s.requests_refused = r.u32(); s.indications = r.u32();
|
|
||||||
s.radio_submitted = r.u32(); s.radio_failed = r.u32(); s.radio_received = r.u32(); s.radio_dropped = r.u32();
|
|
||||||
s.link_rx_frames = r.u32(); s.link_crc_errors = r.u32(); s.link_malformed = r.u32(); s.poti_updates = r.u32();
|
|
||||||
s.its_time_ms = r.u64();
|
|
||||||
return r.done();
|
|
||||||
}
|
|
||||||
|
|
||||||
// RESULT
|
|
||||||
Bytes encode(const Result& res) {
|
|
||||||
Writer w;
|
|
||||||
w.u8(static_cast<std::uint8_t>(res.code)); w.u8(static_cast<std::uint8_t>(res.detail.size())); w.bytes(res.detail);
|
|
||||||
return w.out;
|
|
||||||
}
|
|
||||||
bool decode(const Bytes& body, Result& res) {
|
|
||||||
Reader r(body);
|
|
||||||
res.code = static_cast<Code>(r.u8()); res.detail = r.bytes(r.u8());
|
|
||||||
return r.done();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace microbu::link
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// micrOBU station-internal link, message layer version 1 (implementation/station-link/README.md).
|
|
||||||
// Transport independent: the same octets are one GATT attribute value later and one serial
|
|
||||||
// frame payload today. Little-endian integers; ETSI payloads inside stay opaque.
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <optional>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace microbu::link {
|
|
||||||
|
|
||||||
using Bytes = std::vector<std::uint8_t>;
|
|
||||||
|
|
||||||
constexpr std::size_t maximum_message = 512;
|
|
||||||
constexpr std::size_t header_size = 4;
|
|
||||||
|
|
||||||
/// @brief The message opcode, which determines the body type.
|
|
||||||
enum class Opcode : std::uint8_t {
|
|
||||||
// phone -> ESP32-C5
|
|
||||||
STATION_CONFIGURE = 0x01,
|
|
||||||
POTI_UPDATE = 0x02,
|
|
||||||
BTP_DATA_REQUEST = 0x03,
|
|
||||||
CREDENTIALS_PROVISION = 0x04,
|
|
||||||
CREDENTIALS_ERASE = 0x05,
|
|
||||||
SF_IDCHANGE_SUBSCRIBE = 0x06,
|
|
||||||
SF_IDCHANGE_UNSUBSCRIBE = 0x07,
|
|
||||||
SF_IDCHANGE_EVENT_RESPONSE = 0x08,
|
|
||||||
SF_IDCHANGE_TRIGGER = 0x09,
|
|
||||||
SF_ID_LOCK = 0x0A,
|
|
||||||
SF_ID_UNLOCK = 0x0B,
|
|
||||||
STATUS_REQUEST = 0x0C,
|
|
||||||
// ESP32-C5 -> phone
|
|
||||||
RESULT = 0x80,
|
|
||||||
BTP_DATA_INDICATION = 0x81,
|
|
||||||
SF_IDCHANGE_EVENT = 0x82,
|
|
||||||
STATUS = 0x84,
|
|
||||||
// MicrOBU extension, not in the colleague's station-link v1: every ITS message heard on air,
|
|
||||||
// unwrapped by gn_unwrap.c whether or not the security entity could verify it. Body is the
|
|
||||||
// previous firmware's SERIAL_MSG_V2X_RX payload unchanged: [u16 btp_dest_port][i8 rssi]
|
|
||||||
// [u8 flags: bit0 geo area valid, bit1 signed but not verified][i32 area_lat][i32 area_lon]
|
|
||||||
// [u16 area_distance_a], then the UPER bytes. See obu-firmware/NOTES.md.
|
|
||||||
V2X_RX = 0x85,
|
|
||||||
};
|
|
||||||
|
|
||||||
constexpr std::uint8_t flag_fragment_first = 0x01;
|
|
||||||
constexpr std::uint8_t flag_fragment_more = 0x02;
|
|
||||||
|
|
||||||
// RESULT codes: vanetza_idf::Result values first (same numbering), then link codes.
|
|
||||||
enum class Code : std::uint8_t {
|
|
||||||
accepted = 0, invalid_argument = 1, unsupported = 2, wrong_entry_point = 3, security_unavailable = 4,
|
|
||||||
resource_limit = 5, rejected = 6, time_regression = 7, identity_change_pending = 8,
|
|
||||||
unknown_opcode = 0x10, malformed = 0x11, not_configured = 0x12, busy = 0x13, no_credentials = 0x14,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @brief The header of a link message.
|
|
||||||
struct Header {
|
|
||||||
Opcode opcode;
|
|
||||||
std::uint8_t flags = 0;
|
|
||||||
std::uint16_t sequence = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @brief A complete link message.
|
|
||||||
struct Message {
|
|
||||||
Header header;
|
|
||||||
Bytes body;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @brief Serializes header + body.
|
|
||||||
/// @param message Message to serialize.
|
|
||||||
/// @param out Receives the serialized bytes.
|
|
||||||
/// @return false when the result would exceed maximum_message.
|
|
||||||
bool encode(const Message& message, Bytes& out);
|
|
||||||
/// @brief Parses header + body.
|
|
||||||
/// @param octets Raw bytes to parse.
|
|
||||||
/// @param out Receives the decoded message.
|
|
||||||
/// @return false when shorter than the header or longer than maximum_message.
|
|
||||||
bool decode(const Bytes& octets, Message& out);
|
|
||||||
|
|
||||||
// ---- bodies ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// security, address_configuration, default_traffic_class and default_lifetime are the GN protocol
|
|
||||||
// constants itsGnSecurity, itsGnLocalAddrConfMethod, itsGnDefaultTrafficClass and
|
|
||||||
// itsGnDefaultPacketLifetime of ETSI TS 103 836-4-1 (GeoNetworking) annex H; channel_number,
|
|
||||||
// transmit_power_dbm and radio are ITS-G5 access-layer parameters (ETSI EN 303 797).
|
|
||||||
struct StationConfigure {
|
|
||||||
std::uint8_t station_type = 2; // TS 102 894-2 StationType (2 = cyclist)
|
|
||||||
std::uint8_t security = 1; // itsGnSecurity
|
|
||||||
std::uint8_t address_configuration = 1; // 0 AUTO (mid below), 1 ANONYMOUS (ticket digest)
|
|
||||||
std::uint8_t mid[6] = {2, 0, 0, 0, 0, 1};
|
|
||||||
std::uint8_t beaconing = 1;
|
|
||||||
std::uint16_t channel_number = 180;
|
|
||||||
std::uint8_t transmit_power_dbm = 10;
|
|
||||||
std::uint8_t radio = 0; // 0 off, 1 receive only, 2 transmit and receive
|
|
||||||
std::uint8_t default_traffic_class = 2;
|
|
||||||
std::uint8_t default_lifetime = 0x05; // 1 s (base One_Second, multiplier 1)
|
|
||||||
};
|
|
||||||
/// @param body Raw message body bytes.
|
|
||||||
/// @param out Receives the decoded configuration.
|
|
||||||
/// @return false if malformed.
|
|
||||||
bool decode(const Bytes& body, StationConfigure& out);
|
|
||||||
|
|
||||||
/// @param value Configuration to encode.
|
|
||||||
/// @return Encoded body bytes.
|
|
||||||
Bytes encode(const StationConfigure& value);
|
|
||||||
|
|
||||||
// Position/confidence-ellipse/speed/heading fields are the ETSI TS 102 894-2 (Common Data
|
|
||||||
// Dictionary) ReferencePosition / PosConfidenceEllipse data types; pai() mirrors the Position
|
|
||||||
// Accuracy Indicator field of the GeoNetworking Long Position Vector (ETSI TS 103 836-4-1
|
|
||||||
// clause 9.5.2.2, table 2).
|
|
||||||
struct PotiUpdate {
|
|
||||||
std::uint64_t timestamp_ms = 0; // TimestampIts
|
|
||||||
std::int32_t latitude = 0; // 1/10 microdegree
|
|
||||||
std::int32_t longitude = 0;
|
|
||||||
std::uint16_t semi_major_cm = 0;
|
|
||||||
std::uint16_t semi_minor_cm = 0;
|
|
||||||
std::uint16_t orientation_deci_degree = 0;
|
|
||||||
std::uint8_t flags = 0; // bit0 altitude, bit1 speed, bit2 heading, bit3 PAI
|
|
||||||
std::int32_t altitude_cm = 0;
|
|
||||||
std::uint16_t speed_cm_s = 0;
|
|
||||||
std::uint16_t heading_deci_degree = 0;
|
|
||||||
bool has_altitude() const { return flags & 1; }
|
|
||||||
bool has_speed() const { return flags & 2; }
|
|
||||||
bool has_heading() const { return flags & 4; }
|
|
||||||
bool pai() const { return flags & 8; }
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @param body Raw message body bytes.
|
|
||||||
/// @param out Receives the decoded fix.
|
|
||||||
/// @return false if malformed.
|
|
||||||
bool decode(const Bytes& body, PotiUpdate& out);
|
|
||||||
|
|
||||||
/// @param value Fix to encode.
|
|
||||||
/// @return Encoded body bytes.
|
|
||||||
Bytes encode(const PotiUpdate& value);
|
|
||||||
|
|
||||||
|
|
||||||
/// @brief The message body of a BTP_DATA_REQUEST (ETSI TS 103 836-4-1 annex J.2) request.
|
|
||||||
// GeoArea per ETSI TS 103 899 "Geographical Area Definition". shape follows the GEOBROADCAST_*/
|
|
||||||
// GEOANYCAST_* header sub-type encoding of ETSI TS 103 836-4-1 (GeoNetworking) clause 9.7.4 table 9
|
|
||||||
// (0 circle, 1 rectangle, 2 ellipse); position/distance_a/distance_b/angle are the GeoArea fields
|
|
||||||
// carried in the GBC/GAC extended header per clause 9.8.5 table 36.
|
|
||||||
struct DestinationArea {
|
|
||||||
std::uint8_t shape = 0; // 0 circle, 1 rectangle, 2 ellipse
|
|
||||||
std::int32_t latitude = 0;
|
|
||||||
std::int32_t longitude = 0;
|
|
||||||
std::uint16_t distance_a = 0;
|
|
||||||
std::uint16_t distance_b = 0;
|
|
||||||
std::uint16_t angle = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// btp_type/destination_port(_info) are the BTP-A/BTP-B header fields of ETSI TS 103 836-5-1 (Basic
|
|
||||||
// Transport Protocol); the gn_* fields mirror the TRANSP_CORE.request service primitive parameters
|
|
||||||
// of ETSI TS 103 836-4-1 (GeoNetworking) annex J.2 (Packet transport type, Traffic class, Maximum
|
|
||||||
// hop limit, Repetition interval/maximum, Security profile); its_aid follows the ITS-AID registry
|
|
||||||
// of ETSI TS 102 965.
|
|
||||||
struct BtpDataRequest {
|
|
||||||
std::uint8_t btp_type = 1;
|
|
||||||
std::uint16_t destination_port = 0;
|
|
||||||
std::uint16_t destination_port_info = 0; // or source_port for BTP-A
|
|
||||||
std::uint8_t gn_packet_transport_type = 1; // 0 GUC, 1 SHB, 2 TSB, 3 GBC, 4 GAC (TS 103 836-4-1 annex J.2)
|
|
||||||
std::uint8_t gn_communication_profile = 1;
|
|
||||||
std::uint8_t gn_security_profile = 0; // 0 not given, 1 unsecured, 2 secured
|
|
||||||
std::uint8_t gn_traffic_class = 0xFF; // 0xFF = station default
|
|
||||||
std::uint8_t gn_maximum_packet_lifetime = 0xFF; // 0xFF = station default
|
|
||||||
std::uint8_t gn_maximum_hop_limit = 0; // 0 = station default
|
|
||||||
std::uint16_t gn_repetition_interval_ms = 0;
|
|
||||||
std::uint16_t gn_repetition_maximum_ms = 0;
|
|
||||||
std::uint32_t its_aid = 0;
|
|
||||||
Bytes permissions;
|
|
||||||
Bytes context;
|
|
||||||
std::optional<DestinationArea> area; // present for GBC
|
|
||||||
Bytes fl_sdu;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @param body Raw message body bytes.
|
|
||||||
/// @param out Receives the decoded request.
|
|
||||||
/// @return false if malformed.
|
|
||||||
bool decode(const Bytes& body, BtpDataRequest& out);
|
|
||||||
|
|
||||||
/// @param value Request to encode.
|
|
||||||
/// @return Encoded body bytes.
|
|
||||||
Bytes encode(const BtpDataRequest& value);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Mirrors the TRANSP_CORE.indication service primitive parameters of ETSI TS 103 836-4-1
|
|
||||||
// (GeoNetworking) annex J.4: source_gn_address/source_timestamp/source_latitude/source_longitude
|
|
||||||
// are the sender's Long Position Vector (clause 9.5.2), security_report/certificate_* the Security
|
|
||||||
// report/Certificate id parameters, its_aid/permissions the ITS-AID/Security permissions
|
|
||||||
// parameters.
|
|
||||||
struct BtpDataIndication {
|
|
||||||
std::uint8_t btp_type = 1;
|
|
||||||
std::uint16_t destination_port = 0;
|
|
||||||
std::uint16_t destination_port_info = 0;
|
|
||||||
std::uint8_t gn_packet_transport_type = 1; // 0 GUC, 1 SHB, 2 TSB, 3 GBC, 4 GAC (TS 103 836-4-1 annex J.4)
|
|
||||||
std::uint8_t gn_traffic_class = 0;
|
|
||||||
std::uint8_t gn_remaining_packet_lifetime = 0xFF;
|
|
||||||
std::uint8_t gn_remaining_hop_limit = 0xFF;
|
|
||||||
std::uint8_t source_gn_address[8] = {};
|
|
||||||
std::uint32_t source_timestamp = 0;
|
|
||||||
std::int32_t source_latitude = 0;
|
|
||||||
std::int32_t source_longitude = 0;
|
|
||||||
std::uint8_t security_report = 0; // 0 unsecured, 1 + VerificationReport ordinal
|
|
||||||
std::uint32_t its_aid = 0;
|
|
||||||
Bytes permissions;
|
|
||||||
bool certificate_present = false;
|
|
||||||
std::uint8_t certificate_id[8] = {};
|
|
||||||
std::optional<DestinationArea> area;
|
|
||||||
Bytes received_fl_sdu;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @param body Raw message body bytes.
|
|
||||||
/// @param out Receives the decoded indication.
|
|
||||||
/// @return false if malformed.
|
|
||||||
bool decode(const Bytes& body, BtpDataIndication& out);
|
|
||||||
|
|
||||||
/// @param value Indication to encode.
|
|
||||||
/// @return Encoded body bytes.
|
|
||||||
Bytes encode(const BtpDataIndication& value);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Segmented upload of an ETSI TS 102 941 (Trust and Privacy Management) credential bundle
|
|
||||||
// (root CA / enrolment or authorization authority certificates, authorization tickets), whose
|
|
||||||
// certificate encoding is ETSI TS 103 097.
|
|
||||||
struct CredentialsProvision {
|
|
||||||
std::uint16_t total_length = 0;
|
|
||||||
std::uint16_t offset = 0;
|
|
||||||
Bytes segment;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @param body Raw message body bytes.
|
|
||||||
/// @param out Receives the decoded segment.
|
|
||||||
/// @return false if malformed.
|
|
||||||
/// @note One CredentialsProvision carries a single segment of a larger bundle; total_length
|
|
||||||
/// and offset let the caller reassemble the full bundle across several messages.
|
|
||||||
bool decode(const Bytes& body, CredentialsProvision& out);
|
|
||||||
|
|
||||||
/// @param value Segment to encode.
|
|
||||||
/// @return Encoded body bytes.
|
|
||||||
Bytes encode(const CredentialsProvision& value);
|
|
||||||
|
|
||||||
// Counts of ETSI TS 102 941 credentials (root CA / EA-AA certificates / authorization tickets)
|
|
||||||
// applied from a CredentialsProvision bundle.
|
|
||||||
struct ApplyReport { std::uint8_t roots = 0, authorities = 0, tickets = 0; };
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Mirrors the security entity's pseudonym-change handshake that the GN Core subscribes to via the
|
|
||||||
// SN-IDCHANGE-SUBSCRIBE/-EVENT/-UNSUBSCRIBE primitives at the CORE_SEC interface (ETSI
|
|
||||||
// TS 103 836-4-1 clause 10.2.1.4); the pseudonym/Authorization Ticket change itself is governed by
|
|
||||||
// ETSI TS 102 941. command is the link's own PREPARE/COMMIT/ABORT/DEREG handshake state, not an
|
|
||||||
// ETSI-defined field.
|
|
||||||
struct IdChangeEvent {
|
|
||||||
std::uint64_t subscription = 0;
|
|
||||||
std::uint8_t command = 0; // 0 PREPARE, 1 COMMIT, 2 ABORT, 3 DEREG
|
|
||||||
std::uint8_t id[8] = {};
|
|
||||||
Bytes subscriber_data;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @param body Raw message body bytes.
|
|
||||||
/// @param out Receives the decoded event.
|
|
||||||
/// @return false if malformed.
|
|
||||||
bool decode(const Bytes& body, IdChangeEvent& out);
|
|
||||||
|
|
||||||
/// @param value Event to encode.
|
|
||||||
/// @return Encoded body bytes.
|
|
||||||
Bytes encode(const IdChangeEvent& value);
|
|
||||||
|
|
||||||
// The link's encoding of the SN-IDCHANGE-EVENT.response primitive (ETSI TS 103 836-4-1
|
|
||||||
// clause 10.2.1.4), acknowledging an IdChangeEvent.
|
|
||||||
struct IdChangeEventResponse { std::uint64_t subscription = 0; std::uint8_t return_code = 0; };
|
|
||||||
/// @param body Raw message body bytes.
|
|
||||||
/// @param out Receives the decoded response.
|
|
||||||
/// @return false if malformed.
|
|
||||||
bool decode(const Bytes& body, IdChangeEventResponse& out);
|
|
||||||
/// @param value Response to encode.
|
|
||||||
/// @return Encoded body bytes.
|
|
||||||
Bytes encode(const IdChangeEventResponse& value);
|
|
||||||
|
|
||||||
struct Status {
|
|
||||||
std::uint32_t uptime_ms = 0;
|
|
||||||
std::uint8_t configured = 0;
|
|
||||||
std::uint8_t gn_address[8] = {};
|
|
||||||
std::uint8_t identifier[8] = {};
|
|
||||||
std::uint8_t change_pending = 0;
|
|
||||||
std::uint8_t tickets = 0;
|
|
||||||
std::uint32_t signed_messages = 0, refused_no_ticket = 0, refused_change_pending = 0, refused_permission = 0,
|
|
||||||
sign_failed = 0, verified = 0, rejected = 0;
|
|
||||||
std::uint32_t requests_accepted = 0, requests_refused = 0, indications = 0;
|
|
||||||
std::uint32_t radio_submitted = 0, radio_failed = 0, radio_received = 0, radio_dropped = 0;
|
|
||||||
std::uint32_t link_rx_frames = 0, link_crc_errors = 0, link_malformed = 0, poti_updates = 0;
|
|
||||||
std::uint64_t its_time_ms = 0;
|
|
||||||
};
|
|
||||||
/// @param body Raw message body bytes.
|
|
||||||
/// @param out Receives the decoded status.
|
|
||||||
/// @return false if malformed.
|
|
||||||
bool decode(const Bytes& body, Status& out);
|
|
||||||
/// @param value Status to encode.
|
|
||||||
/// @return Encoded body bytes.
|
|
||||||
Bytes encode(const Status& value);
|
|
||||||
|
|
||||||
struct Result {
|
|
||||||
Code code = Code::accepted;
|
|
||||||
Bytes detail;
|
|
||||||
};
|
|
||||||
/// @param body Raw message body bytes.
|
|
||||||
/// @param out Receives the decoded result.
|
|
||||||
/// @return false if malformed.
|
|
||||||
bool decode(const Bytes& body, Result& out);
|
|
||||||
/// @param value Result to encode.
|
|
||||||
/// @return Encoded body bytes.
|
|
||||||
Bytes encode(const Result& value);
|
|
||||||
|
|
||||||
// ---- little-endian helpers shared with the test channel ----------------------------------
|
|
||||||
/// @note All multi-byte values are written little-endian.
|
|
||||||
class Writer {
|
|
||||||
public:
|
|
||||||
/// @brief Output buffer thats apppended to
|
|
||||||
Bytes out;
|
|
||||||
/// @brief Appends a single byte to the output.
|
|
||||||
void u8(std::uint8_t v) { out.push_back(v); }
|
|
||||||
/// @brief Appends a 16-bit unsigned integer to the output.
|
|
||||||
void u16(std::uint16_t v) { out.push_back(v & 0xFF); out.push_back(v >> 8); }
|
|
||||||
/// @brief Appends a 32-bit unsigned integer to the output.
|
|
||||||
void u32(std::uint32_t v) { for (int i = 0; i < 4; ++i) out.push_back((v >> (8 * i)) & 0xFF); }
|
|
||||||
/// @brief Appends a 64-bit unsigned integer to the output.
|
|
||||||
void u64(std::uint64_t v) { for (int i = 0; i < 8; ++i) out.push_back((v >> (8 * i)) & 0xFF); }
|
|
||||||
/// @brief Appends a 32-bit signed integer to the output.
|
|
||||||
void i32(std::int32_t v) { u32(static_cast<std::uint32_t>(v)); }
|
|
||||||
/// @brief Appends a sequence of bytes to the output.
|
|
||||||
void bytes(const std::uint8_t* p, std::size_t n) { out.insert(out.end(), p, p + n); }
|
|
||||||
/// @brief Appends a sequence of bytes to the output.
|
|
||||||
void bytes(const Bytes& b) { out.insert(out.end(), b.begin(), b.end()); }
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @note All multi-byte values are read little-endian. Once a read runs past the end of the
|
|
||||||
/// buffer, ok() becomes false and all further reads return zero/empty instead of throwing.
|
|
||||||
class Reader {
|
|
||||||
public:
|
|
||||||
/// @param b Buffer to read from; must outlive the Reader.
|
|
||||||
/// @param at Starting offset into b.
|
|
||||||
Reader(const Bytes& b, std::size_t at = 0) : b_(b), at_(at) {}
|
|
||||||
bool ok() const { return ok_; }
|
|
||||||
bool done() const { return ok_ && at_ == b_.size(); }
|
|
||||||
std::size_t remaining() const { return b_.size() - at_; }
|
|
||||||
std::uint8_t u8() { return need(1) ? b_[at_++] : 0; }
|
|
||||||
std::uint16_t u16() { if (!need(2)) return 0; std::uint16_t v = b_[at_] | (b_[at_ + 1] << 8); at_ += 2; return v; }
|
|
||||||
std::uint32_t u32() { if (!need(4)) return 0; std::uint32_t v = 0; for (int i = 3; i >= 0; --i) v = (v << 8) | b_[at_ + i]; at_ += 4; return v; }
|
|
||||||
std::uint64_t u64() { if (!need(8)) return 0; std::uint64_t v = 0; for (int i = 7; i >= 0; --i) v = (v << 8) | b_[at_ + i]; at_ += 8; return v; }
|
|
||||||
std::int32_t i32() { return static_cast<std::int32_t>(u32()); }
|
|
||||||
bool bytes(std::uint8_t* p, std::size_t n) { if (!need(n)) return false; for (std::size_t i = 0; i < n; ++i) p[i] = b_[at_ + i]; at_ += n; return true; }
|
|
||||||
Bytes bytes(std::size_t n) { Bytes r; if (need(n)) { r.assign(b_.begin() + at_, b_.begin() + at_ + n); at_ += n; } return r; }
|
|
||||||
Bytes rest() { Bytes r(b_.begin() + at_, b_.end()); at_ = b_.size(); return r; }
|
|
||||||
private:
|
|
||||||
bool need(std::size_t n) { if (!ok_ || at_ + n > b_.size()) { ok_ = false; return false; } return true; }
|
|
||||||
const Bytes& b_;
|
|
||||||
std::size_t at_;
|
|
||||||
bool ok_ = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace microbu::link
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
#include "link_service.hpp"
|
|
||||||
#include "simple_ble.hpp"
|
|
||||||
#include "serial_link.hpp"
|
|
||||||
#include <esp_log.h>
|
|
||||||
#include <esp_timer.h>
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
namespace {
|
|
||||||
const char* TAG = "link";
|
|
||||||
}
|
|
||||||
|
|
||||||
LinkService::LinkService(Station& station) : station_(station) {
|
|
||||||
station_.on_indication([this](const link::BtpDataIndication& indication) {
|
|
||||||
broadcast(link::Opcode::BTP_DATA_INDICATION, ++own_sequence_, link::encode(indication));
|
|
||||||
});
|
|
||||||
// MicrOBU: every ITS message heard on air, verified or not (Station::on_raw_its).
|
|
||||||
station_.on_raw_its([this](const link::Bytes& body) {
|
|
||||||
broadcast(link::Opcode::V2X_RX, ++own_sequence_, body);
|
|
||||||
});
|
|
||||||
station_.on_id_event([this](const link::IdChangeEvent& event) {
|
|
||||||
// PREPARE/COMMIT expect SF_IDCHANGE_EVENT_RESPONSE with this sequence
|
|
||||||
broadcast(link::Opcode::SF_IDCHANGE_EVENT, ++own_sequence_, link::encode(event));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void LinkService::send(LinkTransport transport, link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body) {
|
|
||||||
link::Bytes octets;
|
|
||||||
link::Message message {{opcode, 0, sequence}, body};
|
|
||||||
if (!link::encode(message, octets)) { ESP_LOGE(TAG, "message 0x%02x too long (%u)", unsigned(opcode), unsigned(body.size())); return; }
|
|
||||||
if (transport == LinkTransport::serial) serial::write(serial::FrameType::LINK, octets);
|
|
||||||
else ble::write(octets);
|
|
||||||
}
|
|
||||||
|
|
||||||
void LinkService::broadcast(link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body) {
|
|
||||||
send(LinkTransport::serial, opcode, sequence, body);
|
|
||||||
if (ble::connected()) send(LinkTransport::ble, opcode, sequence, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
void LinkService::reply(LinkTransport transport, std::uint16_t sequence, link::Code code, const link::Bytes& detail) {
|
|
||||||
send(transport, link::Opcode::RESULT, sequence, link::encode(link::Result {code, detail}));
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Status LinkService::status() {
|
|
||||||
auto value = station_.status();
|
|
||||||
const auto serial_counters = serial::counters();
|
|
||||||
const auto ble_counters = ble::counters();
|
|
||||||
value.link_rx_frames = serial_counters.frames + ble_counters.rx_messages;
|
|
||||||
value.link_crc_errors = serial_counters.crc_errors;
|
|
||||||
value.link_malformed += ble_counters.malformed;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
void LinkService::handle(const link::Bytes& octets, LinkTransport origin) {
|
|
||||||
link::Message m;
|
|
||||||
if (!link::decode(octets, m)) return;
|
|
||||||
const auto seq = m.header.sequence;
|
|
||||||
using link::Opcode; using link::Code;
|
|
||||||
switch (m.header.opcode) {
|
|
||||||
case Opcode::STATION_CONFIGURE: {
|
|
||||||
link::StationConfigure c;
|
|
||||||
if (!link::decode(m.body, c)) return reply(origin, seq, Code::malformed);
|
|
||||||
link::Bytes detail;
|
|
||||||
const auto result = station_.configure(c, detail);
|
|
||||||
ESP_LOGI(TAG, "station configured: security %u, radio %u, result %d", unsigned(c.security), unsigned(c.radio), int(result));
|
|
||||||
return reply(origin, seq, result, detail);
|
|
||||||
}
|
|
||||||
case Opcode::POTI_UPDATE: {
|
|
||||||
link::PotiUpdate p;
|
|
||||||
if (!link::decode(m.body, p)) return reply(origin, seq, Code::malformed);
|
|
||||||
const auto result = station_.poti(p);
|
|
||||||
if (result != Code::accepted) reply(origin, seq, result); // accepted updates are silent
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case Opcode::BTP_DATA_REQUEST: {
|
|
||||||
link::BtpDataRequest q;
|
|
||||||
if (!link::decode(m.body, q)) return reply(origin, seq, Code::malformed);
|
|
||||||
return reply(origin, seq, station_.btp_request(q));
|
|
||||||
}
|
|
||||||
case Opcode::CREDENTIALS_PROVISION: {
|
|
||||||
link::CredentialsProvision c;
|
|
||||||
if (!link::decode(m.body, c)) return reply(origin, seq, Code::malformed);
|
|
||||||
if (c.offset == 0) { bundle_.clear(); bundle_total_ = c.total_length; }
|
|
||||||
if (c.total_length != bundle_total_ || c.offset != bundle_.size() || c.total_length > 8192) { bundle_.clear(); return reply(origin, seq, Code::malformed); }
|
|
||||||
bundle_.insert(bundle_.end(), c.segment.begin(), c.segment.end());
|
|
||||||
if (bundle_.size() < bundle_total_) return reply(origin, seq, Code::accepted);
|
|
||||||
link::ApplyReport report;
|
|
||||||
link::Code result = Code::accepted;
|
|
||||||
try {
|
|
||||||
result = station_.provision(bundle_, report);
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
ESP_LOGE(TAG, "provision exception: %s", e.what());
|
|
||||||
result = Code::invalid_argument;
|
|
||||||
}
|
|
||||||
bundle_.clear();
|
|
||||||
link::Bytes detail;
|
|
||||||
detail.push_back(static_cast<std::uint8_t>(report.roots));
|
|
||||||
detail.push_back(static_cast<std::uint8_t>(report.authorities));
|
|
||||||
detail.push_back(static_cast<std::uint8_t>(report.tickets));
|
|
||||||
return reply(origin, seq, result, detail);
|
|
||||||
}
|
|
||||||
case Opcode::CREDENTIALS_ERASE:
|
|
||||||
return reply(origin, seq, station_.erase_credentials());
|
|
||||||
case Opcode::SF_IDCHANGE_SUBSCRIBE: {
|
|
||||||
link::Reader r(m.body);
|
|
||||||
const auto data = r.bytes(r.u8());
|
|
||||||
if (!r.done()) {
|
|
||||||
ESP_LOGW(TAG, "SF_IDCHANGE_SUBSCRIBE malformed (len=%u)", (unsigned)m.body.size());
|
|
||||||
return reply(origin, seq, Code::malformed);
|
|
||||||
}
|
|
||||||
std::uint64_t handle = 0;
|
|
||||||
const auto result = station_.subscribe(data, handle);
|
|
||||||
ESP_LOGI(TAG, "SF_IDCHANGE_SUBSCRIBE handle=%llu result=%d", (unsigned long long)handle, int(result));
|
|
||||||
link::Writer w; w.u64(handle);
|
|
||||||
return reply(origin, seq, result, result == Code::accepted ? w.out : link::Bytes {});
|
|
||||||
}
|
|
||||||
case Opcode::SF_IDCHANGE_UNSUBSCRIBE: {
|
|
||||||
link::Reader r(m.body);
|
|
||||||
const auto handle = r.u64();
|
|
||||||
if (!r.done()) return reply(origin, seq, Code::malformed);
|
|
||||||
return reply(origin, seq, station_.unsubscribe(handle));
|
|
||||||
}
|
|
||||||
case Opcode::SF_IDCHANGE_EVENT_RESPONSE: {
|
|
||||||
link::IdChangeEventResponse e;
|
|
||||||
if (!link::decode(m.body, e)) return; // no reply defined for a response
|
|
||||||
station_.event_response(e.subscription, e.return_code != 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case Opcode::SF_IDCHANGE_TRIGGER:
|
|
||||||
return reply(origin, seq, station_.trigger());
|
|
||||||
case Opcode::SF_ID_LOCK: {
|
|
||||||
link::Reader r(m.body);
|
|
||||||
const auto seconds = r.u8();
|
|
||||||
if (!r.done()) return reply(origin, seq, Code::malformed);
|
|
||||||
std::uint64_t handle = 0;
|
|
||||||
const auto result = station_.lock(seconds, handle);
|
|
||||||
link::Writer w; w.u64(handle);
|
|
||||||
return reply(origin, seq, result, result == Code::accepted ? w.out : link::Bytes {});
|
|
||||||
}
|
|
||||||
case Opcode::SF_ID_UNLOCK: {
|
|
||||||
link::Reader r(m.body);
|
|
||||||
const auto handle = r.u64();
|
|
||||||
if (!r.done()) return reply(origin, seq, Code::malformed);
|
|
||||||
return reply(origin, seq, station_.unlock(handle));
|
|
||||||
}
|
|
||||||
case Opcode::STATUS_REQUEST:
|
|
||||||
return send(origin, link::Opcode::STATUS, seq, link::encode(status()));
|
|
||||||
default:
|
|
||||||
return reply(origin, seq, Code::unknown_opcode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void LinkService::tick() {
|
|
||||||
const auto now = esp_timer_get_time();
|
|
||||||
if (now - last_status_us_ < 1000000) return;
|
|
||||||
last_status_us_ = now;
|
|
||||||
broadcast(link::Opcode::STATUS, ++own_sequence_, link::encode(status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// Dispatches station-link messages (phone -> micrOBU) to the station and sends the
|
|
||||||
// micrOBU -> phone messages (results, indications, identifier-change events, status).
|
|
||||||
#include "link_protocol.hpp"
|
|
||||||
#include "station.hpp"
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
|
|
||||||
/// @brief Represents the transport mechanism for link messages. Either BLE or Serial
|
|
||||||
enum class LinkTransport : std::uint8_t { serial, ble };
|
|
||||||
|
|
||||||
class LinkService {
|
|
||||||
public:
|
|
||||||
/// @brief Binds the service to a station and subscribes to its indication/id-event callbacks.
|
|
||||||
/// @param station Station to bind to; must outlive the LinkService.
|
|
||||||
explicit LinkService(Station& station);
|
|
||||||
/// @brief Decodes and dispatches one received message; call from the station task.
|
|
||||||
/// @param message Raw message bytes as received from the transport.
|
|
||||||
/// @param origin Transport the message arrived on; replies go back on the same transport.
|
|
||||||
void handle(const link::Bytes& message, LinkTransport origin);
|
|
||||||
/// @brief Periodic work: broadcasts STATUS once a second.
|
|
||||||
void tick();
|
|
||||||
private:
|
|
||||||
/// @brief Encodes and writes one message on the given transport.
|
|
||||||
/// @param transport Transport to write to.
|
|
||||||
/// @param opcode Message opcode.
|
|
||||||
/// @param sequence Sequence number to stamp on the header.
|
|
||||||
/// @param body Encoded message body.
|
|
||||||
void send(LinkTransport transport, link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body);
|
|
||||||
/// @brief Sends a message on serial, and also on BLE when a phone is connected.
|
|
||||||
/// @param opcode Message opcode.
|
|
||||||
/// @param sequence Sequence number to stamp on the header.
|
|
||||||
/// @param body Encoded message body.
|
|
||||||
void broadcast(link::Opcode opcode, std::uint16_t sequence, const link::Bytes& body);
|
|
||||||
/// @brief Sends a RESULT reply to the message's origin transport.
|
|
||||||
/// @param origin Transport to reply on.
|
|
||||||
/// @param sequence Sequence number of the message being replied to.
|
|
||||||
/// @param code Result code to report.
|
|
||||||
/// @param detail Optional result detail bytes.
|
|
||||||
void reply(LinkTransport origin, std::uint16_t sequence, link::Code code, const link::Bytes& detail = {});
|
|
||||||
/// @brief Builds a STATUS snapshot from the station and transport counters.
|
|
||||||
/// @return Current status.
|
|
||||||
link::Status status();
|
|
||||||
Station& station_;
|
|
||||||
std::uint16_t own_sequence_ = 0;
|
|
||||||
link::Bytes bundle_; // credentials being provisioned
|
|
||||||
std::uint16_t bundle_total_ = 0;
|
|
||||||
std::int64_t last_status_us_ = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
[mapping:newlib_memcmp]
|
|
||||||
archive: libnewlib.a
|
|
||||||
entries:
|
|
||||||
memcmp (noflash)
|
|
||||||
@@ -259,13 +259,8 @@ static void wifi_promisc_rx_cb(void *recv_buf, wifi_promiscuous_pkt_type_t type)
|
|||||||
s_cb_item.rssi = packet->rx_ctrl.rssi;
|
s_cb_item.rssi = packet->rx_ctrl.rssi;
|
||||||
|
|
||||||
// 0 timeout: never block the WiFi driver's own task waiting for queue space. xQueueSend copies
|
// 0 timeout: never block the WiFi driver's own task waiting for queue space. xQueueSend copies
|
||||||
// the struct out before returning, so reusing s_cb_item on the next callback is fine. The
|
// the struct out before returning, so reusing s_cb_item on the next callback is fine.
|
||||||
// return value used to go unchecked, so a full queue (rx_forward_task still draining a
|
xQueueSend(s_rx_queue, &s_cb_item, 0);
|
||||||
// previous burst) silently ate frames with no counter anywhere - see
|
|
||||||
// serial_link_note_rx_queue_drop()'s KDoc.
|
|
||||||
if (xQueueSend(s_rx_queue, &s_cb_item, 0) != pdTRUE) {
|
|
||||||
serial_link_note_rx_queue_drop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void rx_forward_task(void *arg)
|
static void rx_forward_task(void *arg)
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
// Sourced from OpenTrafficMap's its-g5-receiver-firmware_txenabled, main/tx_custom.c
|
|
||||||
// (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled, pinned commit
|
|
||||||
// 674e34128279235ba34c9f8d778f43cf1d075397, no stated license). Reproduced here as generated by
|
|
||||||
// implementation/external/vanetza-idf/ports/esp_idf/radio_c5.cmake (which hashes the upstream file
|
|
||||||
// so this copy cannot silently drift) and checked in for readability. The struct layouts
|
|
||||||
// (x_eb_txdesc_t, x_middle_data_t, x_ebuf_t) and the bit-level manipulation of esp_wifi's private
|
|
||||||
// tx descriptor are OTM's own reverse engineering of the closed esp_wifi/libphy internals -- not
|
|
||||||
// documented or supported by Espressif, and not verified independently by this project. The one
|
|
||||||
// deliberate change from upstream is `result = ieee80211_post_hmac_tx(eb);` below: upstream
|
|
||||||
// discards that return value, but ESP_OK from this function means the driver accepted the frame
|
|
||||||
// for submission, not that it was independently observed on air (see
|
|
||||||
// experiments/evidence/c5-radio-characterization/phy-internals-investigation.md).
|
|
||||||
#include "esp_private/wifi_os_adapter.h"
|
|
||||||
#include "esp_wifi.h"
|
|
||||||
#include "otm_tx_custom.h"
|
|
||||||
#include <assert.h>
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
esp_err_t ieee80211_raw_frame_sanity_check(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq);
|
|
||||||
esp_err_t ieee80211_post_hmac_tx(void *ebuf);
|
|
||||||
void *ic_ebuf_alloc(const void *packet, uint32_t unknown, uint32_t len);
|
|
||||||
void *ic_get_default_sched(void);
|
|
||||||
|
|
||||||
extern wifi_osi_funcs_t *g_osi_funcs_p;
|
|
||||||
extern void *g_wifi_global_lock;
|
|
||||||
|
|
||||||
typedef struct x_eb_txdesc
|
|
||||||
{
|
|
||||||
uint32_t flags;
|
|
||||||
uint32_t field_4;
|
|
||||||
uint32_t field_8;
|
|
||||||
uint8_t rate;
|
|
||||||
uint8_t field_d;
|
|
||||||
uint8_t field_e;
|
|
||||||
uint8_t field_f;
|
|
||||||
uint32_t field_10;
|
|
||||||
uint32_t field_14;
|
|
||||||
uint32_t timestamp;
|
|
||||||
void* sched;
|
|
||||||
uint32_t field_20;
|
|
||||||
uint32_t field_24;
|
|
||||||
uint32_t field_28;
|
|
||||||
union {
|
|
||||||
uint32_t field_2c_32;
|
|
||||||
struct {
|
|
||||||
uint8_t field_2c;
|
|
||||||
uint8_t field_2d;
|
|
||||||
uint8_t field_2e;
|
|
||||||
uint8_t field_2f;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
union {
|
|
||||||
uint32_t field_30_32;
|
|
||||||
struct {
|
|
||||||
uint8_t field_30;
|
|
||||||
uint8_t field_31;
|
|
||||||
uint8_t field_32;
|
|
||||||
uint8_t field_33;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
uint32_t field_34;
|
|
||||||
uint32_t field_38;
|
|
||||||
uint32_t field_3c;
|
|
||||||
uint32_t field_40;
|
|
||||||
uint32_t field_44;
|
|
||||||
} x_eb_txdesc_t;
|
|
||||||
_Static_assert(sizeof(x_eb_txdesc_t) == 0x48, "eb_txdesc size");
|
|
||||||
|
|
||||||
typedef struct x_middle_data
|
|
||||||
{
|
|
||||||
uint32_t field_40;
|
|
||||||
uint8_t* buf;
|
|
||||||
uint32_t field_48;
|
|
||||||
uint32_t field_4c;
|
|
||||||
} x_middle_data_t;
|
|
||||||
_Static_assert(sizeof(x_middle_data_t) == 0x10, "middle_data size");
|
|
||||||
|
|
||||||
typedef struct x_ebuf
|
|
||||||
{
|
|
||||||
uint32_t field_0;
|
|
||||||
x_middle_data_t* ds_head;
|
|
||||||
x_middle_data_t* ds_tail;
|
|
||||||
uint16_t field_c;
|
|
||||||
uint16_t field_e;
|
|
||||||
uint32_t extra_data_start;
|
|
||||||
uint16_t header_length;
|
|
||||||
uint32_t data_length;
|
|
||||||
uint16_t field_1c;
|
|
||||||
uint8_t alloc_type;
|
|
||||||
uint8_t field_1f;
|
|
||||||
uint32_t field_20;
|
|
||||||
uint8_t field_24;
|
|
||||||
uint8_t field_25;
|
|
||||||
uint8_t field_26;
|
|
||||||
uint8_t field_27;
|
|
||||||
uint32_t field_28;
|
|
||||||
uint8_t field_2c;
|
|
||||||
uint32_t field_30;
|
|
||||||
uint32_t next_free;
|
|
||||||
x_eb_txdesc_t* txdesc;
|
|
||||||
uint16_t field_3c;
|
|
||||||
uint8_t field_3e;
|
|
||||||
uint8_t field_3f;
|
|
||||||
} x_ebuf_t;
|
|
||||||
_Static_assert(sizeof(x_ebuf_t) == 0x40, "ebuf size");
|
|
||||||
_Static_assert(offsetof(x_ebuf_t, txdesc) == 0x38, "ebuf txdesc offset");
|
|
||||||
_Static_assert(offsetof(x_eb_txdesc_t, rate) == 0x0c, "txdesc rate offset");
|
|
||||||
|
|
||||||
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len, bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config, wifi_band_t band, wifi_bandwidth_t bw)
|
|
||||||
{
|
|
||||||
esp_err_t result = 0;
|
|
||||||
|
|
||||||
if (!result)
|
|
||||||
{
|
|
||||||
g_osi_funcs_p->_mutex_lock(g_wifi_global_lock);
|
|
||||||
x_ebuf_t* eb = ic_ebuf_alloc(buffer, 1, len);
|
|
||||||
|
|
||||||
if (eb)
|
|
||||||
{
|
|
||||||
eb->data_length = 0;
|
|
||||||
x_eb_txdesc_t *txdesc_1 = eb->txdesc;
|
|
||||||
eb->header_length = len;
|
|
||||||
txdesc_1->flags |= 0x4000;
|
|
||||||
txdesc_1->sched = ic_get_default_sched();
|
|
||||||
wifi_phy_rate_t rate = tx_rate_config->rate;
|
|
||||||
x_eb_txdesc_t *txdesc = eb->txdesc;
|
|
||||||
|
|
||||||
if (rate)
|
|
||||||
txdesc->rate = (char)rate;
|
|
||||||
else if (band != WIFI_BAND_5G)
|
|
||||||
txdesc->rate = 0;
|
|
||||||
else
|
|
||||||
txdesc->rate = (char)WIFI_PHY_RATE_6M;
|
|
||||||
|
|
||||||
wifi_phy_mode_t phymode = tx_rate_config->phymode;
|
|
||||||
|
|
||||||
if (phymode == WIFI_PHY_MODE_HE20)
|
|
||||||
{
|
|
||||||
txdesc->flags |= 0x80000000;
|
|
||||||
txdesc->field_2f =
|
|
||||||
(char)((((uint32_t)tx_rate_config->ersu + 6) & 0xf) << 3)
|
|
||||||
| (txdesc->field_2f & 0x87);
|
|
||||||
|
|
||||||
if ((uint32_t)tx_rate_config->dcm)
|
|
||||||
txdesc->field_31 |= 0x80;
|
|
||||||
}
|
|
||||||
else if (phymode == WIFI_PHY_MODE_VHT20)
|
|
||||||
txdesc->flags |= 0x1000000;
|
|
||||||
|
|
||||||
// OTM's own comment on this line upstream: "No idea if this is correct, but this is
|
|
||||||
// what the original code does...". This project always passes WIFI_BW20 (see
|
|
||||||
// c5_radio.cpp), so bw_is_bw40 is always 0 here; see the investigation doc above for
|
|
||||||
// what is and isn't verified about ITS-G5's 10 MHz channel width on this path.
|
|
||||||
uint32_t bw_is_bw40 = bw == WIFI_BW40;
|
|
||||||
txdesc->field_8 = (bw_is_bw40 << 0xf) | (txdesc->field_8 & 0xffff7fff);
|
|
||||||
|
|
||||||
if (en_sys_seq)
|
|
||||||
txdesc->flags |= 1;
|
|
||||||
|
|
||||||
txdesc->field_10 =
|
|
||||||
(txdesc->field_10 & 0xfff3ffff) | ((ifx & WIFI_IF_MAX) << 0x12);
|
|
||||||
txdesc->field_14 = 0x100;
|
|
||||||
|
|
||||||
result = ieee80211_post_hmac_tx(eb);
|
|
||||||
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result = ESP_ERR_NO_MEM;
|
|
||||||
g_osi_funcs_p->_mutex_unlock(g_wifi_global_lock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
// Sourced from OpenTrafficMap's its-g5-receiver-firmware_txenabled, main/tx_custom.h
|
|
||||||
// (https://codeberg.org/opentrafficmap/its-g5-receiver-firmware_txenabled, pinned commit
|
|
||||||
// 674e34128279235ba34c9f8d778f43cf1d075397, no stated license). That project reverse-engineered
|
|
||||||
// this ESP32-C5 ROM/libphy entry point -- it is not documented or supported by Espressif.
|
|
||||||
// See experiments/evidence/c5-radio-characterization/phy-internals-investigation.md for what
|
|
||||||
// this repository independently verified (by disassembling libphy.a) versus what is still an
|
|
||||||
// unverified upstream guess.
|
|
||||||
|
|
||||||
#include "esp_wifi.h"
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
esp_err_t esp_wifi_80211_tx_custom(wifi_interface_t ifx, const void *buffer, int32_t len,
|
|
||||||
bool en_sys_seq, wifi_tx_rate_config_t *tx_rate_config,
|
|
||||||
wifi_band_t band, wifi_bandwidth_t bw);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -21,7 +21,6 @@ static serial_link_cam_tx_pv_cb_t s_on_cam_tx_pv;
|
|||||||
static uint16_t s_oversize_drops;
|
static uint16_t s_oversize_drops;
|
||||||
static uint16_t s_tx_failures;
|
static uint16_t s_tx_failures;
|
||||||
static uint16_t s_rx_crc_errors;
|
static uint16_t s_rx_crc_errors;
|
||||||
static uint16_t s_rx_queue_drops;
|
|
||||||
|
|
||||||
// Serializes send_frame(): it writes a frame as four separate usb_serial_jtag_write_bytes() calls
|
// Serializes send_frame(): it writes a frame as four separate usb_serial_jtag_write_bytes() calls
|
||||||
// and shares one static CRC scratch buffer, and it's now called from three tasks (rx_forward for
|
// and shares one static CRC scratch buffer, and it's now called from three tasks (rx_forward for
|
||||||
@@ -46,12 +45,6 @@ void serial_link_note_oversize_drop(uint16_t btp_dest_port)
|
|||||||
btp_dest_port, s_oversize_drops);
|
btp_dest_port, s_oversize_drops);
|
||||||
}
|
}
|
||||||
|
|
||||||
void serial_link_note_rx_queue_drop(void)
|
|
||||||
{
|
|
||||||
bump(&s_rx_queue_drops);
|
|
||||||
ESP_LOGW(TAG, "rx queue full, dropped a captured frame, total rx queue drops %u", s_rx_queue_drops);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
|
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
|
||||||
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
|
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
|
||||||
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
|
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
|
||||||
@@ -172,10 +165,9 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
|
|||||||
|
|
||||||
bool serial_link_send_status(uint8_t status)
|
bool serial_link_send_status(uint8_t status)
|
||||||
{
|
{
|
||||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
|
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1] -
|
||||||
// [rx_queue_drops:2 LE] - keep in lockstep with EspLinkStatus.parse() in the app's
|
// keep in lockstep with EspLinkStatus.parse() in the app's SerialFrame.kt.
|
||||||
// SerialFrame.kt.
|
uint8_t payload[8];
|
||||||
uint8_t payload[10];
|
|
||||||
payload[0] = status;
|
payload[0] = status;
|
||||||
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
|
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
|
||||||
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
|
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
|
||||||
@@ -186,8 +178,6 @@ bool serial_link_send_status(uint8_t status)
|
|||||||
// What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which
|
// What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which
|
||||||
// is what lets a new app keep working against firmware that predates that message.
|
// is what lets a new app keep working against firmware that predates that message.
|
||||||
payload[7] = SERIAL_CAP_CAM_TX_PV;
|
payload[7] = SERIAL_CAP_CAM_TX_PV;
|
||||||
payload[8] = (uint8_t)(s_rx_queue_drops & 0xFF);
|
|
||||||
payload[9] = (uint8_t)((s_rx_queue_drops >> 8) & 0xFF);
|
|
||||||
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
|
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
#include "serial_link.hpp"
|
|
||||||
#include "sdkconfig.h"
|
|
||||||
#include <driver/usb_serial_jtag.h>
|
|
||||||
#include <esp_log.h>
|
|
||||||
#include <esp_timer.h>
|
|
||||||
#include <freertos/FreeRTOS.h>
|
|
||||||
#include <freertos/semphr.h>
|
|
||||||
#include <freertos/task.h>
|
|
||||||
#include <cstdarg>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <atomic>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
namespace microbu::serial {
|
|
||||||
namespace {
|
|
||||||
std::uint16_t crc16_ccitt_false_step(std::uint16_t crc, std::uint8_t octet) {
|
|
||||||
crc ^= static_cast<std::uint16_t>(octet) << 8;
|
|
||||||
for (int bit = 0; bit < 8; ++bit) crc = (crc & 0x8000) ? static_cast<std::uint16_t>((crc << 1) ^ 0x1021) : static_cast<std::uint16_t>(crc << 1);
|
|
||||||
return crc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::uint16_t crc16_ccitt_false(const std::uint8_t* data, std::size_t length) {
|
|
||||||
std::uint16_t crc = 0xFFFF;
|
|
||||||
for (std::size_t i = 0; i < length; ++i) crc = crc16_ccitt_false_step(crc, data[i]);
|
|
||||||
return crc;
|
|
||||||
}
|
|
||||||
|
|
||||||
Bytes encode_frame(FrameType type, const Bytes& payload) {
|
|
||||||
Bytes out;
|
|
||||||
out.reserve(payload.size() + 7);
|
|
||||||
out.push_back(0xAA); out.push_back(0x55);
|
|
||||||
out.push_back(static_cast<std::uint8_t>(type));
|
|
||||||
out.push_back(payload.size() & 0xFF); out.push_back(payload.size() >> 8);
|
|
||||||
out.insert(out.end(), payload.begin(), payload.end());
|
|
||||||
const auto crc = crc16_ccitt_false(out.data() + 2, out.size() - 2);
|
|
||||||
out.push_back(crc & 0xFF); out.push_back(crc >> 8);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Decoder::feed(const std::uint8_t* data, std::size_t length, const Handler& handler) {
|
|
||||||
for (std::size_t i = 0; i < length; ++i) {
|
|
||||||
const std::uint8_t b = data[i];
|
|
||||||
switch (state_) {
|
|
||||||
case State::SYNC0: state_ = (b == 0xAA) ? State::SYNC1 : State::SYNC0; break;
|
|
||||||
case State::SYNC1: state_ = (b == 0x55) ? State::TYPE : (b == 0xAA ? State::SYNC1 : State::SYNC0); break;
|
|
||||||
case State::TYPE: type_ = b; state_ = State::LEN_LO; break;
|
|
||||||
case State::LEN_LO: length_ = b; state_ = State::LEN_HI; break;
|
|
||||||
case State::LEN_HI:
|
|
||||||
length_ |= static_cast<std::uint16_t>(b) << 8;
|
|
||||||
payload_.clear();
|
|
||||||
if (length_ > maximum_payload) state_ = State::SYNC0; // untrustworthy boundary: resync
|
|
||||||
else state_ = length_ == 0 ? State::CRC_LO : State::PAYLOAD;
|
|
||||||
break;
|
|
||||||
case State::PAYLOAD:
|
|
||||||
payload_.push_back(b);
|
|
||||||
if (payload_.size() >= length_) state_ = State::CRC_LO;
|
|
||||||
break;
|
|
||||||
case State::CRC_LO: crc_ = b; state_ = State::CRC_HI; break;
|
|
||||||
case State::CRC_HI: {
|
|
||||||
crc_ |= static_cast<std::uint16_t>(b) << 8;
|
|
||||||
// CRC over head then payload without concatenating them: same running value as encode_frame.
|
|
||||||
const std::uint8_t head[3] = {type_, static_cast<std::uint8_t>(length_ & 0xFF), static_cast<std::uint8_t>(length_ >> 8)};
|
|
||||||
std::uint16_t crc = 0xFFFF;
|
|
||||||
for (auto octet : head) crc = crc16_ccitt_false_step(crc, octet);
|
|
||||||
for (auto octet : payload_) crc = crc16_ccitt_false_step(crc, octet);
|
|
||||||
if (crc == crc_) { ++frames_; handler(Frame {static_cast<FrameType>(type_), payload_}); }
|
|
||||||
else ++crc_errors_;
|
|
||||||
state_ = State::SYNC0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
SemaphoreHandle_t writer_lock = nullptr;
|
|
||||||
Counters the_counters;
|
|
||||||
Decoder::Handler frame_handler;
|
|
||||||
std::atomic<std::uint32_t> last_frame_at_ms {0}; // 32-bit: no libatomic needed on RV32
|
|
||||||
#if CONFIG_MICROBU_LOG_OVER_LINK
|
|
||||||
vprintf_like_t previous_vprintf = nullptr;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
bool write_all(const Bytes& frame) {
|
|
||||||
std::size_t sent = 0;
|
|
||||||
while (sent < frame.size()) {
|
|
||||||
// MicrOBU: 50 ms, not the colleague's 500. A phone that is plugged in but not reading (app
|
|
||||||
// not running) fills the driver's buffer, and every write then waits out this timeout on
|
|
||||||
// the station task, which also serves BLE and the radio.
|
|
||||||
const int count = usb_serial_jtag_write_bytes(frame.data() + sent, frame.size() - sent, pdMS_TO_TICKS(50));
|
|
||||||
if (count <= 0) return false; // the host detects an incomplete frame by CRC
|
|
||||||
sent += count;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
#if CONFIG_MICROBU_LOG_OVER_LINK
|
|
||||||
// ESP_LOG sink: one LOG frame per call, never a raw write into the frame stream.
|
|
||||||
int log_to_frame(const char* format, va_list args) {
|
|
||||||
char line[256];
|
|
||||||
const int length = std::vsnprintf(line, sizeof line, format, args);
|
|
||||||
if (length <= 0) return 0;
|
|
||||||
std::size_t n = static_cast<std::size_t>(length) < sizeof line ? length : sizeof line - 1;
|
|
||||||
while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r')) --n;
|
|
||||||
if (n == 0) return length;
|
|
||||||
// esp_log colour escapes are stripped by the emulator; keep the line verbatim otherwise.
|
|
||||||
write(FrameType::LOG, Bytes(line, line + n));
|
|
||||||
return length;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
void reader_task(void*) {
|
|
||||||
Decoder decoder;
|
|
||||||
std::uint8_t buffer[512];
|
|
||||||
for (;;) {
|
|
||||||
const int count = usb_serial_jtag_read_bytes(buffer, sizeof buffer, pdMS_TO_TICKS(20));
|
|
||||||
if (count > 0) {
|
|
||||||
decoder.feed(buffer, static_cast<std::size_t>(count), [](Frame frame) {
|
|
||||||
last_frame_at_ms = static_cast<std::uint32_t>(esp_timer_get_time() / 1000);
|
|
||||||
frame_handler(std::move(frame));
|
|
||||||
});
|
|
||||||
the_counters.frames = decoder.frames();
|
|
||||||
the_counters.crc_errors = decoder.crc_errors();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void start(const Decoder::Handler& on_frame) {
|
|
||||||
frame_handler = on_frame;
|
|
||||||
writer_lock = xSemaphoreCreateMutex();
|
|
||||||
usb_serial_jtag_driver_config_t config {};
|
|
||||||
config.rx_buffer_size = 8192;
|
|
||||||
config.tx_buffer_size = 8192;
|
|
||||||
ESP_ERROR_CHECK(usb_serial_jtag_driver_install(&config));
|
|
||||||
#if CONFIG_MICROBU_LOG_OVER_LINK
|
|
||||||
previous_vprintf = esp_log_set_vprintf(log_to_frame);
|
|
||||||
#endif
|
|
||||||
xTaskCreate(reader_task, "link_rx", 6144, nullptr, 12, nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool write(FrameType type, const Bytes& payload) {
|
|
||||||
if (payload.size() > maximum_payload || !writer_lock) return false;
|
|
||||||
// MicrOBU: nothing on the native port (phone on BLE, or unplugged). Without this check every
|
|
||||||
// write, the 1 Hz STATUS included, would block the station task until the timeout.
|
|
||||||
if (!usb_serial_jtag_is_connected()) { ++the_counters.not_connected; return false; }
|
|
||||||
const auto frame = encode_frame(type, payload);
|
|
||||||
if (xSemaphoreTake(writer_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { ++the_counters.write_failures; return false; }
|
|
||||||
const bool ok = write_all(frame);
|
|
||||||
xSemaphoreGive(writer_lock);
|
|
||||||
if (!ok) ++the_counters.write_failures;
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
Counters counters() { return the_counters; }
|
|
||||||
|
|
||||||
std::uint32_t last_frame_ms() { return last_frame_at_ms.load(); }
|
|
||||||
|
|
||||||
} // namespace microbu::serial
|
|
||||||
@@ -52,14 +52,11 @@
|
|||||||
// message's own ItsPduHeader.stationID is the meaningful identifier.
|
// message's own ItsPduHeader.stationID is the meaningful identifier.
|
||||||
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
|
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
|
||||||
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
|
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
|
||||||
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 10 bytes:
|
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 8 bytes:
|
||||||
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
|
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
|
||||||
// [rx_queue_drops:2 LE]
|
|
||||||
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
|
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
|
||||||
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
|
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
|
||||||
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected;
|
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected.
|
||||||
// rx_queue_drops (bytes 8-9) follows the same rule for an app that predates it. Either side
|
|
||||||
// reading a payload shorter than the field it wants should treat that field as 0, not error.
|
|
||||||
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
|
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
|
||||||
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
|
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
|
||||||
// in the app's SerialFrame.kt.
|
// in the app's SerialFrame.kt.
|
||||||
@@ -168,13 +165,4 @@ void serial_link_note_tx_failure(void);
|
|||||||
// whose capture buffer is smaller than the largest frames on air.
|
// whose capture buffer is smaller than the largest frames on air.
|
||||||
void serial_link_note_oversize_drop(uint16_t btp_dest_port);
|
void serial_link_note_oversize_drop(uint16_t btp_dest_port);
|
||||||
|
|
||||||
// Counts a promiscuously-captured frame that main.c's wifi_promisc_rx_cb() could not hand to
|
|
||||||
// rx_forward_task because s_rx_queue was full - i.e. frames arrived faster than the forward task
|
|
||||||
// (gn_unwrap + a blocking USB write, up to SERIAL_LINK_WRITE_TIMEOUT_MS x 4 per frame under
|
|
||||||
// contention) could drain them. Unlike oversize_drop this is not about one frame's size; it is
|
|
||||||
// about a burst of otherwise-forwardable frames. Previously silent - xQueueSend's return value
|
|
||||||
// was not even checked - so a run of these had no visible symptom beyond "that station's CAM
|
|
||||||
// count looked a little low."
|
|
||||||
void serial_link_note_rx_queue_drop(void);
|
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// Serial transport of the station-internal link over the ESP32-C5 native USB Serial/JTAG port.
|
|
||||||
// Framing is the app's Phase 03 one: [AA][55][type][len LE][payload][crc16 LE] with
|
|
||||||
// CRC-16/CCITT-FALSE over type+len+payload (implementation/station-link/README.md, "Serial").
|
|
||||||
// One writer serialises complete frames. The byte stream never carries plain text: ESP_LOG stays on
|
|
||||||
// UART0 here, or travels as LOG frames with CONFIG_MICROBU_LOG_OVER_LINK.
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <functional>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace microbu::serial {
|
|
||||||
|
|
||||||
/// @brief Byte buffer type for frame payloads.
|
|
||||||
using Bytes = std::vector<std::uint8_t>;
|
|
||||||
|
|
||||||
/// @brief The type of a frame. Either Link (link protocol), Test (test channel), or Log (ESP_LOG output).
|
|
||||||
enum class FrameType : std::uint8_t { LINK = 0x10, TEST = 0x11, LOG = 0x7F };
|
|
||||||
|
|
||||||
constexpr std::size_t maximum_payload = 1536;
|
|
||||||
|
|
||||||
/// @brief A complete frame with a type and a payload.
|
|
||||||
struct Frame { FrameType type; Bytes payload; };
|
|
||||||
|
|
||||||
/// @brief Computes the CRC-16/CCITT-FALSE checksum over a byte range.
|
|
||||||
/// @param data Pointer to the first byte to checksum.
|
|
||||||
/// @param length Number of bytes to checksum.
|
|
||||||
/// @return Checksum value.
|
|
||||||
std::uint16_t crc16_ccitt_false(const std::uint8_t* data, std::size_t length);
|
|
||||||
/// @brief Frames a payload as [AA][55][type][len LE][payload][crc16 LE].
|
|
||||||
/// @param type Frame type tag.
|
|
||||||
/// @param payload Payload bytes; must not exceed maximum_payload.
|
|
||||||
/// @return Encoded frame bytes.
|
|
||||||
Bytes encode_frame(FrameType type, const Bytes& payload);
|
|
||||||
|
|
||||||
/// Byte-at-a-time decoder, same state machine as the app's SerialFrameDecoder.
|
|
||||||
class Decoder {
|
|
||||||
public:
|
|
||||||
using Handler = std::function<void(Frame)>;
|
|
||||||
/// @brief Feeds raw bytes through the frame state machine, invoking the handler per complete frame.
|
|
||||||
/// @param data Pointer to the first byte to feed.
|
|
||||||
/// @param length Number of bytes to feed.
|
|
||||||
/// @param handler Invoked once per complete, CRC-valid frame; may be called zero or more times.
|
|
||||||
void feed(const std::uint8_t* data, std::size_t length, const Handler& handler);
|
|
||||||
/// @return Number of frames rejected for a CRC mismatch so far.
|
|
||||||
std::uint32_t crc_errors() const { return crc_errors_; }
|
|
||||||
/// @return Number of frames decoded successfully so far.
|
|
||||||
std::uint32_t frames() const { return frames_; }
|
|
||||||
private:
|
|
||||||
enum class State { SYNC0, SYNC1, TYPE, LEN_LO, LEN_HI, PAYLOAD, CRC_LO, CRC_HI } state_ = State::SYNC0;
|
|
||||||
std::uint8_t type_ = 0;
|
|
||||||
std::uint16_t length_ = 0, crc_ = 0;
|
|
||||||
Bytes payload_;
|
|
||||||
std::uint32_t crc_errors_ = 0, frames_ = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct Counters { std::uint32_t frames = 0, crc_errors = 0, write_failures = 0, not_connected = 0; };
|
|
||||||
|
|
||||||
/// @brief Installs the USB Serial/JTAG driver and starts the reader task. ESP_LOG goes into LOG frames
|
|
||||||
/// only with CONFIG_MICROBU_LOG_OVER_LINK; on this board it stays on the UART0 console.
|
|
||||||
/// @param on_frame Invoked once per complete, CRC-valid frame.
|
|
||||||
/// @note The handler runs on the reader task; it must only enqueue, never block.
|
|
||||||
void start(const Decoder::Handler& on_frame);
|
|
||||||
/// @brief Writes one complete frame (blocking, mutex-protected). Safe from any task.
|
|
||||||
/// @param type Frame type tag.
|
|
||||||
/// @param payload Payload bytes; must not exceed maximum_payload.
|
|
||||||
/// @return false on a full payload, missing driver, lock timeout, or write failure.
|
|
||||||
bool write(FrameType type, const Bytes& payload);
|
|
||||||
/// @return Current frame/error counters.
|
|
||||||
Counters counters();
|
|
||||||
/// @return esp_timer time in ms (wrapping) at which the last CRC-valid frame arrived from the host,
|
|
||||||
/// 0 if none yet. MicrOBU: app_main pauses BLE advertising while this is recent.
|
|
||||||
std::uint32_t last_frame_ms();
|
|
||||||
|
|
||||||
} // namespace microbu::serial
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
#include "simple_ble.hpp"
|
|
||||||
#include "sdkconfig.h"
|
|
||||||
|
|
||||||
#include <esp_log.h>
|
|
||||||
#include <esp_mac.h>
|
|
||||||
#include <freertos/FreeRTOS.h>
|
|
||||||
#include <freertos/task.h>
|
|
||||||
|
|
||||||
#include <nimble/ble.h>
|
|
||||||
#include <host/ble_att.h>
|
|
||||||
#include <host/ble_gap.h>
|
|
||||||
#include <host/ble_gatt.h>
|
|
||||||
#include <host/ble_hs.h>
|
|
||||||
#include <host/ble_hs_mbuf.h>
|
|
||||||
#include <host/ble_sm.h>
|
|
||||||
#include <host/ble_store.h>
|
|
||||||
#include <host/ble_uuid.h>
|
|
||||||
#include <host/util/util.h>
|
|
||||||
#include <nimble/nimble_port.h>
|
|
||||||
#include <nimble/nimble_port_freertos.h>
|
|
||||||
#include <services/gap/ble_svc_gap.h>
|
|
||||||
#include <services/gatt/ble_svc_gatt.h>
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
extern "C" void ble_store_config_init(void);
|
|
||||||
|
|
||||||
namespace microbu::ble {
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
const char* TAG = "cits_ble";
|
|
||||||
constexpr uint32_t FIXED_PASSKEY = 123456;
|
|
||||||
|
|
||||||
// Base UUID: 0000xxxx-ba5e-4c17-8000-00805f9b34fb
|
|
||||||
// Little-endian byte order for NimBLE (16 bytes, index 15 down to 0):
|
|
||||||
#define CITS_UUID_BASE(id_lo, id_hi) \
|
|
||||||
0xfb, 0x34, 0x9b, 0x5f, 0x80, 0x00, 0x00, 0x80, \
|
|
||||||
0x17, 0x4c, 0x5e, 0xba, (id_lo), (id_hi), 0x00, 0x00
|
|
||||||
|
|
||||||
static const ble_uuid128_t s_svc_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x75, 0xc1));
|
|
||||||
static const ble_uuid128_t s_chr_btp_req_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x76, 0xc1));
|
|
||||||
static const ble_uuid128_t s_chr_btp_ind_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x77, 0xc1));
|
|
||||||
static const ble_uuid128_t s_chr_poti_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x78, 0xc1));
|
|
||||||
static const ble_uuid128_t s_chr_status_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x79, 0xc1));
|
|
||||||
static const ble_uuid128_t s_chr_id_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x7a, 0xc1));
|
|
||||||
static const ble_uuid128_t s_chr_config_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x7b, 0xc1));
|
|
||||||
static const ble_uuid128_t s_chr_result_uuid = BLE_UUID128_INIT(CITS_UUID_BASE(0x7c, 0xc1));
|
|
||||||
|
|
||||||
enum class ChrId : uintptr_t {
|
|
||||||
BtpRequest = 1,
|
|
||||||
BtpIndication = 2,
|
|
||||||
Poti = 3,
|
|
||||||
Status = 4,
|
|
||||||
IdChange = 5,
|
|
||||||
Configure = 6,
|
|
||||||
Result = 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
Receiver s_receiver;
|
|
||||||
Counters statistics;
|
|
||||||
char s_dev_name[32] = "micrOBU";
|
|
||||||
uint16_t s_btp_ind_val_handle = 0;
|
|
||||||
uint16_t s_status_val_handle = 0;
|
|
||||||
uint16_t s_id_val_handle = 0;
|
|
||||||
uint16_t s_result_val_handle = 0;
|
|
||||||
volatile uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
|
|
||||||
int s_notify_count = 0; // number of characteristics with active CCCD subscription
|
|
||||||
bool s_subscribed = false;
|
|
||||||
uint8_t s_own_addr_type = 0;
|
|
||||||
volatile bool s_synced = false;
|
|
||||||
volatile bool s_adv_allowed = true;
|
|
||||||
|
|
||||||
// GATT characteristic read/write callback, shared by all characteristics (arg identifies which).
|
|
||||||
int chr_access(uint16_t conn_handle, uint16_t attr_handle,
|
|
||||||
struct ble_gatt_access_ctxt *ctxt, void *arg) {
|
|
||||||
uintptr_t id = reinterpret_cast<uintptr_t>(arg);
|
|
||||||
if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) {
|
|
||||||
uint16_t total = OS_MBUF_PKTLEN(ctxt->om);
|
|
||||||
if (total == 0 || total > link::maximum_message) {
|
|
||||||
ESP_LOGW(TAG, "rx invalid length: %u", (unsigned)total);
|
|
||||||
++statistics.malformed;
|
|
||||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
|
||||||
}
|
|
||||||
link::Bytes buf(total);
|
|
||||||
uint16_t copied = 0;
|
|
||||||
int rc = ble_hs_mbuf_to_flat(ctxt->om, buf.data(), total, &copied);
|
|
||||||
if (rc != 0 || copied != total) {
|
|
||||||
++statistics.malformed;
|
|
||||||
return BLE_ATT_ERR_UNLIKELY;
|
|
||||||
}
|
|
||||||
++statistics.rx_messages;
|
|
||||||
if (s_receiver) {
|
|
||||||
// Require valid link header (opcode + flags + seq).
|
|
||||||
// Host->station opcodes are 0x01..0x0F; station->host are 0x80+.
|
|
||||||
if (total >= link::header_size && buf[0] >= 0x01 && buf[0] <= 0x0F) {
|
|
||||||
ESP_LOGD(TAG, "rx chr %u op=0x%02x len=%u", (unsigned)id, buf[0], (unsigned)total);
|
|
||||||
s_receiver(std::move(buf));
|
|
||||||
} else {
|
|
||||||
ESP_LOGW(TAG, "rx chr %u unframed or invalid op=0x%02x len=%u", (unsigned)id, buf[0], (unsigned)total);
|
|
||||||
++statistics.malformed;
|
|
||||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
} else if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
|
|
||||||
if (id == static_cast<uintptr_t>(ChrId::Status)) {
|
|
||||||
return 0; // empty read; status is pushed via notify
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return BLE_ATT_ERR_READ_NOT_PERMITTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#define CHR_FLAG_RW_ENC (BLE_GATT_CHR_F_READ_ENC | BLE_GATT_CHR_F_READ_AUTHEN | \
|
|
||||||
BLE_GATT_CHR_F_WRITE_ENC | BLE_GATT_CHR_F_WRITE_AUTHEN)
|
|
||||||
#define CHR_FLAG_NOTIFY_ENC (BLE_GATT_CHR_F_NOTIFY_INDICATE_ENC | \
|
|
||||||
BLE_GATT_CHR_F_NOTIFY_INDICATE_AUTHEN)
|
|
||||||
|
|
||||||
#pragma GCC diagnostic push
|
|
||||||
#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
|
|
||||||
static struct ble_gatt_chr_def s_chr_defs[] = {
|
|
||||||
{
|
|
||||||
// NF-SAP: BTP-DATA.request (Central -> Station Write)
|
|
||||||
.uuid = &s_chr_btp_req_uuid.u,
|
|
||||||
.access_cb = chr_access,
|
|
||||||
.arg = reinterpret_cast<void*>(ChrId::BtpRequest),
|
|
||||||
.flags = BLE_GATT_CHR_F_WRITE | CHR_FLAG_RW_ENC,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// NF-SAP: BTP-DATA.indication (Station -> Central Notify)
|
|
||||||
.uuid = &s_chr_btp_ind_uuid.u,
|
|
||||||
.access_cb = chr_access,
|
|
||||||
.arg = reinterpret_cast<void*>(ChrId::BtpIndication),
|
|
||||||
.flags = BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_NOTIFY_ENC,
|
|
||||||
.val_handle = &s_btp_ind_val_handle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// PoTi: Position & Time Fix Update (Central -> Station Write)
|
|
||||||
.uuid = &s_chr_poti_uuid.u,
|
|
||||||
.access_cb = chr_access,
|
|
||||||
.arg = reinterpret_cast<void*>(ChrId::Poti),
|
|
||||||
.flags = BLE_GATT_CHR_F_WRITE | CHR_FLAG_RW_ENC,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Station Status (Station -> Central Read/Notify)
|
|
||||||
.uuid = &s_chr_status_uuid.u,
|
|
||||||
.access_cb = chr_access,
|
|
||||||
.arg = reinterpret_cast<void*>(ChrId::Status),
|
|
||||||
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_RW_ENC | CHR_FLAG_NOTIFY_ENC,
|
|
||||||
.val_handle = &s_status_val_handle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// SF-SAP: Identity Change Event (Station -> Central Notify)
|
|
||||||
.uuid = &s_chr_id_uuid.u,
|
|
||||||
.access_cb = chr_access,
|
|
||||||
.arg = reinterpret_cast<void*>(ChrId::IdChange),
|
|
||||||
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_RW_ENC | CHR_FLAG_NOTIFY_ENC,
|
|
||||||
.val_handle = &s_id_val_handle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Station Configure / Credentials (Central -> Station Write)
|
|
||||||
.uuid = &s_chr_config_uuid.u,
|
|
||||||
.access_cb = chr_access,
|
|
||||||
.arg = reinterpret_cast<void*>(ChrId::Configure),
|
|
||||||
.flags = BLE_GATT_CHR_F_WRITE | CHR_FLAG_RW_ENC,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Result / Response (Station -> Central Notify)
|
|
||||||
.uuid = &s_chr_result_uuid.u,
|
|
||||||
.access_cb = chr_access,
|
|
||||||
.arg = reinterpret_cast<void*>(ChrId::Result),
|
|
||||||
.flags = BLE_GATT_CHR_F_NOTIFY | CHR_FLAG_NOTIFY_ENC,
|
|
||||||
.val_handle = &s_result_val_handle,
|
|
||||||
},
|
|
||||||
{0}
|
|
||||||
};
|
|
||||||
|
|
||||||
static struct ble_gatt_svc_def s_svc_defs[] = {
|
|
||||||
{
|
|
||||||
.type = BLE_GATT_SVC_TYPE_PRIMARY,
|
|
||||||
.uuid = &s_svc_uuid.u,
|
|
||||||
.characteristics = s_chr_defs,
|
|
||||||
},
|
|
||||||
{0}
|
|
||||||
};
|
|
||||||
#pragma GCC diagnostic pop
|
|
||||||
|
|
||||||
int start_advertising();
|
|
||||||
|
|
||||||
// NimBLE GAP callback: connect/disconnect, pairing (fixed-passkey), and notify subscriptions.
|
|
||||||
int gap_event(struct ble_gap_event *event, void *arg) {
|
|
||||||
struct ble_gap_conn_desc desc;
|
|
||||||
switch (event->type) {
|
|
||||||
case BLE_GAP_EVENT_CONNECT:
|
|
||||||
ESP_LOGI(TAG, "connect status=%d handle=%d", event->connect.status, event->connect.conn_handle);
|
|
||||||
if (event->connect.status == 0) {
|
|
||||||
s_conn_handle = event->connect.conn_handle;
|
|
||||||
s_subscribed = false;
|
|
||||||
struct ble_gap_upd_params params = {};
|
|
||||||
params.itvl_min = 12; // 15 ms
|
|
||||||
params.itvl_max = 16; // 20 ms
|
|
||||||
params.latency = 0;
|
|
||||||
params.supervision_timeout = 400; // 4 s
|
|
||||||
params.min_ce_len = 0;
|
|
||||||
params.max_ce_len = 0;
|
|
||||||
ble_gap_update_params(event->connect.conn_handle, ¶ms);
|
|
||||||
ble_gap_security_initiate(event->connect.conn_handle);
|
|
||||||
} else {
|
|
||||||
start_advertising();
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
case BLE_GAP_EVENT_DISCONNECT:
|
|
||||||
ESP_LOGI(TAG, "disconnect reason=%d", event->disconnect.reason);
|
|
||||||
s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
|
|
||||||
s_subscribed = false;
|
|
||||||
s_notify_count = 0;
|
|
||||||
start_advertising();
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
case BLE_GAP_EVENT_REPEAT_PAIRING:
|
|
||||||
// MicrOBU: logged, since a phone that pairs again while we hold its bond means one side
|
|
||||||
// lost the keys; the phone's side then usually needs "forget device" as well.
|
|
||||||
ESP_LOGW(TAG, "phone started pairing again although bonded: dropping the old bond");
|
|
||||||
if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) == 0) {
|
|
||||||
ble_store_util_delete_peer(&desc.peer_id_addr);
|
|
||||||
}
|
|
||||||
return BLE_GAP_REPEAT_PAIRING_RETRY;
|
|
||||||
|
|
||||||
case BLE_GAP_EVENT_ENC_CHANGE:
|
|
||||||
// MicrOBU: the one place the board side says whether the link got secure. 0 = encrypted.
|
|
||||||
if (ble_gap_conn_find(event->enc_change.conn_handle, &desc) == 0) {
|
|
||||||
ESP_LOGI(TAG, "encryption change status=%d encrypted=%d authenticated=%d bonded=%d",
|
|
||||||
event->enc_change.status, desc.sec_state.encrypted, desc.sec_state.authenticated,
|
|
||||||
desc.sec_state.bonded);
|
|
||||||
} else {
|
|
||||||
ESP_LOGI(TAG, "encryption change status=%d", event->enc_change.status);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
case BLE_GAP_EVENT_MTU:
|
|
||||||
ESP_LOGI(TAG, "ATT MTU %u", unsigned(event->mtu.value));
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
case BLE_GAP_EVENT_PASSKEY_ACTION:
|
|
||||||
if (event->passkey.params.action == BLE_SM_IOACT_DISP) {
|
|
||||||
struct ble_sm_io pkey = {};
|
|
||||||
pkey.action = BLE_SM_IOACT_DISP;
|
|
||||||
pkey.passkey = FIXED_PASSKEY;
|
|
||||||
ESP_LOGW(TAG, "BLE PAIRING FIXED PASSKEY: %06lu", static_cast<unsigned long>(FIXED_PASSKEY));
|
|
||||||
int rc = ble_sm_inject_io(event->passkey.conn_handle, &pkey);
|
|
||||||
if (rc != 0) {
|
|
||||||
ESP_LOGW(TAG, "ble_sm_inject_io rc=%d", rc);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ESP_LOGW(TAG, "unhandled passkey action %d", event->passkey.params.action);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
case BLE_GAP_EVENT_SUBSCRIBE:
|
|
||||||
if (event->subscribe.attr_handle == s_btp_ind_val_handle ||
|
|
||||||
event->subscribe.attr_handle == s_status_val_handle ||
|
|
||||||
event->subscribe.attr_handle == s_id_val_handle ||
|
|
||||||
event->subscribe.attr_handle == s_result_val_handle) {
|
|
||||||
if (event->subscribe.cur_notify && !event->subscribe.prev_notify)
|
|
||||||
++s_notify_count;
|
|
||||||
else if (!event->subscribe.cur_notify && event->subscribe.prev_notify)
|
|
||||||
--s_notify_count;
|
|
||||||
if (s_notify_count < 0) s_notify_count = 0;
|
|
||||||
s_subscribed = (s_notify_count > 0);
|
|
||||||
ESP_LOGI(TAG, "subscription handle %u changed: notify=%d count=%d",
|
|
||||||
event->subscribe.attr_handle, (int)event->subscribe.cur_notify, s_notify_count);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
case BLE_GAP_EVENT_ADV_COMPLETE:
|
|
||||||
start_advertising();
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Re)starts undirected advertising with the device name and service UUID, unless paused.
|
|
||||||
int start_advertising() {
|
|
||||||
if (!s_adv_allowed || !s_synced || s_conn_handle != BLE_HS_CONN_HANDLE_NONE || ble_gap_adv_active()) return 0;
|
|
||||||
const char* name = s_dev_name;
|
|
||||||
size_t name_len = strlen(name);
|
|
||||||
|
|
||||||
struct ble_hs_adv_fields adv = {};
|
|
||||||
adv.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
|
|
||||||
adv.tx_pwr_lvl_is_present = 1;
|
|
||||||
adv.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
|
|
||||||
adv.name = reinterpret_cast<const uint8_t*>(name);
|
|
||||||
adv.name_len = static_cast<uint8_t>(name_len);
|
|
||||||
adv.name_is_complete = 1;
|
|
||||||
|
|
||||||
int rc = ble_gap_adv_set_fields(&adv);
|
|
||||||
if (rc != 0) {
|
|
||||||
ESP_LOGE(TAG, "adv_set_fields rc=%d", rc);
|
|
||||||
return rc;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ble_hs_adv_fields rsp = {};
|
|
||||||
rsp.uuids128 = &s_svc_uuid;
|
|
||||||
rsp.num_uuids128 = 1;
|
|
||||||
rsp.uuids128_is_complete = 1;
|
|
||||||
|
|
||||||
rc = ble_gap_adv_rsp_set_fields(&rsp);
|
|
||||||
if (rc != 0) {
|
|
||||||
ESP_LOGE(TAG, "adv_rsp_set_fields rc=%d", rc);
|
|
||||||
return rc;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ble_gap_adv_params params = {};
|
|
||||||
params.conn_mode = BLE_GAP_CONN_MODE_UND;
|
|
||||||
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
|
||||||
|
|
||||||
rc = ble_gap_adv_start(s_own_addr_type, nullptr, BLE_HS_FOREVER,
|
|
||||||
¶ms, gap_event, nullptr);
|
|
||||||
if (rc != 0) {
|
|
||||||
ESP_LOGE(TAG, "adv_start rc=%d", rc);
|
|
||||||
return rc;
|
|
||||||
}
|
|
||||||
ESP_LOGI(TAG, "BLE advertising started as '%s'", name);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NimBLE host reset callback.
|
|
||||||
void on_reset(int reason) {
|
|
||||||
ESP_LOGE(TAG, "NimBLE reset, reason=%d", reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NimBLE host sync callback: resolves our address and starts advertising.
|
|
||||||
void on_sync() {
|
|
||||||
int rc = ble_hs_util_ensure_addr(0);
|
|
||||||
assert(rc == 0);
|
|
||||||
rc = ble_hs_id_infer_auto(0, &s_own_addr_type);
|
|
||||||
if (rc != 0) {
|
|
||||||
ESP_LOGE(TAG, "ble_hs_id_infer_auto rc=%d", rc);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
s_synced = true;
|
|
||||||
// MicrOBU: a bond that did not persist (full NVS) looks like "the phone has to pair every time".
|
|
||||||
int bonds = 0;
|
|
||||||
if (ble_store_util_count(BLE_STORE_OBJ_TYPE_OUR_SEC, &bonds) == 0)
|
|
||||||
ESP_LOGI(TAG, "%d bonded phone(s) in NVS", bonds);
|
|
||||||
start_advertising();
|
|
||||||
}
|
|
||||||
|
|
||||||
void nimble_host_task(void* param) {
|
|
||||||
ESP_LOGI(TAG, "NimBLE host task running");
|
|
||||||
nimble_port_run();
|
|
||||||
nimble_port_freertos_deinit();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool start(Receiver receiver) {
|
|
||||||
s_receiver = std::move(receiver);
|
|
||||||
|
|
||||||
uint8_t mac[6] = {};
|
|
||||||
if (esp_read_mac(mac, ESP_MAC_BT) == ESP_OK) {
|
|
||||||
std::snprintf(s_dev_name, sizeof(s_dev_name), "micrOBU-%02X%02X", mac[4], mac[5]);
|
|
||||||
}
|
|
||||||
|
|
||||||
esp_err_t err = nimble_port_init();
|
|
||||||
if (err != ESP_OK) {
|
|
||||||
ESP_LOGE(TAG, "nimble_port_init failed: %d", err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ble_hs_cfg.reset_cb = on_reset;
|
|
||||||
ble_hs_cfg.sync_cb = on_sync;
|
|
||||||
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
|
|
||||||
|
|
||||||
// Fixed passkey LE Secure Connections + Bonding
|
|
||||||
ble_hs_cfg.sm_io_cap = BLE_HS_IO_DISPLAY_ONLY;
|
|
||||||
ble_hs_cfg.sm_sc = 1;
|
|
||||||
ble_hs_cfg.sm_bonding = 1;
|
|
||||||
ble_hs_cfg.sm_mitm = 1;
|
|
||||||
ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
|
|
||||||
ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
|
|
||||||
|
|
||||||
ble_svc_gap_init();
|
|
||||||
ble_svc_gatt_init();
|
|
||||||
ble_svc_gap_device_name_set(s_dev_name);
|
|
||||||
|
|
||||||
int rc = ble_gatts_count_cfg(s_svc_defs);
|
|
||||||
if (rc != 0) {
|
|
||||||
ESP_LOGE(TAG, "ble_gatts_count_cfg failed: %d", rc);
|
|
||||||
nimble_port_deinit();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
rc = ble_gatts_add_svcs(s_svc_defs);
|
|
||||||
if (rc != 0) {
|
|
||||||
ESP_LOGE(TAG, "ble_gatts_add_svcs failed: %d", rc);
|
|
||||||
nimble_port_deinit();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ble_store_config_init();
|
|
||||||
nimble_port_freertos_init(nimble_host_task);
|
|
||||||
ESP_LOGI(TAG, "simple_ble initialized (fixed passkey %06lu)", static_cast<unsigned long>(FIXED_PASSKEY));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool write(const link::Bytes& message) {
|
|
||||||
uint16_t conn_handle = s_conn_handle;
|
|
||||||
if (conn_handle == BLE_HS_CONN_HANDLE_NONE) {
|
|
||||||
++statistics.tx_failed;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (message.empty() || message.size() > link::maximum_message) {
|
|
||||||
++statistics.tx_failed;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
uint16_t val_handle = s_result_val_handle;
|
|
||||||
if (message.size() >= link::header_size) {
|
|
||||||
link::Opcode op = static_cast<link::Opcode>(message[0]);
|
|
||||||
switch (op) {
|
|
||||||
case link::Opcode::V2X_RX:
|
|
||||||
case link::Opcode::BTP_DATA_INDICATION:
|
|
||||||
val_handle = s_btp_ind_val_handle;
|
|
||||||
break;
|
|
||||||
case link::Opcode::SF_IDCHANGE_EVENT:
|
|
||||||
val_handle = s_id_val_handle;
|
|
||||||
break;
|
|
||||||
case link::Opcode::STATUS:
|
|
||||||
// Route unsolicited or requested STATUS over the subscribed result/reply characteristic
|
|
||||||
val_handle = s_result_val_handle;
|
|
||||||
break;
|
|
||||||
case link::Opcode::RESULT:
|
|
||||||
default:
|
|
||||||
val_handle = s_result_val_handle;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// MicrOBU: one notification is one whole message (no fragmentation on this GATT layout), and
|
|
||||||
// NimBLE cuts a value longer than ATT_MTU - 3 short without an error. The phone asks for an
|
|
||||||
// MTU of 517; until it has, drop rather than deliver a truncated message.
|
|
||||||
if (message.size() + 3 > ble_att_mtu(conn_handle)) {
|
|
||||||
++statistics.tx_failed;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
struct os_mbuf *om = ble_hs_mbuf_from_flat(message.data(), message.size());
|
|
||||||
if (!om) {
|
|
||||||
++statistics.tx_failed;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
int rc = ble_gatts_notify_custom(conn_handle, val_handle, om);
|
|
||||||
if (rc != 0) {
|
|
||||||
ESP_LOGW(TAG, "notify failed handle=%u op=0x%02x rc=%d", (unsigned)val_handle, (unsigned)(message.empty() ? 0 : message[0]), rc);
|
|
||||||
if (rc == BLE_HS_ENOTSUP) os_mbuf_free_chain(om);
|
|
||||||
++statistics.tx_failed;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
++statistics.tx_messages;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool connected() {
|
|
||||||
return s_conn_handle != BLE_HS_CONN_HANDLE_NONE && s_subscribed;
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_advertising_allowed(bool allowed) {
|
|
||||||
if (s_adv_allowed == allowed) return;
|
|
||||||
s_adv_allowed = allowed;
|
|
||||||
if (!s_synced) return;
|
|
||||||
if (allowed) {
|
|
||||||
start_advertising();
|
|
||||||
ESP_LOGI(TAG, "USB link idle: BLE advertising resumed");
|
|
||||||
} else if (ble_gap_adv_active()) {
|
|
||||||
ble_gap_adv_stop();
|
|
||||||
ESP_LOGI(TAG, "USB link in use: BLE advertising paused");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool advertising_allowed() {
|
|
||||||
return s_adv_allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool is_subscribed() {
|
|
||||||
return s_subscribed;
|
|
||||||
}
|
|
||||||
|
|
||||||
Counters counters() {
|
|
||||||
return statistics;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace microbu::ble
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "link_protocol.hpp"
|
|
||||||
#include <cstdint>
|
|
||||||
#include <functional>
|
|
||||||
|
|
||||||
namespace microbu::ble {
|
|
||||||
|
|
||||||
using Receiver = std::function<void(link::Bytes)>;
|
|
||||||
|
|
||||||
/// @brief A set of counters for tracking BLE message traffic.
|
|
||||||
struct Counters {
|
|
||||||
std::uint32_t rx_messages = 0;
|
|
||||||
std::uint32_t tx_messages = 0;
|
|
||||||
std::uint32_t tx_failed = 0;
|
|
||||||
std::uint32_t malformed = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Starts a simple, bonded BLE GATT peripheral on the NimBLE host.
|
|
||||||
* Uses structured ETSI C-ITS Station Service & Characteristics:
|
|
||||||
* Service: 0000C175-BA5E-4C17-8000-00805F9B34FB
|
|
||||||
* BTP-DATA.request: 0000C176-BA5E-4C17-8000-00805F9B34FB (Write, Authenticated/Encrypted)
|
|
||||||
* BTP-DATA.indication: 0000C177-BA5E-4C17-8000-00805F9B34FB (Notify, Authenticated/Encrypted)
|
|
||||||
* PoTi Fix Update: 0000C178-BA5E-4C17-8000-00805F9B34FB (Write, Authenticated/Encrypted)
|
|
||||||
* Station Status: 0000C179-BA5E-4C17-8000-00805F9B34FB (Read / Notify, Authenticated/Encrypted)
|
|
||||||
* SF-SAP Identity: 0000C17A-BA5E-4C17-8000-00805F9B34FB (Notify, Authenticated/Encrypted)
|
|
||||||
* Station Configure: 0000C17B-BA5E-4C17-8000-00805F9B34FB (Write, Authenticated/Encrypted)
|
|
||||||
* Result: 0000C17C-BA5E-4C17-8000-00805F9B34FB (Notify, Authenticated/Encrypted)
|
|
||||||
*
|
|
||||||
* Fixed passkey bonding: 123456.
|
|
||||||
* Single ATT message transmission/reception up to 512 bytes without fragmentation chunk delays.
|
|
||||||
* @param receiver Invoked with each decoded station-link message written by the phone.
|
|
||||||
* @return false if the NimBLE host or GATT service failed to start.
|
|
||||||
*/
|
|
||||||
bool start(Receiver receiver);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Sends one complete station-link message via GATT notification.
|
|
||||||
* Dispatches immediately without queuing delay.
|
|
||||||
* @param message Encoded station-link message to notify.
|
|
||||||
* @return false if not connected/subscribed or the notification could not be sent.
|
|
||||||
*/
|
|
||||||
bool write(const link::Bytes& message);
|
|
||||||
|
|
||||||
/// @return true if a phone is connected.
|
|
||||||
bool connected();
|
|
||||||
/// @brief MicrOBU: allows or stops advertising (an existing connection is never dropped). Called
|
|
||||||
/// with false while the phone uses the USB link, so BLE takes no airtime from ITS-G5.
|
|
||||||
void set_advertising_allowed(bool allowed);
|
|
||||||
/// @return true while advertising is allowed (see set_advertising_allowed).
|
|
||||||
bool advertising_allowed();
|
|
||||||
/// @return true if the phone has subscribed to notifications.
|
|
||||||
bool is_subscribed();
|
|
||||||
/// @return Current message/error counters.
|
|
||||||
Counters counters();
|
|
||||||
|
|
||||||
} // namespace microbu::ble
|
|
||||||
@@ -1,653 +0,0 @@
|
|||||||
#include "station.hpp"
|
|
||||||
#include <vanetza_idf/nf_sap.hpp>
|
|
||||||
#include <vanetza_idf/sf_sap.hpp>
|
|
||||||
#include <vanetza_idf/nvs_credential_store.hpp>
|
|
||||||
#include <vanetza/geonet/serialization_buffer.hpp>
|
|
||||||
#include <vanetza/geonet/areas.hpp>
|
|
||||||
#include <vanetza/units/angle.hpp>
|
|
||||||
#include <vanetza/units/length.hpp>
|
|
||||||
#include <vanetza/units/velocity.hpp>
|
|
||||||
#include <esp_log.h>
|
|
||||||
#include <esp_timer.h>
|
|
||||||
#include "c5_radio.hpp"
|
|
||||||
extern "C" {
|
|
||||||
#include "geonet.h"
|
|
||||||
#include "gn_unwrap.h"
|
|
||||||
}
|
|
||||||
#include <algorithm>
|
|
||||||
#include <iterator>
|
|
||||||
#include <chrono>
|
|
||||||
#include <cmath>
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
using namespace vanetza_idf;
|
|
||||||
using vanetza::ByteBuffer;
|
|
||||||
namespace gn = vanetza::geonet;
|
|
||||||
namespace {
|
|
||||||
const char* TAG = "station";
|
|
||||||
|
|
||||||
link::Code code(Result result) { return static_cast<link::Code>(result); }
|
|
||||||
|
|
||||||
// Numbering of the link's "Packet transport type" field matches the TRANSP_CORE.request/.indication
|
|
||||||
// service primitive parameter of ETSI TS 103 836-4-1 annex J.2/J.4 (0 GUC, 1 SHB, 2 TSB, 3 GBC,
|
|
||||||
// 4 GAC); it does not reuse the HT wire encoding of clause 9.7.4 table 9, which numbers these
|
|
||||||
// differently (GEOUNICAST=2, GEOANYCAST=3, GEOBROADCAST=4, TSB=5, no separate SHB value there).
|
|
||||||
std::uint8_t transport_number(gn::TransportType t) {
|
|
||||||
switch (t) {
|
|
||||||
case gn::TransportType::GUC: return 0;
|
|
||||||
case gn::TransportType::SHB: return 1;
|
|
||||||
case gn::TransportType::TSB: return 2;
|
|
||||||
case gn::TransportType::GBC: return 3;
|
|
||||||
case gn::TransportType::GAC: return 4;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
std::optional<gn::TransportType> transport_from(std::uint8_t n) {
|
|
||||||
switch (n) {
|
|
||||||
case 0: return gn::TransportType::GUC;
|
|
||||||
case 1: return gn::TransportType::SHB;
|
|
||||||
case 2: return gn::TransportType::TSB;
|
|
||||||
case 3: return gn::TransportType::GBC;
|
|
||||||
case 4: return gn::TransportType::GAC;
|
|
||||||
}
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire DestinationArea -> vanetza gn::Area (GBC destination).
|
|
||||||
gn::Area area_from(const link::DestinationArea& a) {
|
|
||||||
gn::Area area;
|
|
||||||
switch (a.shape) {
|
|
||||||
case 0: { gn::Circle c; c.r = double(a.distance_a) * vanetza::units::si::meter; area.shape = c; break; }
|
|
||||||
case 1: { gn::Rectangle r; r.a = double(a.distance_a) * vanetza::units::si::meter; r.b = double(a.distance_b) * vanetza::units::si::meter; area.shape = r; break; }
|
|
||||||
default: { gn::Ellipse e; e.a = double(a.distance_a) * vanetza::units::si::meter; e.b = double(a.distance_b) * vanetza::units::si::meter; area.shape = e; break; }
|
|
||||||
}
|
|
||||||
area.position = gn::GeodeticPosition(a.latitude / 1.0e7 * vanetza::units::degree, a.longitude / 1.0e7 * vanetza::units::degree);
|
|
||||||
area.angle = vanetza::units::Angle(double(a.angle) * vanetza::units::degree);
|
|
||||||
return area;
|
|
||||||
}
|
|
||||||
|
|
||||||
// gn::Area -> wire DestinationArea (the inverse of area_from).
|
|
||||||
struct AreaToLink : boost::static_visitor<link::DestinationArea> {
|
|
||||||
const gn::Area& area;
|
|
||||||
explicit AreaToLink(const gn::Area& a) : area(a) {}
|
|
||||||
link::DestinationArea common() const {
|
|
||||||
link::DestinationArea out;
|
|
||||||
out.latitude = static_cast<std::int32_t>(area.position.latitude.value() * 1.0e7);
|
|
||||||
out.longitude = static_cast<std::int32_t>(area.position.longitude.value() * 1.0e7);
|
|
||||||
out.angle = static_cast<std::uint16_t>(area.angle.value());
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
link::DestinationArea operator()(const gn::Circle& c) const { auto o = common(); o.shape = 0; o.distance_a = c.r.value(); return o; }
|
|
||||||
link::DestinationArea operator()(const gn::Rectangle& r) const { auto o = common(); o.shape = 1; o.distance_a = r.a.value(); o.distance_b = r.b.value(); return o; }
|
|
||||||
link::DestinationArea operator()(const gn::Ellipse& e) const { auto o = common(); o.shape = 2; o.distance_a = e.a.value(); o.distance_b = e.b.value(); return o; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// 0 unsecured (no security envelope), 1 + VerificationReport ordinal otherwise
|
|
||||||
struct ReportToLink : boost::static_visitor<std::uint8_t> {
|
|
||||||
ReportToLink() = default;
|
|
||||||
std::uint8_t operator()(boost::blank) const { return 0; }
|
|
||||||
std::uint8_t operator()(vanetza::security::VerificationReport r) const { return 1 + static_cast<std::uint8_t>(r); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Credentials, backend, trust configuration, ticket pool and the entity built on them.
|
|
||||||
struct Station::Security {
|
|
||||||
BackendMbedTls backend;
|
|
||||||
security::TrustConfiguration trust;
|
|
||||||
security::CertificatePool pool {backend};
|
|
||||||
std::unique_ptr<security::SecurityEntity> entity;
|
|
||||||
bool loaded = false;
|
|
||||||
security::ApplyReport report;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::int64_t Station::Clock::now_us() const {
|
|
||||||
return base_its_us + (esp_timer_get_time() - base_esp_us);
|
|
||||||
}
|
|
||||||
|
|
||||||
Station::Station() = default;
|
|
||||||
Station::~Station() { teardown(); }
|
|
||||||
|
|
||||||
void Station::teardown() {
|
|
||||||
rebuilding_ = true; // the DEREG of TS 102 723-8 Figure 15 is ours, not the phone's
|
|
||||||
stack_.reset(); // router timers before the runtime
|
|
||||||
// AccessStack::Dcc's Limeric holds a reference to *runtime_ and calls into it from its
|
|
||||||
// destructor (Runtime::cancel): it must be destroyed before runtime_ is.
|
|
||||||
access_stack_.reset();
|
|
||||||
security_.reset();
|
|
||||||
runtime_.reset();
|
|
||||||
dcc_cca_last_.reset();
|
|
||||||
dcc_last_sample_its_us_ = 0;
|
|
||||||
pending_responders_.clear();
|
|
||||||
rebuilding_ = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TS 102 894-2 station config -> the GeoNetworking MIB (TS 103 836-4-1).
|
|
||||||
void Station::apply_mib(StackConfig& config, const link::StationConfigure& c) const {
|
|
||||||
config.mib.itsGnLocalGnAddr.mid(vanetza::MacAddress {c.mid[0], c.mid[1], c.mid[2], c.mid[3], c.mid[4], c.mid[5]});
|
|
||||||
config.mib.itsGnLocalGnAddr.station_type(static_cast<gn::StationType>(c.station_type & 0x1F));
|
|
||||||
config.mib.itsGnLocalGnAddr.is_manually_configured(c.address_configuration == 0);
|
|
||||||
config.mib.itsGnLocalAddrConfMethod = c.address_configuration == 1 ? gn::AddrConfMethod::Anonymous : gn::AddrConfMethod::Auto;
|
|
||||||
config.mib.itsGnSecurity = c.security == 1;
|
|
||||||
config.mib.vanetzaDisableBeaconing = c.beaconing == 0;
|
|
||||||
config.mib.itsGnDefaultTrafficClass = gn::TrafficClass(c.default_traffic_class);
|
|
||||||
gn::Lifetime lifetime; lifetime.raw(c.default_lifetime);
|
|
||||||
config.mib.itsGnDefaultPacketLifetime = lifetime;
|
|
||||||
config.mib.itsGnMaxPacketLifetime = lifetime < config.mib.itsGnMaxPacketLifetime ? config.mib.itsGnMaxPacketLifetime : lifetime;
|
|
||||||
config.radio_parameters.channel_number = c.channel_number;
|
|
||||||
config.radio_parameters.transmit_power_dbm = c.transmit_power_dbm;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carry the phone's SF-SAP pseudonym-change subscriptions (TS 102 723-8 clause 6.3)
|
|
||||||
// over to the identity manager of a freshly rebuilt security entity.
|
|
||||||
void Station::resubscribe_id_change() {
|
|
||||||
std::map<std::uint64_t, std::uint64_t> renewed;
|
|
||||||
for (const auto& [handle, old_service] : link_subscriptions_) {
|
|
||||||
(void)old_service;
|
|
||||||
renewed[handle] = security_->entity->id_change().subscribe(
|
|
||||||
[this, handle](security::IdChangeCommand command, const security::Identifier& id, const ByteBuffer& data,
|
|
||||||
std::shared_ptr<security::IdChangeResponder> responder) {
|
|
||||||
link::IdChangeEvent event;
|
|
||||||
event.subscription = handle;
|
|
||||||
event.command = static_cast<std::uint8_t>(command);
|
|
||||||
std::copy(id.begin(), id.end(), event.id);
|
|
||||||
event.subscriber_data = data;
|
|
||||||
if (responder) pending_responders_[handle] = responder;
|
|
||||||
if (id_event_ && !rebuilding_) id_event_(event);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
link_subscriptions_ = renewed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// (Re)build stack and security entity from config_, the stored credentials and the current clock.
|
|
||||||
link::Code Station::build() {
|
|
||||||
teardown();
|
|
||||||
const auto& c = *config_;
|
|
||||||
runtime_ = std::make_unique<vanetza::ManualRuntime>(vanetza::Clock::time_point(std::chrono::microseconds(clock_.synchronised ? clock_.now_us() : 0)));
|
|
||||||
StackConfig config;
|
|
||||||
apply_mib(config, c);
|
|
||||||
radio_parameters_ = config.radio_parameters;
|
|
||||||
vanetza::security::SecurityEntity* entity = nullptr;
|
|
||||||
if (config.mib.itsGnSecurity) {
|
|
||||||
security_ = std::make_unique<Security>();
|
|
||||||
security::Credentials credentials;
|
|
||||||
security::NvsCredentialStore store;
|
|
||||||
const auto load = store.load(credentials);
|
|
||||||
if (load == Result::accepted) {
|
|
||||||
security_->report = security::apply(credentials, security_->trust, security_->pool);
|
|
||||||
security_->loaded = security_->report.result == Result::accepted && !security_->pool.empty();
|
|
||||||
ESP_LOGI(TAG, "credentials from NVS: %u roots, %u authorities, %u tickets (result %d)",
|
|
||||||
unsigned(security_->report.roots), unsigned(security_->report.authorities), unsigned(security_->report.tickets),
|
|
||||||
int(security_->report.result));
|
|
||||||
} else if (load == Result::rejected) {
|
|
||||||
ESP_LOGW(TAG, "no credentials in NVS: secured requests will be refused until provisioned");
|
|
||||||
} else {
|
|
||||||
ESP_LOGE(TAG, "stored credentials do not decode (result %d)", int(load));
|
|
||||||
}
|
|
||||||
security_->entity = std::make_unique<security::SecurityEntity>(*runtime_, *this, security_->backend, security_->pool, security_->trust);
|
|
||||||
entity = security_->entity.get();
|
|
||||||
resubscribe_id_change();
|
|
||||||
}
|
|
||||||
stack_ = std::make_unique<Stack>(config, *runtime_, *this, entity);
|
|
||||||
stack_->on_receive([this](BtpIndication indication) { deliver(std::move(indication)); });
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
stack_->on_receive_gn([this](GnIndication indication) { record(3, std::move(indication.data)); });
|
|
||||||
#endif
|
|
||||||
stack_->on_access_result([](Result) { /* counted in Station::request, the adapter itself */ });
|
|
||||||
// DCC_ACC gate in front of whatever radio_ currently is (rebuilt here rather than in
|
|
||||||
// Station::configure() because it must reference the fresh runtime_, not a torn-down one --
|
|
||||||
// see the destruction-order comment in Station::teardown()).
|
|
||||||
if (radio_) {
|
|
||||||
access_stack_ = std::make_unique<AccessStack>(*radio_);
|
|
||||||
access_stack_->enable_dcc(*runtime_);
|
|
||||||
stack_->report_tx_power(static_cast<unsigned>(std::lround(radio_parameters_.transmit_power_dbm)));
|
|
||||||
}
|
|
||||||
dcc_cca_last_.reset();
|
|
||||||
dcc_last_sample_its_us_ = clock_.synchronised ? clock_.now_us() : 0;
|
|
||||||
if (have_fix_) apply_position();
|
|
||||||
return link::Code::accepted;
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::configure(const link::StationConfigure& c, link::Bytes& detail) {
|
|
||||||
const bool radio_changed = !config_ || config_->radio != c.radio || config_->channel_number != c.channel_number ||
|
|
||||||
config_->transmit_power_dbm != c.transmit_power_dbm;
|
|
||||||
if (radio_changed) {
|
|
||||||
radio_.reset();
|
|
||||||
if (c.radio != 0) {
|
|
||||||
C5RadioConfig rc;
|
|
||||||
rc.channel_number = c.channel_number;
|
|
||||||
rc.transmit_power_dbm = c.transmit_power_dbm;
|
|
||||||
rc.laboratory_transmission = c.radio == 2;
|
|
||||||
radio_ = std::make_unique<C5Radio>(rc);
|
|
||||||
const auto error = radio_->start();
|
|
||||||
if (error != ESP_OK) {
|
|
||||||
ESP_LOGE(TAG, "radio start failed: %s", esp_err_to_name(error));
|
|
||||||
radio_.reset();
|
|
||||||
return link::Code::rejected;
|
|
||||||
}
|
|
||||||
ESP_LOGI(TAG, "radio on channel %u, %s", unsigned(c.channel_number), c.radio == 2 ? "transmit and receive" : "receive only");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config_ = c;
|
|
||||||
link_subscriptions_.clear(); // a configuration starts a phone session: earlier subscriptions are void
|
|
||||||
counters_ = link::Status {}; // and the session's counters start at zero
|
|
||||||
const auto result = build();
|
|
||||||
if (result != link::Code::accepted) return result;
|
|
||||||
link::Writer w;
|
|
||||||
ByteBuffer address;
|
|
||||||
gn::serialize_into_buffer(stack_->address(), address);
|
|
||||||
w.bytes(address);
|
|
||||||
security::Identifier identifier {};
|
|
||||||
if (security_ && security_->entity) identifier = security_->entity->id_change().current_identifier();
|
|
||||||
w.bytes(identifier.data(), 8);
|
|
||||||
w.u8(security_ && security_->loaded ? 1 : 0);
|
|
||||||
w.u8(security_ ? static_cast<std::uint8_t>(std::min<std::size_t>(security_->pool.size(), 255)) : 0);
|
|
||||||
detail = w.out;
|
|
||||||
counters_.configured = 1;
|
|
||||||
return link::Code::accepted;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Station::apply_position() {
|
|
||||||
if (!stack_ || !have_fix_) return;
|
|
||||||
// never ahead of the station clock (Stack::update_position rejects that)
|
|
||||||
const auto now = runtime_->now();
|
|
||||||
if (fix_.timestamp > now) fix_.timestamp = now;
|
|
||||||
const auto result = stack_->update_position(fix_);
|
|
||||||
if (result != Result::accepted) ESP_LOGW(TAG, "position rejected: %d", int(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::poti(const link::PotiUpdate& p) {
|
|
||||||
if (p.latitude < -900000000 || p.latitude > 900000000 || p.longitude < -1800000000 || p.longitude > 1800000000)
|
|
||||||
return link::Code::invalid_argument;
|
|
||||||
// ITS clock: the phone's PoTi time is the reference; the local esp_timer runs between updates.
|
|
||||||
const std::int64_t its_us = static_cast<std::int64_t>(p.timestamp_ms) * 1000;
|
|
||||||
const std::int64_t esp_us = esp_timer_get_time();
|
|
||||||
link::Code result = link::Code::accepted;
|
|
||||||
if (!clock_.synchronised) {
|
|
||||||
clock_ = Clock {true, its_us, esp_us};
|
|
||||||
if (config_) build(); // the runtime started at 0: restart it at real time
|
|
||||||
} else {
|
|
||||||
const std::int64_t drift = its_us - clock_.now_us();
|
|
||||||
if (drift >= 0) {
|
|
||||||
clock_.base_its_us = its_us; clock_.base_esp_us = esp_us; // forward: step immediately
|
|
||||||
} else if (drift > -1000000) {
|
|
||||||
// small backward drift: hold the local clock, it catches up with the next updates
|
|
||||||
} else {
|
|
||||||
// the reference moved back by more than a second: restart the station at that time
|
|
||||||
ESP_LOGW(TAG, "ITS time moved back by %lld ms, restarting the station", static_cast<long long>(-drift / 1000));
|
|
||||||
clock_ = Clock {true, its_us, esp_us};
|
|
||||||
if (config_) build();
|
|
||||||
result = link::Code::time_regression;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fix_ = vanetza::PositionFix {};
|
|
||||||
fix_.timestamp = vanetza::Clock::time_point(std::chrono::microseconds(std::min(its_us, clock_.now_us())));
|
|
||||||
fix_.latitude = p.latitude / 1.0e7 * vanetza::units::degree;
|
|
||||||
fix_.longitude = p.longitude / 1.0e7 * vanetza::units::degree;
|
|
||||||
fix_.confidence.semi_major = p.semi_major_cm / 100.0 * vanetza::units::si::meter;
|
|
||||||
fix_.confidence.semi_minor = p.semi_minor_cm / 100.0 * vanetza::units::si::meter;
|
|
||||||
fix_.confidence.orientation = p.orientation_deci_degree / 10.0 * vanetza::units::true_north_degrees;
|
|
||||||
fix_.speed = (p.has_speed() ? p.speed_cm_s / 100.0 : 0.0) * vanetza::units::si::meters_per_second;
|
|
||||||
fix_.course = (p.has_heading() ? p.heading_deci_degree / 10.0 : 0.0) * vanetza::units::true_north_degrees;
|
|
||||||
if (p.has_altitude()) fix_.altitude = vanetza::ConfidentQuantity<vanetza::units::Length>(p.altitude_cm / 100.0 * vanetza::units::si::meter);
|
|
||||||
have_fix_ = true;
|
|
||||||
last_poti_ = p;
|
|
||||||
++counters_.poti_updates;
|
|
||||||
tick();
|
|
||||||
apply_position();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::btp_request(const link::BtpDataRequest& q) {
|
|
||||||
if (!stack_) return link::Code::not_configured;
|
|
||||||
if (!clock_.synchronised || !have_fix_) { ++counters_.requests_refused; return link::Code::rejected; }
|
|
||||||
NF_SAP::BTP_DATA_request request;
|
|
||||||
request.fl_sdu = q.fl_sdu;
|
|
||||||
request.length = q.fl_sdu.size();
|
|
||||||
request.btp_type = q.btp_type == 0 ? BtpType::a : BtpType::b;
|
|
||||||
request.destination_port = q.destination_port;
|
|
||||||
if (request.btp_type == BtpType::a) request.source_port = q.destination_port_info;
|
|
||||||
else request.destination_port_info = q.destination_port_info;
|
|
||||||
const auto transport = transport_from(q.gn_packet_transport_type);
|
|
||||||
if (!transport) { ++counters_.requests_refused; return link::Code::invalid_argument; }
|
|
||||||
request.gn_packet_transport_type = *transport;
|
|
||||||
|
|
||||||
switch (q.gn_communication_profile) {
|
|
||||||
case 0: request.gn_communication_profile = gn::CommunicationProfile::Unspecified; break;
|
|
||||||
case 1: request.gn_communication_profile = gn::CommunicationProfile::ITS_G5; break;
|
|
||||||
case 2: request.gn_communication_profile = gn::CommunicationProfile::LTE_V2X; break;
|
|
||||||
default: ++counters_.requests_refused; return link::Code::invalid_argument;
|
|
||||||
}
|
|
||||||
// MicrOBU: the colleague's firmware refuses unsecured requests outright. Here the phone's
|
|
||||||
// "Sign outgoing messages" setting decides, per request; 0 means the station's configuration.
|
|
||||||
if (q.gn_security_profile > 2) {
|
|
||||||
++counters_.requests_refused;
|
|
||||||
return link::Code::invalid_argument;
|
|
||||||
}
|
|
||||||
const bool secured = q.gn_security_profile == 2 || (q.gn_security_profile == 0 && config_->security == 1);
|
|
||||||
if (!secured) return unsecured_request(q);
|
|
||||||
request.gn_security_profile = NF_SAP::SecurityProfile::SECURED;
|
|
||||||
request.gn_traffic_class = q.gn_traffic_class == 0xFF ? stack_->config().mib.itsGnDefaultTrafficClass : gn::TrafficClass(q.gn_traffic_class);
|
|
||||||
if (q.gn_maximum_packet_lifetime != 0xFF) { gn::Lifetime l; l.raw(q.gn_maximum_packet_lifetime); request.gn_maximum_packet_lifetime = l; }
|
|
||||||
if (q.gn_maximum_hop_limit) request.gn_maximum_hop_limit = q.gn_maximum_hop_limit;
|
|
||||||
if (q.gn_repetition_interval_ms) {
|
|
||||||
gn::DataRequest::Repetition repetition;
|
|
||||||
repetition.interval = (q.gn_repetition_interval_ms / 1000.0) * vanetza::units::si::seconds;
|
|
||||||
repetition.maximum = (q.gn_repetition_maximum_ms / 1000.0) * vanetza::units::si::seconds;
|
|
||||||
request.gn_repetition = repetition;
|
|
||||||
}
|
|
||||||
if (*transport == gn::TransportType::GBC) {
|
|
||||||
if (!q.area) { ++counters_.requests_refused; return link::Code::invalid_argument; }
|
|
||||||
request.gn_destination_address = area_from(*q.area);
|
|
||||||
}
|
|
||||||
request.its_aid = q.its_aid;
|
|
||||||
request.permissions = q.permissions;
|
|
||||||
request.context_information = q.context;
|
|
||||||
tick(); // the packet carries the current time and position
|
|
||||||
ESP_LOGI(TAG, "heap before sign: free=%lu min=%lu dma=%lu stack_hwm=%u",
|
|
||||||
(unsigned long)esp_get_free_heap_size(),
|
|
||||||
(unsigned long)esp_get_minimum_free_heap_size(),
|
|
||||||
(unsigned long)heap_caps_get_free_size(MALLOC_CAP_DMA),
|
|
||||||
(unsigned)uxTaskGetStackHighWaterMark(nullptr));
|
|
||||||
Result result = Result::rejected;
|
|
||||||
try {
|
|
||||||
result = NF_SAP::BTP_DATA_request_submit(*stack_, std::move(request));
|
|
||||||
if (result == Result::accepted) ++counters_.requests_accepted; else ++counters_.requests_refused;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
ESP_LOGE(TAG, "BTP_DATA_request_submit exception: %s", e.what());
|
|
||||||
++counters_.requests_refused;
|
|
||||||
return link::Code::rejected;
|
|
||||||
}
|
|
||||||
return code(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Station::forward_raw(const ByteBuffer& frame, int rssi) {
|
|
||||||
if (!raw_its_) return;
|
|
||||||
// Most captured frames are not ITS traffic this handles; false is the common case, not an error.
|
|
||||||
gn_rx_t rx;
|
|
||||||
if (!gn_unwrap_its(frame.data(), static_cast<int>(frame.size()), &rx)) return;
|
|
||||||
constexpr std::size_t prefix = 14;
|
|
||||||
if (rx.truncated || rx.payload_len <= 0 ||
|
|
||||||
static_cast<std::size_t>(rx.payload_len) + prefix + link::header_size > link::maximum_message) {
|
|
||||||
++raw_oversize_;
|
|
||||||
static std::int64_t last_report = 0; // one line per 10 s at most, the counter has the rest
|
|
||||||
const auto now = esp_timer_get_time();
|
|
||||||
if (now - last_report > 10000000) {
|
|
||||||
last_report = now;
|
|
||||||
ESP_LOGW(TAG, "V2X_RX: port %u message of %d bytes does not fit a link message (%lu dropped so far)",
|
|
||||||
unsigned(rx.btp_dest_port), rx.payload_len, (unsigned long)raw_oversize_);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
link::Writer w;
|
|
||||||
w.u16(rx.btp_dest_port);
|
|
||||||
w.u8(static_cast<std::uint8_t>(static_cast<std::int8_t>(std::clamp(rssi, -128, 127))));
|
|
||||||
w.u8((rx.has_geo_area ? 0x01 : 0) | (rx.signed_unverified ? 0x02 : 0));
|
|
||||||
w.i32(rx.geo_area_lat_tenmicrodeg);
|
|
||||||
w.i32(rx.geo_area_lon_tenmicrodeg);
|
|
||||||
w.u16(rx.geo_area_distance_a_m);
|
|
||||||
w.bytes(rx.payload, static_cast<std::size_t>(rx.payload_len));
|
|
||||||
++raw_forwarded_;
|
|
||||||
raw_its_(w.out);
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::unsecured_request(const link::BtpDataRequest& q) {
|
|
||||||
// geonet.c builds exactly one packet shape: BTP-B inside a GN single-hop broadcast. That is
|
|
||||||
// what CAM and VAM are; anything else still needs the stack, which here only signs.
|
|
||||||
if (q.btp_type != 1 || q.gn_packet_transport_type != 1) {
|
|
||||||
++counters_.requests_refused;
|
|
||||||
return link::Code::unsupported;
|
|
||||||
}
|
|
||||||
gn_lpv_t lpv {};
|
|
||||||
std::copy(std::begin(config_->mid), std::end(config_->mid), lpv.mac);
|
|
||||||
lpv.station_type = config_->station_type;
|
|
||||||
lpv.pai = last_poti_.pai();
|
|
||||||
lpv.tst_ms = static_cast<std::uint32_t>(last_poti_.timestamp_ms); // TimestampIts mod 2^32
|
|
||||||
lpv.lat_tenmicrodeg = last_poti_.latitude;
|
|
||||||
lpv.lon_tenmicrodeg = last_poti_.longitude;
|
|
||||||
lpv.speed_cms = static_cast<std::int16_t>(last_poti_.has_speed() ? std::min<unsigned>(last_poti_.speed_cm_s, 16383) : 0);
|
|
||||||
lpv.heading_decideg = last_poti_.has_heading() ? last_poti_.heading_deci_degree : 0;
|
|
||||||
ByteBuffer pdu(q.fl_sdu.size() + 64);
|
|
||||||
const int length = geonet_wrap_shb(q.fl_sdu.data(), static_cast<int>(q.fl_sdu.size()), &lpv,
|
|
||||||
q.destination_port, pdu.data(), pdu.size());
|
|
||||||
if (length <= 0) {
|
|
||||||
++counters_.requests_refused;
|
|
||||||
return link::Code::invalid_argument;
|
|
||||||
}
|
|
||||||
pdu.resize(static_cast<std::size_t>(length));
|
|
||||||
AlDataRequest frame = radio_parameters_;
|
|
||||||
frame.source = vanetza::MacAddress {lpv.mac[0], lpv.mac[1], lpv.mac[2], lpv.mac[3], lpv.mac[4], lpv.mac[5]};
|
|
||||||
frame.destination = vanetza::cBroadcastMacAddress;
|
|
||||||
frame.data = std::move(pdu);
|
|
||||||
const auto result = request(std::move(frame));
|
|
||||||
if (result == Result::accepted) ++counters_.requests_accepted; else ++counters_.requests_refused;
|
|
||||||
return code(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Station::deliver(BtpIndication received) {
|
|
||||||
auto indication = NF_SAP::BTP_DATA_indication_from(std::move(received));
|
|
||||||
++counters_.indications;
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
// The test channel sees every BTP indication as well (kind 2, the HIL SUT layout).
|
|
||||||
if (mirror_ != Mirror::off) {
|
|
||||||
link::Writer w;
|
|
||||||
w.u8(indication.btp_type == BtpType::b ? 1 : 0);
|
|
||||||
const auto port = indication.destination_port;
|
|
||||||
const auto info = indication.source_port.value_or(indication.destination_port_info.value_or(0));
|
|
||||||
w.out.push_back(port >> 8); w.out.push_back(port & 0xFF); // big-endian like hil_sut.cpp
|
|
||||||
w.out.push_back(info >> 8); w.out.push_back(info & 0xFF);
|
|
||||||
w.bytes(indication.received_fl_sdu);
|
|
||||||
record(2, w.out);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
if (!indication_) return;
|
|
||||||
link::BtpDataIndication out;
|
|
||||||
out.btp_type = indication.btp_type == BtpType::b ? 1 : 0;
|
|
||||||
out.destination_port = indication.destination_port;
|
|
||||||
out.destination_port_info = indication.source_port.value_or(indication.destination_port_info.value_or(0));
|
|
||||||
out.gn_packet_transport_type = transport_number(indication.gn.transport_type);
|
|
||||||
out.gn_traffic_class = indication.gn.traffic_class.raw();
|
|
||||||
out.gn_remaining_packet_lifetime = indication.gn.remaining_packet_lifetime ? indication.gn.remaining_packet_lifetime->raw() : 0xFF;
|
|
||||||
out.gn_remaining_hop_limit = indication.gn.remaining_hop_limit ? static_cast<std::uint8_t>(std::min(*indication.gn.remaining_hop_limit, 254u)) : 0xFF;
|
|
||||||
ByteBuffer address;
|
|
||||||
gn::serialize_into_buffer(indication.gn.source_position.gn_addr, address);
|
|
||||||
std::copy_n(address.begin(), std::min<std::size_t>(address.size(), 8), out.source_gn_address);
|
|
||||||
out.source_timestamp = indication.gn.source_position.timestamp.raw();
|
|
||||||
out.source_latitude = indication.gn.source_position.latitude.value();
|
|
||||||
out.source_longitude = indication.gn.source_position.longitude.value();
|
|
||||||
out.security_report = boost::apply_visitor(ReportToLink {}, indication.gn.security_report);
|
|
||||||
out.its_aid = indication.gn.its_aid.value_or(0);
|
|
||||||
if (indication.gn.permissions) out.permissions = *indication.gn.permissions;
|
|
||||||
if (indication.gn.certificate_id) { out.certificate_present = true; std::copy(indication.gn.certificate_id->begin(), indication.gn.certificate_id->end(), out.certificate_id); }
|
|
||||||
if (const auto* area = boost::get<gn::Area>(&indication.gn.destination)) out.area = boost::apply_visitor(AreaToLink {*area}, area->shape);
|
|
||||||
out.received_fl_sdu = std::move(indication.received_fl_sdu);
|
|
||||||
indication_(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::provision(const link::Bytes& bundle, link::ApplyReport& report) {
|
|
||||||
try {
|
|
||||||
security::Credentials credentials;
|
|
||||||
if (!security::decode(bundle, credentials) || credentials.roots.empty() || credentials.tickets.empty()) {
|
|
||||||
return link::Code::invalid_argument;
|
|
||||||
}
|
|
||||||
security::NvsCredentialStore store;
|
|
||||||
const auto saved = store.save(credentials);
|
|
||||||
if (saved != Result::accepted) return code(saved);
|
|
||||||
if (config_ && config_->security) {
|
|
||||||
const auto result = build();
|
|
||||||
if (result != link::Code::accepted) return result;
|
|
||||||
if (security_) {
|
|
||||||
report.roots = security_->report.roots;
|
|
||||||
report.authorities = security_->report.authorities;
|
|
||||||
report.tickets = security_->report.tickets;
|
|
||||||
ESP_LOGI(TAG, "credentials provisioned: %u roots, %u authorities, %u tickets",
|
|
||||||
unsigned(report.roots), unsigned(report.authorities), unsigned(report.tickets));
|
|
||||||
}
|
|
||||||
return link::Code::accepted;
|
|
||||||
}
|
|
||||||
report.roots = credentials.roots.size();
|
|
||||||
report.authorities = credentials.authorities.size();
|
|
||||||
report.tickets = credentials.tickets.size();
|
|
||||||
return link::Code::accepted;
|
|
||||||
} catch (const std::bad_alloc&) {
|
|
||||||
ESP_LOGE(TAG, "out of memory provisioning credentials");
|
|
||||||
return link::Code::resource_limit;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
ESP_LOGE(TAG, "failed provisioning credentials: %s", e.what());
|
|
||||||
return link::Code::invalid_argument;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::erase_credentials() {
|
|
||||||
security::NvsCredentialStore store;
|
|
||||||
const auto erased = store.erase();
|
|
||||||
if (erased != Result::accepted && erased != Result::rejected) return code(erased);
|
|
||||||
if (config_ && config_->security) return build();
|
|
||||||
return link::Code::accepted;
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::subscribe(const link::Bytes& subscriber_data, std::uint64_t& subscription) {
|
|
||||||
if (!security_ || !security_->entity) return link::Code::security_unavailable;
|
|
||||||
static std::uint64_t next_handle = 1;
|
|
||||||
const auto handle = next_handle++;
|
|
||||||
const auto service = security_->entity->id_change().subscribe(
|
|
||||||
[this, handle](security::IdChangeCommand command, const security::Identifier& id, const ByteBuffer& data,
|
|
||||||
std::shared_ptr<security::IdChangeResponder> responder) {
|
|
||||||
link::IdChangeEvent event;
|
|
||||||
event.subscription = handle;
|
|
||||||
event.command = static_cast<std::uint8_t>(command);
|
|
||||||
std::copy(id.begin(), id.end(), event.id);
|
|
||||||
event.subscriber_data = data;
|
|
||||||
if (responder) pending_responders_[handle] = responder;
|
|
||||||
if (id_event_ && !rebuilding_) id_event_(event);
|
|
||||||
}, subscriber_data);
|
|
||||||
link_subscriptions_[handle] = service;
|
|
||||||
subscription = handle;
|
|
||||||
return link::Code::accepted;
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::unsubscribe(std::uint64_t subscription) {
|
|
||||||
const auto it = link_subscriptions_.find(subscription);
|
|
||||||
if (it == link_subscriptions_.end()) return link::Code::invalid_argument;
|
|
||||||
Result result = Result::accepted;
|
|
||||||
if (security_ && security_->entity) result = security_->entity->id_change().unsubscribe(it->second);
|
|
||||||
link_subscriptions_.erase(it);
|
|
||||||
pending_responders_.erase(subscription);
|
|
||||||
return code(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::event_response(std::uint64_t subscription, bool return_code) {
|
|
||||||
const auto it = pending_responders_.find(subscription);
|
|
||||||
if (it == pending_responders_.end()) return link::Code::invalid_argument;
|
|
||||||
it->second->respond(return_code);
|
|
||||||
pending_responders_.erase(it);
|
|
||||||
return link::Code::accepted;
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::trigger() {
|
|
||||||
if (!security_ || !security_->entity) return link::Code::security_unavailable;
|
|
||||||
return code(security_->entity->id_change().trigger());
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::lock(std::uint8_t seconds, std::uint64_t& handle) {
|
|
||||||
if (!security_ || !security_->entity) return link::Code::security_unavailable;
|
|
||||||
handle = security_->entity->id_change().lock(seconds);
|
|
||||||
return link::Code::accepted;
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::unlock(std::uint64_t handle) {
|
|
||||||
if (!security_ || !security_->entity) return link::Code::security_unavailable;
|
|
||||||
return code(security_->entity->id_change().unlock(handle));
|
|
||||||
}
|
|
||||||
|
|
||||||
void Station::tick() {
|
|
||||||
// MicrOBU: the stack needs the ITS clock, which only the phone's first PoTi sets. The radio
|
|
||||||
// does not: its queue (16 frames) must be drained and raw frames forwarded from the moment the
|
|
||||||
// station is configured. The colleague's version returned early here, so a phone that had
|
|
||||||
// configured the station but not yet sent a PoTi (no trip recording, no pinger) received
|
|
||||||
// nothing and every frame on air overflowed the queue into radio_dropped.
|
|
||||||
const bool running = stack_ && clock_.synchronised;
|
|
||||||
if (running) {
|
|
||||||
const auto now = vanetza::Clock::time_point(std::chrono::microseconds(clock_.now_us()));
|
|
||||||
if (now > runtime_->now()) stack_->advance(now);
|
|
||||||
}
|
|
||||||
if (radio_) {
|
|
||||||
radio_->poll([this, running](AlDataIndication indication) {
|
|
||||||
++counters_.radio_received;
|
|
||||||
if (received_) received_();
|
|
||||||
if (running && stack_) stack_->indicate(std::move(indication));
|
|
||||||
}, [this](const ByteBuffer& frame, int rssi, std::uint32_t) { forward_raw(frame, rssi); });
|
|
||||||
counters_.radio_dropped = radio_->dropped_frames();
|
|
||||||
}
|
|
||||||
if (running) sample_dcc_channel_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample the hardware LCBR counters at the T_Cbr cadence (TS 102 687 clause 5.4 /
|
|
||||||
// TS 103 836-4-2 clause 5.2, both 100 ms) and feed both DCC entities. The first sample after
|
|
||||||
// (re)build only establishes the baseline counter snapshot -- a CBR delta needs two samples.
|
|
||||||
void Station::sample_dcc_channel_load() {
|
|
||||||
if (!radio_ || !access_stack_) return;
|
|
||||||
const auto its_us = clock_.now_us();
|
|
||||||
if (its_us - dcc_last_sample_its_us_ < 100000) return;
|
|
||||||
const auto counters = radio_->read_cca_counters();
|
|
||||||
if (dcc_cca_last_) {
|
|
||||||
const vanetza::dcc::ChannelLoad local_cbr(C5Radio::calculate_cbr(counters, *dcc_cca_last_));
|
|
||||||
stack_->report_local_channel_load(local_cbr);
|
|
||||||
const auto global_cbr = stack_->global_channel_busy_ratio();
|
|
||||||
// Consume Release-2 CBR_G for DCC_ACC when available, else LCBR.
|
|
||||||
access_stack_->report_channel_load(global_cbr ? *global_cbr : local_cbr);
|
|
||||||
}
|
|
||||||
dcc_cca_last_ = counters;
|
|
||||||
dcc_last_sample_its_us_ = its_us;
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Status Station::status() {
|
|
||||||
link::Status s = counters_;
|
|
||||||
s.uptime_ms = static_cast<std::uint32_t>(esp_timer_get_time() / 1000);
|
|
||||||
s.configured = stack_ ? 1 : 0;
|
|
||||||
if (stack_) {
|
|
||||||
ByteBuffer address;
|
|
||||||
gn::serialize_into_buffer(stack_->address(), address);
|
|
||||||
std::copy_n(address.begin(), std::min<std::size_t>(address.size(), 8), s.gn_address);
|
|
||||||
s.change_pending = stack_->identity_change_pending() ? 1 : 0;
|
|
||||||
}
|
|
||||||
if (security_ && security_->entity) {
|
|
||||||
const auto id = security_->entity->id_change().current_identifier();
|
|
||||||
std::copy(id.begin(), id.end(), s.identifier);
|
|
||||||
s.tickets = static_cast<std::uint8_t>(std::min<std::size_t>(security_->pool.size(), 255));
|
|
||||||
const auto& st = security_->entity->statistics();
|
|
||||||
s.signed_messages = st.signed_messages; s.refused_no_ticket = st.refused_no_ticket;
|
|
||||||
s.refused_change_pending = st.refused_change_pending; s.refused_permission = st.refused_permission;
|
|
||||||
s.sign_failed = st.failed; s.verified = st.verified;
|
|
||||||
s.rejected = st.rejected_profile + st.rejected_signer + st.rejected_certificate + st.rejected_signature + st.rejected_time + st.replayed;
|
|
||||||
}
|
|
||||||
s.its_time_ms = clock_.synchronised ? static_cast<std::uint64_t>(clock_.now_us() / 1000) : 0;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- access adapter: radio, mirrored or diverted to the software lower tester ----
|
|
||||||
Result Station::request(AlDataRequest request) {
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
if (mirror_ != Mirror::off) record(1, request.data);
|
|
||||||
if (mirror_ == Mirror::divert) { ++counters_.radio_submitted; return overflow_ ? Result::resource_limit : Result::accepted; }
|
|
||||||
#endif
|
|
||||||
if (radio_) {
|
|
||||||
// Adaptive DCC_ACC gates here when access_stack_ has it enabled (build()); it falls
|
|
||||||
// through to radio_->request() unchanged otherwise.
|
|
||||||
const auto result = access_stack_ ? access_stack_->request(std::move(request)) : radio_->request(std::move(request));
|
|
||||||
if (result == Result::accepted) {
|
|
||||||
++counters_.radio_submitted;
|
|
||||||
if (disseminated_) disseminated_();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
++counters_.radio_failed;
|
|
||||||
static std::int64_t last_report = 0; // one line per second, the counter has the rest
|
|
||||||
const auto now = esp_timer_get_time();
|
|
||||||
if (now - last_report > 1000000) { last_report = now; ESP_LOGW(TAG, "radio refused a frame: result %d", int(result)); }
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
++counters_.radio_failed;
|
|
||||||
return Result::unsupported; // no radio configured and nothing diverting: the request has nowhere to go
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// The ESP32-C5 half of the VRU ITS-S: BTP-B, GeoNetworking, the SN-SAP
|
|
||||||
// security entity with the station's provisioned credentials, and the ITS-G5 access adapter.
|
|
||||||
// Everything here runs on one task (the station task).
|
|
||||||
#include "c5_radio.hpp"
|
|
||||||
#include "link_protocol.hpp"
|
|
||||||
#include <vanetza_idf/stack.hpp>
|
|
||||||
#include <vanetza_idf/security.hpp>
|
|
||||||
#include <vanetza_idf/credentials.hpp>
|
|
||||||
#include <vanetza_idf/backend_mbedtls.hpp>
|
|
||||||
#include <vanetza/common/manual_runtime.hpp>
|
|
||||||
#include <vanetza/common/position_provider.hpp>
|
|
||||||
#include <deque>
|
|
||||||
#include <functional>
|
|
||||||
#include <map>
|
|
||||||
#include <memory>
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
/// Records the software lower tester collects (test channel, test firmware only).
|
|
||||||
struct TestRecord { std::uint8_t kind; link::Bytes bytes; };
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// @note All public members except the on_*() callback setters must be called from the station task.
|
|
||||||
class Station final : public vanetza_idf::Access, public vanetza::PositionProvider {
|
|
||||||
public:
|
|
||||||
using Indication = std::function<void(const link::BtpDataIndication&)>;
|
|
||||||
using IdEvent = std::function<void(const link::IdChangeEvent&)>;
|
|
||||||
using Disseminated = std::function<void()>;
|
|
||||||
using Received = std::function<void()>;
|
|
||||||
/// MicrOBU: body of one link V2X_RX message (see link_protocol.hpp).
|
|
||||||
using RawIts = std::function<void(const link::Bytes&)>;
|
|
||||||
|
|
||||||
/// @brief Constructs an unconfigured station (call configure() before use).
|
|
||||||
Station();
|
|
||||||
/// @brief Tears down the stack, security entity and radio in dependency order.
|
|
||||||
~Station() override;
|
|
||||||
|
|
||||||
// ---- link primitives (station task) ----
|
|
||||||
/// @brief (Re)builds the stack and security entity for a new station configuration.
|
|
||||||
/// @param config Requested configuration.
|
|
||||||
/// @param detail Receives the assigned GN address, identifier, and ticket count.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code configure(const link::StationConfigure& config, link::Bytes& detail);
|
|
||||||
|
|
||||||
/// @brief Applies a position/time fix from the phone and advances the ITS clock.
|
|
||||||
/// @param fix Position/time fix to apply.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code poti(const link::PotiUpdate& fix);
|
|
||||||
|
|
||||||
/// @brief Submits a BTP data request to the stack.
|
|
||||||
/// @param request Request to submit.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code btp_request(const link::BtpDataRequest& request);
|
|
||||||
|
|
||||||
/// @brief Decodes and stores a credential bundle, rebuilding the security entity if configured.
|
|
||||||
/// @param bundle Raw credential bundle bytes.
|
|
||||||
/// @param report Receives the applied root/authority/ticket counts.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code provision(const link::Bytes& bundle, link::ApplyReport& report);
|
|
||||||
|
|
||||||
/// @brief Erases stored credentials and rebuilds if security is configured.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code erase_credentials();
|
|
||||||
|
|
||||||
/// @brief Subscribes to pseudonym-change events.
|
|
||||||
/// @param subscriber_data Opaque data echoed back with events for this subscription.
|
|
||||||
/// @param subscription Receives the assigned subscription handle.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code subscribe(const link::Bytes& subscriber_data, std::uint64_t& subscription);
|
|
||||||
|
|
||||||
/// @brief Cancels a pseudonym-change subscription.
|
|
||||||
/// @param subscription Handle returned by subscribe().
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code unsubscribe(std::uint64_t subscription);
|
|
||||||
|
|
||||||
/// @brief Resolves a pending PREPARE/COMMIT identity-change event.
|
|
||||||
/// @param subscription Handle the event was delivered for.
|
|
||||||
/// @param return_code Caller's response (accept/reject) to the pending event.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code event_response(std::uint64_t subscription, bool return_code);
|
|
||||||
|
|
||||||
/// @brief Triggers an immediate pseudonym change.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code trigger();
|
|
||||||
|
|
||||||
/// @brief Locks the current pseudonym for the given duration.
|
|
||||||
/// @param seconds Lock duration in seconds.
|
|
||||||
/// @param handle Receives the assigned lock handle.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code lock(std::uint8_t seconds, std::uint64_t& handle);
|
|
||||||
|
|
||||||
/// @brief Releases a pseudonym lock.
|
|
||||||
/// @param handle Handle returned by lock().
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code unlock(std::uint64_t handle);
|
|
||||||
|
|
||||||
/// @return Current station status snapshot.
|
|
||||||
link::Status status();
|
|
||||||
|
|
||||||
/// @brief Advances the ITS clock, runs timers, polls the radio; call every few milliseconds.
|
|
||||||
void tick();
|
|
||||||
|
|
||||||
/// @brief Registers the callback invoked when a BTP data indication arrives.
|
|
||||||
/// @param f Callback to invoke; replaces any previously registered callback.
|
|
||||||
void on_indication(Indication f) { indication_ = std::move(f); }
|
|
||||||
|
|
||||||
/// @brief Registers the callback invoked when a pseudonym-change event fires.
|
|
||||||
/// @param f Callback to invoke; replaces any previously registered callback.
|
|
||||||
void on_id_event(IdEvent f) { id_event_ = std::move(f); }
|
|
||||||
|
|
||||||
/// @brief Registers the callback invoked after a frame is disseminated.
|
|
||||||
/// @param f Callback to invoke; replaces any previously registered callback.
|
|
||||||
void on_disseminated(Disseminated f) { disseminated_ = std::move(f); }
|
|
||||||
|
|
||||||
/// @brief Registers the callback invoked after a frame is received.
|
|
||||||
/// @param f Callback to invoke; replaces any previously registered callback.
|
|
||||||
void on_received(Received f) { received_ = std::move(f); }
|
|
||||||
|
|
||||||
/// @brief MicrOBU: registers the callback for every ITS message heard on air, unwrapped by
|
|
||||||
/// gn_unwrap.c before (and independently of) the stack's security checks. The stack drops
|
|
||||||
/// what it cannot verify (itsGnSnDecapResultHandling is STRICT in vanetza-idf): unsigned
|
|
||||||
/// traffic, and anything signed under a root other than the provisioned demo root, i.e. every
|
|
||||||
/// RSU. This path is how those still reach the phone, exactly as with the previous firmware.
|
|
||||||
/// @param f Callback to invoke with a V2X_RX body; replaces any previously registered callback.
|
|
||||||
void on_raw_its(RawIts f) { raw_its_ = std::move(f); }
|
|
||||||
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
// ---- test channel (software lower tester; not part of the phone interface -- see test_channel.hpp) ----
|
|
||||||
enum class Mirror : std::uint8_t { off = 0, mirror = 1, divert = 2 };
|
|
||||||
/// @brief Sets whether requests/indications are mirrored to, or diverted through, the test channel.
|
|
||||||
/// @param mode Off, mirror (copy) or divert (intercept) mode.
|
|
||||||
void test_mirror(Mirror mode);
|
|
||||||
|
|
||||||
/// @brief Injects a raw GN PDU as if received over the air.
|
|
||||||
/// @param source Source MAC address to report for the injected frame.
|
|
||||||
/// @param destination Destination MAC address to report for the injected frame.
|
|
||||||
/// @param gnpdu Raw GeoNetworking PDU bytes.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code test_inject(const vanetza::MacAddress& source, const vanetza::MacAddress& destination, link::Bytes gnpdu);
|
|
||||||
/// @brief Submits a raw GeoNetworking request bypassing BTP.
|
|
||||||
/// @param traffic_class GeoNetworking traffic class to submit with.
|
|
||||||
/// @param payload Raw payload bytes.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code test_gn_request(std::uint8_t traffic_class, link::Bytes payload);
|
|
||||||
/// @brief Pops queued mirror/divert records fitting into budget octets (3 per record header); the rest stays queued.
|
|
||||||
/// @param overflow Receives true if records were dropped because the queue budget was exceeded.
|
|
||||||
/// @param budget Maximum number of octets of records to drain.
|
|
||||||
/// @return Drained records.
|
|
||||||
std::deque<TestRecord> test_drain(bool& overflow, std::size_t budget);
|
|
||||||
/// @brief Clears mirror mode and any queued records.
|
|
||||||
void test_reset();
|
|
||||||
/// @brief Starts the radio if needed and sends a burst of raw test frames.
|
|
||||||
/// @param channel ITS-G5 channel number to transmit on.
|
|
||||||
/// @param power_dbm Transmit power in dBm.
|
|
||||||
/// @param mcs 802.11p modulation and coding scheme index.
|
|
||||||
/// @param count Number of frames to send.
|
|
||||||
/// @param interval_ms Interval between frames in milliseconds.
|
|
||||||
/// @param payload_len Length of each frame's payload in bytes.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code test_radio_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
|
|
||||||
unsigned count, unsigned interval_ms, std::size_t payload_len);
|
|
||||||
/// @brief Starts the radio if needed and samples the CCA state for duration_ms.
|
|
||||||
/// @param duration_ms Sampling window duration in milliseconds.
|
|
||||||
/// @param detail Receives the sampling statistics.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code test_cca_sample(unsigned duration_ms, link::Bytes& detail);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// vanetza_idf::Access
|
|
||||||
/// @brief Submits a frame to the radio (through DCC_ACC when enabled), mirroring/diverting for the test channel.
|
|
||||||
/// @param request Frame and transmit parameters from the access layer.
|
|
||||||
/// @return Result code.
|
|
||||||
vanetza_idf::Result request(vanetza_idf::AlDataRequest request) override;
|
|
||||||
|
|
||||||
// vanetza::PositionProvider
|
|
||||||
/// @return The most recently applied position fix.
|
|
||||||
const vanetza::PositionFix& position_fix() override { return fix_; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct Security;
|
|
||||||
struct Clock {
|
|
||||||
bool synchronised = false;
|
|
||||||
std::int64_t base_its_us = 0; // ITS time at base_esp_us
|
|
||||||
std::int64_t base_esp_us = 0;
|
|
||||||
std::int64_t now_us() const;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// @brief (Re)builds the stack and security entity from config_, stored credentials and the clock.
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code build();
|
|
||||||
|
|
||||||
/// @brief Destroys the stack, security entity and runtime in dependency order.
|
|
||||||
void teardown();
|
|
||||||
|
|
||||||
/// @brief Maps a station configuration onto the GeoNetworking MIB.
|
|
||||||
/// @param mib MIB to populate.
|
|
||||||
/// @param config Station configuration to map from.
|
|
||||||
void apply_mib(vanetza_idf::StackConfig& mib, const link::StationConfigure& config) const;
|
|
||||||
|
|
||||||
/// @brief Re-subscribes existing pseudonym-change handles to a freshly built security entity.
|
|
||||||
void resubscribe_id_change();
|
|
||||||
|
|
||||||
/// @brief Pushes the current position fix into the stack, clamped to not precede the station clock.
|
|
||||||
void apply_position();
|
|
||||||
|
|
||||||
/// @brief Samples the hardware CCA counters at the DCC cadence and feeds both DCC entities.
|
|
||||||
void sample_dcc_channel_load();
|
|
||||||
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
/// @brief Queues bytes for the test channel, subject to the mirror-buffer budget.
|
|
||||||
/// @param kind Record kind tag.
|
|
||||||
/// @param bytes Record payload bytes.
|
|
||||||
void record(std::uint8_t kind, link::Bytes bytes);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// @brief MicrOBU: unwraps one raw received frame with gn_unwrap.c and hands it to raw_its_.
|
|
||||||
/// @param frame Complete 802.11 frame as captured (FCS included; gn_unwrap reads declared lengths).
|
|
||||||
/// @param rssi Received signal strength, dBm.
|
|
||||||
void forward_raw(const vanetza::ByteBuffer& frame, int rssi);
|
|
||||||
|
|
||||||
/// @brief MicrOBU: transmits an unsecured BTP-B/SHB request the way the previous firmware did,
|
|
||||||
/// with geonet.c's GN header and the Source Position Vector of the last PoTi, through request().
|
|
||||||
/// @param request The phone's request (already checked: configured, clock and fix present).
|
|
||||||
/// @return Result code.
|
|
||||||
link::Code unsecured_request(const link::BtpDataRequest& request);
|
|
||||||
|
|
||||||
/// @brief Translates and forwards a stack BTP indication to the link service and test channel.
|
|
||||||
/// @param indication Indication received from the stack.
|
|
||||||
void deliver(vanetza_idf::BtpIndication indication);
|
|
||||||
|
|
||||||
link::Status counters_;
|
|
||||||
|
|
||||||
std::optional<link::StationConfigure> config_;
|
|
||||||
Clock clock_;
|
|
||||||
vanetza::PositionFix fix_;
|
|
||||||
bool have_fix_ = false;
|
|
||||||
link::PotiUpdate last_poti_; // MicrOBU: the GN Source Position Vector of unsecured_request()
|
|
||||||
std::uint32_t raw_forwarded_ = 0, raw_oversize_ = 0;
|
|
||||||
std::unique_ptr<vanetza::ManualRuntime> runtime_;
|
|
||||||
std::unique_ptr<Security> security_;
|
|
||||||
std::unique_ptr<vanetza_idf::Stack> stack_;
|
|
||||||
std::unique_ptr<C5Radio> radio_;
|
|
||||||
std::unique_ptr<vanetza_idf::AccessStack> access_stack_; // DCC_ACC gate in front of radio_
|
|
||||||
vanetza_idf::AlDataRequest radio_parameters_;
|
|
||||||
std::optional<CcaCounters> dcc_cca_last_; // previous read_cca_counters() sample, for the 100 ms LCBR delta
|
|
||||||
std::int64_t dcc_last_sample_its_us_ = 0;
|
|
||||||
Indication indication_;
|
|
||||||
IdEvent id_event_;
|
|
||||||
Disseminated disseminated_;
|
|
||||||
Received received_;
|
|
||||||
RawIts raw_its_;
|
|
||||||
std::map<std::uint64_t, std::shared_ptr<vanetza_idf::security::IdChangeResponder>> pending_responders_;
|
|
||||||
std::map<std::uint64_t, std::uint64_t> link_subscriptions_; // subscription -> service handle (identity)
|
|
||||||
bool rebuilding_ = false;
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
Mirror mirror_ = Mirror::off;
|
|
||||||
std::deque<TestRecord> records_;
|
|
||||||
std::size_t record_bytes_ = 0;
|
|
||||||
bool overflow_ = false;
|
|
||||||
#endif
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
// Station's test-channel API (software lower tester; not part of the phone interface, see
|
|
||||||
// test_channel.hpp). Kept in its own translation unit so the productive station.cpp stays free
|
|
||||||
// of test-only code; compiles to nothing when CONFIG_MICROBU_TEST_CHANNEL is off.
|
|
||||||
#include "station.hpp"
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
#include <vanetza_idf/nf_sap.hpp>
|
|
||||||
#include <vanetza_idf/sf_sap.hpp>
|
|
||||||
#include <esp_log.h>
|
|
||||||
#include "c5_radio.hpp"
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
using namespace vanetza_idf;
|
|
||||||
namespace gn = vanetza::geonet;
|
|
||||||
namespace {
|
|
||||||
const char* TAG = "station";
|
|
||||||
link::Code code(Result result) { return static_cast<link::Code>(result); }
|
|
||||||
}
|
|
||||||
|
|
||||||
void Station::test_mirror(Mirror mode) { mirror_ = mode; }
|
|
||||||
|
|
||||||
link::Code Station::test_inject(const vanetza::MacAddress& source, const vanetza::MacAddress& destination, link::Bytes gnpdu) {
|
|
||||||
if (!stack_) return link::Code::not_configured;
|
|
||||||
tick();
|
|
||||||
AlDataIndication indication;
|
|
||||||
indication.source = source;
|
|
||||||
indication.destination = destination;
|
|
||||||
indication.channel_number = radio_parameters_.channel_number;
|
|
||||||
indication.data = std::move(gnpdu);
|
|
||||||
return code(stack_->indicate(std::move(indication)));
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::test_gn_request(std::uint8_t traffic_class, link::Bytes payload) {
|
|
||||||
if (!stack_) return link::Code::not_configured;
|
|
||||||
if (!clock_.synchronised || !have_fix_) return link::Code::rejected;
|
|
||||||
tick();
|
|
||||||
GnRequest request;
|
|
||||||
request.traffic_class = gn::TrafficClass(traffic_class);
|
|
||||||
request.data = std::move(payload);
|
|
||||||
return code(stack_->request(std::move(request)));
|
|
||||||
}
|
|
||||||
|
|
||||||
std::deque<TestRecord> Station::test_drain(bool& overflow, std::size_t budget) {
|
|
||||||
std::deque<TestRecord> out;
|
|
||||||
std::size_t used = 0;
|
|
||||||
while (!records_.empty() && used + records_.front().bytes.size() + 3 <= budget) {
|
|
||||||
used += records_.front().bytes.size() + 3;
|
|
||||||
record_bytes_ -= records_.front().bytes.size();
|
|
||||||
out.push_back(std::move(records_.front()));
|
|
||||||
records_.pop_front();
|
|
||||||
}
|
|
||||||
overflow = overflow_ && records_.empty();
|
|
||||||
if (records_.empty()) overflow_ = false;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Station::test_reset() {
|
|
||||||
mirror_ = Mirror::off;
|
|
||||||
records_.clear();
|
|
||||||
record_bytes_ = 0;
|
|
||||||
overflow_ = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::test_radio_burst(std::uint16_t channel, double power_dbm, unsigned mcs,
|
|
||||||
unsigned count, unsigned interval_ms, std::size_t payload_len) {
|
|
||||||
if (!radio_) {
|
|
||||||
// Automatically start the radio for testing if not yet started
|
|
||||||
C5RadioConfig rc;
|
|
||||||
rc.channel_number = channel;
|
|
||||||
rc.transmit_power_dbm = power_dbm;
|
|
||||||
rc.laboratory_transmission = true;
|
|
||||||
radio_ = std::make_unique<C5Radio>(rc);
|
|
||||||
const auto error = radio_->start();
|
|
||||||
if (error != ESP_OK) {
|
|
||||||
ESP_LOGE(TAG, "radio start failed for test burst: %s", esp_err_to_name(error));
|
|
||||||
radio_.reset();
|
|
||||||
return link::Code::rejected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const auto err = radio_->transmit_burst(channel, power_dbm, mcs, count, interval_ms, payload_len);
|
|
||||||
if (err == ESP_OK) {
|
|
||||||
counters_.radio_submitted += count;
|
|
||||||
if (disseminated_) disseminated_();
|
|
||||||
return link::Code::accepted;
|
|
||||||
}
|
|
||||||
if (err == ESP_ERR_INVALID_ARG) return link::Code::invalid_argument;
|
|
||||||
if (err == ESP_ERR_NO_MEM) return link::Code::resource_limit;
|
|
||||||
return link::Code::rejected;
|
|
||||||
}
|
|
||||||
|
|
||||||
link::Code Station::test_cca_sample(unsigned duration_ms, link::Bytes& detail) {
|
|
||||||
// Capped: this busy-loops with interrupts otherwise free, so keep it short enough that
|
|
||||||
// the task watchdog and the WiFi/BLE stack's own housekeeping are not starved for long.
|
|
||||||
duration_ms = std::min(duration_ms, 500u);
|
|
||||||
if (!radio_) {
|
|
||||||
C5RadioConfig rc; // default channel 180 / 10 dBm is fine: this probes PHY polling
|
|
||||||
rc.laboratory_transmission = true; // capability, not a specific channel's RF content
|
|
||||||
radio_ = std::make_unique<C5Radio>(rc);
|
|
||||||
const auto error = radio_->start();
|
|
||||||
if (error != ESP_OK) {
|
|
||||||
ESP_LOGE(TAG, "radio start failed for CCA sample: %s", esp_err_to_name(error));
|
|
||||||
radio_.reset();
|
|
||||||
return link::Code::rejected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const auto result = radio_->sample_cca(duration_ms);
|
|
||||||
link::Writer w;
|
|
||||||
w.u32(result.samples);
|
|
||||||
w.u32(result.duration_us);
|
|
||||||
w.u32(result.min_delta_us);
|
|
||||||
w.u32(result.max_delta_us);
|
|
||||||
w.u32(result.busy_count);
|
|
||||||
w.i32(result.first_cca);
|
|
||||||
w.i32(result.last_cca);
|
|
||||||
w.i32(result.noise_floor_dbm);
|
|
||||||
w.i32(result.cca_total_cycles_delta);
|
|
||||||
w.i32(result.cca_busy_cycles_delta);
|
|
||||||
w.i32(result.cca_status);
|
|
||||||
detail = w.out;
|
|
||||||
return result.samples > 0 ? link::Code::accepted : link::Code::rejected;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Station::record(std::uint8_t kind, link::Bytes bytes) {
|
|
||||||
if (mirror_ == Mirror::off) return;
|
|
||||||
if (record_bytes_ + bytes.size() > 8192 || records_.size() >= 32) { overflow_ = true; return; }
|
|
||||||
record_bytes_ += bytes.size();
|
|
||||||
records_.push_back(TestRecord {kind, std::move(bytes)});
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
#endif // CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
#include "test_channel.hpp"
|
|
||||||
#if CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
|
|
||||||
link::Bytes test_channel_execute(Station& station, const link::Bytes& request) {
|
|
||||||
using link::Code;
|
|
||||||
Code result = Code::malformed;
|
|
||||||
std::deque<TestRecord> records;
|
|
||||||
link::Bytes extra;
|
|
||||||
if (!request.empty()) {
|
|
||||||
link::Reader r(request, 1);
|
|
||||||
switch (request[0]) {
|
|
||||||
case 0x01: { // MIRROR mode
|
|
||||||
const auto mode = r.u8();
|
|
||||||
if (r.done() && mode <= 2) { station.test_mirror(static_cast<Station::Mirror>(mode)); result = Code::accepted; }
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 0x02: { // INJECT source destination gnpdu
|
|
||||||
vanetza::MacAddress source, destination;
|
|
||||||
r.bytes(source.octets.data(), 6);
|
|
||||||
r.bytes(destination.octets.data(), 6);
|
|
||||||
auto gnpdu = r.rest();
|
|
||||||
if (r.ok() && !gnpdu.empty()) result = station.test_inject(source, destination, std::move(gnpdu));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 0x03: { // GN_REQUEST traffic class payload
|
|
||||||
const auto tc = r.u8();
|
|
||||||
auto payload = r.rest();
|
|
||||||
if (r.ok() && !payload.empty()) result = station.test_gn_request(tc, std::move(payload));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 0x04: { // DRAIN
|
|
||||||
bool overflow = false;
|
|
||||||
records = station.test_drain(overflow, 1536 - 2);
|
|
||||||
result = overflow ? Code::resource_limit : Code::accepted;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 0x05: // RESET
|
|
||||||
station.test_reset();
|
|
||||||
result = Code::accepted;
|
|
||||||
break;
|
|
||||||
case 0x06: { // RADIO_BURST [channel u16 LE][power_quarter_db u8][mcs u8][count u16 LE][interval_ms u16 LE][payload_len u16 LE]
|
|
||||||
const auto channel = r.u16();
|
|
||||||
const auto power_quarter_db = r.u8();
|
|
||||||
const auto mcs = r.u8();
|
|
||||||
const auto count = r.u16();
|
|
||||||
const auto interval_ms = r.u16();
|
|
||||||
const auto payload_len = r.u16();
|
|
||||||
if (r.done()) {
|
|
||||||
const double power_dbm = power_quarter_db / 4.0;
|
|
||||||
result = station.test_radio_burst(channel, power_dbm, mcs, count, interval_ms, payload_len);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 0x07: { // CCA_SAMPLE [duration_ms u16 LE] -- CCA polling feasibility probe
|
|
||||||
const auto duration_ms = r.u16();
|
|
||||||
if (r.done()) result = station.test_cca_sample(duration_ms, extra);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
result = Code::unknown_opcode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
link::Writer w;
|
|
||||||
w.u8(static_cast<std::uint8_t>(result));
|
|
||||||
w.u8(static_cast<std::uint8_t>(std::min<std::size_t>(records.size(), 255)));
|
|
||||||
for (const auto& record : records) {
|
|
||||||
w.u8(record.kind);
|
|
||||||
w.u16(static_cast<std::uint16_t>(record.bytes.size()));
|
|
||||||
w.bytes(record.bytes);
|
|
||||||
}
|
|
||||||
w.bytes(extra);
|
|
||||||
return w.out;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
#endif // CONFIG_MICROBU_TEST_CHANNEL
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// Test channel (serial frame type 0x11, test firmware only): software lower tester and the
|
|
||||||
// diagnostic hooks the ETSI campaigns need. Not part of the phone interface, never on BLE.
|
|
||||||
#include "link_protocol.hpp"
|
|
||||||
#include "station.hpp"
|
|
||||||
|
|
||||||
namespace microbu {
|
|
||||||
|
|
||||||
/// @brief Executes one software-lower-tester request against the station.
|
|
||||||
/// @param station Station to execute the request against.
|
|
||||||
/// @param request Raw test-channel request bytes.
|
|
||||||
/// @return Reply bytes: [result][record count]{[kind][length u16 LE][bytes]}.
|
|
||||||
link::Bytes test_channel_execute(Station& station, const link::Bytes& request);
|
|
||||||
|
|
||||||
} // namespace microbu
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Name, Type, SubType, Offset, Size, Flags
|
|
||||||
# MicrOBU: NVS 80 KB instead of the colleague's 24 KB (their board has 4 MB of flash, this one 16 MB).
|
|
||||||
# NVS holds the BLE bond, the credential bundle and PHY calibration; at 24 KB, with Wi-Fi settings the
|
|
||||||
# previous firmware had left in it, there was no room for the bond, so the board forgot the phone
|
|
||||||
# after every connection. The app therefore moves from 0x10000 to 0x20000 (64 KB aligned).
|
|
||||||
nvs, data, nvs, 0x9000, 0x14000,
|
|
||||||
phy_init, data, phy, 0x1D000, 0x1000,
|
|
||||||
factory, app, factory, 0x20000, 0x300000,
|
|
||||||
|
+988
-819
File diff suppressed because it is too large
Load Diff
@@ -1,54 +1 @@
|
|||||||
# obu-firmware defaults (ESP32-C5, ESP-IDF 6.0.2). Based on microbu-esp32c5/firmware/sdkconfig.defaults;
|
|
||||||
# the differences for this board are marked "MicrOBU:".
|
|
||||||
CONFIG_IDF_TARGET="esp32c5"
|
CONFIG_IDF_TARGET="esp32c5"
|
||||||
CONFIG_COMPILER_CXX_EXCEPTIONS=y
|
|
||||||
CONFIG_COMPILER_CXX_RTTI=y
|
|
||||||
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
|
|
||||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
|
||||||
# MicrOBU: the production board has 16 MB of flash (esptool reports it); the partition table
|
|
||||||
# only uses the first 4 MB.
|
|
||||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
|
||||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
|
||||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
|
||||||
# Network profile: BTP + GeoNetworking, no facilities codecs (the phone builds CAM/VAM)
|
|
||||||
CONFIG_VANETZA_IDF_PROFILE_NETWORK=y
|
|
||||||
CONFIG_VANETZA_IDF_SECURITY=y
|
|
||||||
CONFIG_VANETZA_IDF_SECURITY_VERIFY=y
|
|
||||||
CONFIG_VANETZA_IDF_NVS_CREDENTIALS=y
|
|
||||||
CONFIG_VANETZA_IDF_PKI=n
|
|
||||||
CONFIG_VANETZA_IDF_HIL=n
|
|
||||||
CONFIG_VANETZA_IDF_RADIO_C5=y
|
|
||||||
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6
|
|
||||||
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16
|
|
||||||
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16
|
|
||||||
CONFIG_ESP_WIFI_MGMT_SBUF_NUM=16
|
|
||||||
# MicrOBU: the console and ESP_LOG stay on UART0, i.e. the CH343 bridge port (COM3 on the bench),
|
|
||||||
# exactly as in the previous firmware. The native USB Serial/JTAG port belongs to the phone and
|
|
||||||
# carries only station-link frames. The colleague's board has only the native port and routes the
|
|
||||||
# console there instead.
|
|
||||||
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
|
|
||||||
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
|
||||||
CONFIG_ESP_PANIC_HANDLER_IRAM=y
|
|
||||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
|
||||||
CONFIG_LOG_COLORS=n
|
|
||||||
# MicrOBU: 20 dBm ceiling, as the previous firmware (the phone configures 20).
|
|
||||||
CONFIG_ESP_PHY_MAX_WIFI_TX_POWER=20
|
|
||||||
# Station-internal BLE GATT link: NimBLE, maximum ATT value, persistent bonds.
|
|
||||||
CONFIG_BT_ENABLED=y
|
|
||||||
CONFIG_BT_BLUEDROID_ENABLED=n
|
|
||||||
CONFIG_BT_NIMBLE_ENABLED=y
|
|
||||||
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=517
|
|
||||||
CONFIG_BT_NIMBLE_SM_SC=y
|
|
||||||
CONFIG_BT_NIMBLE_NVS_PERSIST=y
|
|
||||||
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
|
|
||||||
CONFIG_BT_NIMBLE_MAX_BONDS=1
|
|
||||||
CONFIG_BT_NIMBLE_MAX_CCCDS=8
|
|
||||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12
|
|
||||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=12
|
|
||||||
CONFIG_BT_NIMBLE_ROLE_CENTRAL=n
|
|
||||||
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
|
|
||||||
# Hardware ECDSA/ECC/SHA
|
|
||||||
CONFIG_MBEDTLS_HARDWARE_ECC=y
|
|
||||||
CONFIG_MBEDTLS_HARDWARE_ECDSA_VERIFY=y
|
|
||||||
CONFIG_MBEDTLS_HARDWARE_SHA=y
|
|
||||||
CONFIG_MBEDTLS_HARDWARE_AES=n
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ PYTHON_ASN1 = py -3.11
|
|||||||
FW = ../../main
|
FW = ../../main
|
||||||
BUILD = build
|
BUILD = build
|
||||||
EXE = $(if $(filter Windows_NT,$(OS)),.exe,)
|
EXE = $(if $(filter Windows_NT,$(OS)),.exe,)
|
||||||
RECORDINGS = $(wildcard ../../../its-g5-receiver-firmware/recordings/*.pcap)
|
# Captures live in capture/recordings/ since 2026-09-14; older ones are still in the
|
||||||
|
# receiver checkout beside this repo.
|
||||||
|
RECORDINGS = $(wildcard ../../../capture/recordings/*.pcap ../../../its-g5-receiver-firmware/recordings/*.pcap)
|
||||||
FUZZ_ITER = 2000000
|
FUZZ_ITER = 2000000
|
||||||
FUZZ_SEED = 1
|
FUZZ_SEED = 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Verify the IEEE 1609.2 / TS 103 097 signatures of secured GeoNetworking frames in a pcap.
|
|
||||||
|
|
||||||
Written 2026-09-23 to check, independently of the firmware, that the ESP32-C5 really signs with
|
|
||||||
the demo authorization ticket the app provisions. It shares no code with vanetza-idf: the envelope
|
|
||||||
is decoded with asn1tools from the IEEE 1609.2 ASN.1 modules, and ECDSA is checked with Python's
|
|
||||||
`cryptography` (OpenSSL).
|
|
||||||
|
|
||||||
py -3.11 obu-firmware/test/verify_signed_pcap.py capture.pcap \\
|
|
||||||
--bundle app/src/main/assets/demo-chain.vcr \\
|
|
||||||
--asn1 microbu-esp32c5/external/vanetza-idf/asn1
|
|
||||||
|
|
||||||
For every frame whose GN Basic Header says "secured" it reports: the signer (digest or full
|
|
||||||
certificate), whether that signer is the bundle's ticket, the psid and generation time, and
|
|
||||||
whether the message signature verifies with the ticket's public key. It also checks the bundle's
|
|
||||||
own chain (ticket signed by AA, AA by root, root self-signed). Frames signed by anyone else (an
|
|
||||||
RSU under the EU PKI) are counted and listed, not verified: their certificates are not known here.
|
|
||||||
|
|
||||||
Signature input, IEEE 1609.2 clause 5.3.1: ECDSA over Hash(tbsData) || Hash(signer), where the
|
|
||||||
signer part is the COER of the signing certificate (the empty string for a self-signed root).
|
|
||||||
|
|
||||||
Handles linktype 105 (bare 802.11, what the V2X2MAP bridge records) and 127 (radiotap).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
from collections import Counter
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
LLC_SNAP_GN = b"\xaa\xaa\x03\x00\x00\x00\x89\x47"
|
|
||||||
ITS_EPOCH_UNIX = 1072915200
|
|
||||||
|
|
||||||
|
|
||||||
def pcap_frames(path: Path):
|
|
||||||
data = path.read_bytes()
|
|
||||||
magic = struct.unpack("<I", data[:4])[0]
|
|
||||||
endian = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
|
|
||||||
linktype = struct.unpack(endian + "I", data[20:24])[0]
|
|
||||||
i = 24
|
|
||||||
while i + 16 <= len(data):
|
|
||||||
ts_sec, ts_frac, incl, _orig = struct.unpack(endian + "IIII", data[i:i + 16])
|
|
||||||
frame = data[i + 16:i + 16 + incl]
|
|
||||||
i += 16 + incl
|
|
||||||
if linktype == 127: # radiotap: skip its own length
|
|
||||||
frame = frame[struct.unpack("<H", frame[2:4])[0]:]
|
|
||||||
elif linktype != 105:
|
|
||||||
raise SystemExit("unsupported linktype %d" % linktype)
|
|
||||||
yield ts_sec + ts_frac / 1e6, frame
|
|
||||||
|
|
||||||
|
|
||||||
def secured_payload(frame: bytes):
|
|
||||||
"""Source MAC and the bytes after the GN Basic Header, if this is a secured GN frame."""
|
|
||||||
if len(frame) < 24:
|
|
||||||
return None
|
|
||||||
fc = frame[0]
|
|
||||||
if (fc >> 2) & 0x3 != 2: # not a data frame
|
|
||||||
return None
|
|
||||||
header = 26 if (fc >> 4) & 0x8 else 24 # QoS data carries 2 more octets
|
|
||||||
at = frame.find(LLC_SNAP_GN, header, header + 16)
|
|
||||||
if at < 0:
|
|
||||||
return None
|
|
||||||
gn = frame[at + 8:]
|
|
||||||
if len(gn) < 5 or gn[0] & 0x0F != 2: # Basic Header next header 2: secured packet
|
|
||||||
return None
|
|
||||||
return frame[10:16], gn[4:]
|
|
||||||
|
|
||||||
|
|
||||||
def read_bundle(path: Path):
|
|
||||||
"""The VCR1 bundle's certificates: [type 1][length 2 BE][payload] records."""
|
|
||||||
data = path.read_bytes()
|
|
||||||
certs = {"root": [], "authority": [], "ticket": []}
|
|
||||||
kinds = {1: "root", 2: "authority", 3: "ticket"}
|
|
||||||
i = 4 if data[:4] == b"VCR1" else 0
|
|
||||||
while i + 3 <= len(data):
|
|
||||||
kind, length = data[i], struct.unpack(">H", data[i + 1:i + 3])[0]
|
|
||||||
if kind in kinds:
|
|
||||||
certs[kinds[kind]].append(data[i + 3:i + 3 + length])
|
|
||||||
i += 3 + length
|
|
||||||
return certs
|
|
||||||
|
|
||||||
|
|
||||||
def its_station(gn_common_onward: bytes):
|
|
||||||
"""StationID of the ITS PDU inside a secured GN packet's payload.
|
|
||||||
|
|
||||||
The signed payload is the GN packet from the Common Header on: Common Header (8), the extended
|
|
||||||
header of the Common Header's type, BTP-B (4), then the ITS PDU, whose header is
|
|
||||||
protocolVersion (1), messageID (1), stationID (4)."""
|
|
||||||
if len(gn_common_onward) < 8:
|
|
||||||
return None
|
|
||||||
ext = {5: 28, 4: 44}.get(gn_common_onward[1] >> 4) # HT: 5 TSB/SHB, 4 GBC
|
|
||||||
if ext is None:
|
|
||||||
return None
|
|
||||||
at = 8 + ext + 4
|
|
||||||
pdu = gn_common_onward[at:at + 6]
|
|
||||||
return int.from_bytes(pdu[2:6], "big") if len(pdu) == 6 else None
|
|
||||||
|
|
||||||
|
|
||||||
def hashed_id8(octets: bytes) -> bytes:
|
|
||||||
return hashlib.sha256(octets).digest()[-8:]
|
|
||||||
|
|
||||||
|
|
||||||
class Verifier:
|
|
||||||
def __init__(self, asn1_dir: Path):
|
|
||||||
import asn1tools
|
|
||||||
spec = asn1tools.compile_files([str(asn1_dir / "IEEE1609dot2.asn"),
|
|
||||||
str(asn1_dir / "IEEE1609dot2BaseTypes.asn")], "oer")
|
|
||||||
self.m = spec.modules["IEEE1609dot2"]
|
|
||||||
|
|
||||||
def public_key(self, cert_octets: bytes):
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
|
||||||
cert = self.m["Certificate"].decode(cert_octets)
|
|
||||||
kind, key = cert["toBeSigned"]["verifyKeyIndicator"]
|
|
||||||
if kind != "verificationKey" or key[0] != "ecdsaNistP256":
|
|
||||||
raise ValueError("not an ECDSA P-256 verification key: %r" % (key[0],))
|
|
||||||
form, point = key[1]
|
|
||||||
encoded = {"compressed-y-0": b"\x02" + point, "compressed-y-1": b"\x03" + point,
|
|
||||||
"uncompressedP256": b"\x04" + point.get("x", b"") + point.get("y", b"")
|
|
||||||
if isinstance(point, dict) else None}[form]
|
|
||||||
return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), encoded)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ecdsa_ok(public_key, message: bytes, signature) -> bool:
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
|
||||||
kind, sig = signature
|
|
||||||
if kind != "ecdsaNistP256Signature":
|
|
||||||
raise ValueError("unsupported signature %s" % kind)
|
|
||||||
r_kind, r = sig["rSig"]
|
|
||||||
r_x = r if isinstance(r, (bytes, bytearray)) else r["x"] # x-only / compressed: r is x
|
|
||||||
der = encode_dss_signature(int.from_bytes(r_x, "big"), int.from_bytes(sig["sSig"], "big"))
|
|
||||||
try:
|
|
||||||
public_key.verify(der, message, ec.ECDSA(hashes.SHA256()))
|
|
||||||
return True
|
|
||||||
except InvalidSignature:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def certificate_signed_by(self, cert_octets: bytes, issuer_octets: bytes | None) -> bool:
|
|
||||||
cert = self.m["Certificate"].decode(cert_octets)
|
|
||||||
tbs = self.m["ToBeSignedCertificate"].encode(cert["toBeSigned"])
|
|
||||||
signer_input = hashlib.sha256(issuer_octets if issuer_octets is not None else b"").digest()
|
|
||||||
key = self.public_key(issuer_octets if issuer_octets is not None else cert_octets)
|
|
||||||
return self._ecdsa_ok(key, hashlib.sha256(tbs).digest() + signer_input, cert["signature"])
|
|
||||||
|
|
||||||
def message(self, octets: bytes, known: dict[bytes, bytes]):
|
|
||||||
"""Decodes one Ieee1609Dot2Data; returns a result dict."""
|
|
||||||
data = self.m["Ieee1609Dot2Data"].decode(octets)
|
|
||||||
encoded = self.m["Ieee1609Dot2Data"].encode(data)
|
|
||||||
kind, signed = data["content"]
|
|
||||||
if kind != "signedData":
|
|
||||||
return {"kind": kind}
|
|
||||||
tbs = self.m["ToBeSignedData"].encode(signed["tbsData"])
|
|
||||||
header = signed["tbsData"]["headerInfo"]
|
|
||||||
signer_kind, signer = signed["signer"]
|
|
||||||
if signer_kind == "digest":
|
|
||||||
digest, cert_octets = bytes(signer), known.get(bytes(signer))
|
|
||||||
elif signer_kind == "certificate":
|
|
||||||
cert_octets = self.m["Certificate"].encode(signer[0])
|
|
||||||
digest = hashed_id8(cert_octets)
|
|
||||||
else:
|
|
||||||
return {"kind": "signedData", "signer": signer_kind}
|
|
||||||
result = {
|
|
||||||
"kind": "signedData",
|
|
||||||
"signer": signer_kind,
|
|
||||||
"digest": digest.hex().upper(),
|
|
||||||
"psid": header["psid"],
|
|
||||||
"generation_time_us": header.get("generationTime"),
|
|
||||||
# COER is canonical, so a re-encoding identical to the wire bytes means the slices
|
|
||||||
# hashed below are exactly what the sender signed.
|
|
||||||
"canonical": octets.startswith(encoded) and tbs in octets,
|
|
||||||
}
|
|
||||||
if cert_octets is None:
|
|
||||||
result["verified"] = None # unknown signer
|
|
||||||
return result
|
|
||||||
message = hashlib.sha256(tbs).digest() + hashlib.sha256(cert_octets).digest()
|
|
||||||
result["verified"] = self._ecdsa_ok(self.public_key(cert_octets), message, signed["signature"])
|
|
||||||
inner = signed["tbsData"]["payload"].get("data")
|
|
||||||
if inner and inner["content"][0] == "unsecuredData":
|
|
||||||
payload = bytes(inner["content"][1])
|
|
||||||
result["payload"] = payload
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
||||||
p.add_argument("pcap", type=Path)
|
|
||||||
p.add_argument("--bundle", type=Path, required=True, help="VCR1 credential bundle (demo-chain.vcr)")
|
|
||||||
p.add_argument("--asn1", type=Path, required=True, help="directory with IEEE1609dot2*.asn")
|
|
||||||
args = p.parse_args()
|
|
||||||
|
|
||||||
v = Verifier(args.asn1)
|
|
||||||
certs = read_bundle(args.bundle)
|
|
||||||
root, aa, at = certs["root"][0], certs["authority"][0], certs["ticket"][0]
|
|
||||||
print("bundle: root %s, AA %s, AT %s" % (hashed_id8(root).hex().upper(), hashed_id8(aa).hex().upper(),
|
|
||||||
hashed_id8(at).hex().upper()))
|
|
||||||
print("chain: root self-signed %s, AA by root %s, AT by AA %s" % (
|
|
||||||
v.certificate_signed_by(root, None), v.certificate_signed_by(aa, root), v.certificate_signed_by(at, aa)))
|
|
||||||
known = {hashed_id8(at): at}
|
|
||||||
|
|
||||||
tally = Counter()
|
|
||||||
ours = []
|
|
||||||
for ts, frame in pcap_frames(args.pcap):
|
|
||||||
found = secured_payload(frame)
|
|
||||||
if not found:
|
|
||||||
continue
|
|
||||||
mac, octets = found
|
|
||||||
try:
|
|
||||||
r = v.message(octets, known)
|
|
||||||
except Exception as e: # noqa: BLE001 - a malformed frame is a finding, not a crash
|
|
||||||
tally["undecodable"] += 1
|
|
||||||
continue
|
|
||||||
if r.get("verified") is None:
|
|
||||||
tally["signed by an unknown signer %s (psid %s)" % (r.get("digest"), r.get("psid"))] += 1
|
|
||||||
continue
|
|
||||||
tally["demo AT, signature %s" % ("VALID" if r["verified"] else "INVALID")] += 1
|
|
||||||
ours.append((ts, mac, r))
|
|
||||||
|
|
||||||
for line, n in sorted(tally.items()):
|
|
||||||
print("%5d %s" % (n, line))
|
|
||||||
# The V2X2MAP bridge stamps records with board uptime, not wall-clock time, so the signature's
|
|
||||||
# generationTime is compared with the file's modification time (end of the recording) instead.
|
|
||||||
recorded_until = args.pcap.stat().st_mtime
|
|
||||||
for ts, mac, r in ours[:5]:
|
|
||||||
gen = r["generation_time_us"] / 1e6 + ITS_EPOCH_UNIX if r["generation_time_us"] else None
|
|
||||||
print(" %s from %s: signer %s %s, psid %d, ITS PDU station %s, generationTime %s UTC "
|
|
||||||
"(%+.0f s before the recording ended), canonical %s, signature %s" % (
|
|
||||||
f"{ts:.3f}", mac.hex(":"), r["signer"], r["digest"], r["psid"], its_station(r.get("payload", b"")),
|
|
||||||
datetime.fromtimestamp(gen, timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] if gen else "-",
|
|
||||||
(recorded_until - gen) if gen else float("nan"), r["canonical"],
|
|
||||||
"VALID" if r["verified"] else "INVALID"))
|
|
||||||
return 0 if ours and all(r["verified"] for _, _, r in ours) else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
Reference in New Issue
Block a user