Compare commits

..
Author SHA1 Message Date
Ashin Walpola 2f60623e18 Document the signed-ITS/VAM/BLE work and how it was verified
docs/06-signed-its-vam-ble.md: who does what between phone and ESP32-C5
(signing lives on the board), the link protocol, recovery paths (USB
heartbeat watchdog, BLE supervision timeout and auto-reconnect, board-reset
reconfiguration, app restart), the demo PKI, and what is still open.

obu-firmware/test/verify_signed_pcap.py checks the IEEE 1609.2 signatures in
a pcap with asn1tools and OpenSSL, independent of the firmware. On a capture
of the CAM pinger (2026-09-23) all 12 signed CAMs verify under the demo
ticket, whose chain verifies too. The CiT One receives the same CAMs but its
MQTT interface exposes no security information, so it cannot confirm the
signature itself. The V2X2MAP bridge on COM10 now verifies against the demo
chain as well (change in the colleague's repository); signed CAMs and VAMs
show as verified.

TODO.md: bench checks confirmed so far ticked; open are BLE/ITS-G5
coexistence, time_regression over a longer stationary run, and board reset
recovery over BLE.
2026-09-23 17:28:14 +02:00
Ashin Walpola a08494b56a Drive the ESP32-C5 station over USB or BLE, send CAM or VAM, signed or not
The app now speaks the station-link protocol of the new obu-firmware.
Esp32Link picks the transport from Settings (UsbSerialTransport or the new
BleLinkTransport), tells the previous firmware from the new one by its
heartbeat, and runs the session: STATION_CONFIGURE with the current pseudonym
MAC (which also starts the board's radio), CREDENTIALS_PROVISION of the
bundled demo chain when the board has no ticket, then per message a
POTI_UPDATE and a BTP_DATA_REQUEST. Received messages still arrive as
V2X_RX frames, so the receive side is unchanged. A board on the previous
firmware keeps working for CAM over USB.

Settings > Connection > ESP32-C5: link USB-C or Bluetooth, transmit CAM or
VAM, "Sign outgoing messages" (on by default). The connection card, top bar
and dashboard show the link in use, the pairing passkey and signing counters.

- VAM: VamUperCodec (TS 103 300-3 V2.3.1, bytes checked against asn1tools)
  and VamGenerationRules (clause 6.4, Tables 16/17).
- BLE: the firmware's GATT layout (service 0000C175-...), MTU 517, pairing
  and encryption settled before any other operation (short timeouts during
  pairing made it loop), backoff between attempts, reasons on the card.
- Clock: a PoTi goes to the board once per new fix and never moves the
  board's clock backwards except for a real correction (>= 60 s); stale and
  wobbling fix times made the board answer time_regression and restart its
  stack every few seconds. GnssTimeSource keeps the last measured phone-clock
  error while GNSS time drops out indoors: the bench phone is 14 minutes fast,
  and falling back to it made every transmitted timestamp jump by that much.
- assets/demo-chain.vcr: throwaway, not EU-registered demo chain generated
  2026-09-23 (AT B80B49387A4C12EB, psid 36 and 638). Its private key ships
  with the app on purpose; receivers verifying against the EU trust list
  drop what it signs.
- Bluetooth permissions requested at start-up on Android 12+.

StationLinkTest pins the codec to bytes from the colleague's Python
implementation (microbu_link/messages.py). 103 unit tests pass.
2026-09-23 17:28:05 +02:00
Ashin Walpola d2fd222a62 Sign ITS messages on the ESP32-C5 with vanetza-idf, over USB or BLE
obu-firmware is now a port of the colleague's standalone VRU station
(microbu-esp32c5/firmware, kept beside this repository and gitignored): the
vanetza-idf C-ITS stack with the TS 103 097 security entity, credentials in
NVS, the station-link v1 protocol over the native USB port (frame type 0x10
in the existing 0xAA55 framing) and over a BLE GATT peripheral, and its
ITS-G5 radio adapter. The phone still builds CAM and VAM; the board adds
GeoNetworking/BTP and signs with the provisioned authorization ticket. The
private key never leaves the board. Builds with ESP-IDF 6.0.2 only, which
vanetza-idf pins for the radio's private driver ABI. The previous C firmware
stays on disk unbuilt; a full-flash backup of the bench board is kept in
firmware-backups/ (gitignored).

Changed against the colleague's firmware, marked MicrOBU: in the sources:
- Reception unchanged for the app. vanetza-idf drops what it cannot verify
  (unsigned traffic, every RSU), so each captured frame also goes through the
  previous gn_unwrap.c and reaches the phone as link opcode V2X_RX (0x85),
  whose body is the old SERIAL_MSG_V2X_RX payload.
- Unsigned transmission still possible, with the previous geonet.c header;
  the phone chooses per message.
- Console on UART0 (CH343 port); the native USB port carries only link frames.
- BLE advertising pauses while the USB link is in use: BLE and ITS-G5 share
  one RF front end.
- NVS 80 KB (app at 0x20000). At 24 KB, with Wi-Fi settings the previous
  firmware left behind, the BLE bond could not be stored and the phone had to
  pair on every connection.
- Bench fixes: the radio queue is drained before the first PoTi (no RX and
  ~177 queue drops before); the station loop waited pdMS_TO_TICKS(5) = 0
  ticks at 100 Hz and starved the idle task; the 2.4 KB RX capture buffer is
  off the Wi-Fi task stack; BLE notifications longer than the MTU are dropped
  instead of cut short, MTU 517; serial writes are skipped with no USB host.
- Manual country policy and TX-power read-back from the previous radio setup;
  logs for BLE encryption changes and the number of stored bonds.

Verified on the bench board (COM3) with the phone over USB and BLE: CAM and
VAM, signed and unsigned, go out; reception of the sim car and the RSU's
CAM/SPATEM/MAPEM continues; the board survives app restarts and reconnects.
See docs/06-signed-its-vam-ble.md.
2026-09-23 17:27:54 +02:00
Ashin Walpola 7285fa19b7 Count and surface RX-queue drops on the ESP32-C5's promiscuous path
wifi_promisc_rx_cb() fed s_rx_queue with a 0-timeout xQueueSend() and never
checked whether it succeeded, so a burst of captured frames arriving faster
than rx_forward_task could drain them vanished with no counter anywhere -
none of oversizeDrops/txFailures/rxCrcErrors caught it. Added a rxQueueDrops
counter, threaded it through the STATUS heartbeat as a new trailing uint16
(old firmware/app on either side still parse fine), and surfaced it on the
CAM Pinger card.

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

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

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

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

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

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

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

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

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

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

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

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

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

Compiles and the unit tests pass. None of it has been seen with live
traffic; TODO.md lists the on-device checks under "Waiting on hardware",
including which of the HAW RSUs send CAM alongside SPATEM.
2026-09-15 17:23:01 +02:00
88 changed files with 14940 additions and 1879 deletions
+12 -2
View File
@@ -47,5 +47,15 @@ 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.
~$* ~$*
# Captures are large data files, not source (see capture/README.md). # Colleague's standalone ESP32-C5 VRU station (signed ITS via Vanetza). It is its
/capture/recordings/ # own git repository with its own history; kept beside the project as a reference
# 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/
+281 -41
View File
@@ -5,44 +5,249 @@ 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. Confirmed on air 2026-09-14. Nothing else compiled `geonet_wrap_shb` stores the new byte, but it has not been seen on air yet. Nothing else
reads this byte (`gn_unwrap.c` ignores it, the app never sees GN headers), so the app does not 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.
- [x] Flash `obu-firmware` (done 2026-09-14 on COM3; flash backed up first to - [ ] Flash `obu-firmware` (see `obu-firmware/FLASHING.md`).
`Documents/micrOBU_workspace/firmware-backups/COM3-2026-09-14-before-secured-rx.bin`). - [ ] Connect the phone, let it send CAMs, and confirm the CAM Pinger's `tx fail` counter stays 0.
- [x] Capture with the receiver (COM8) into `its-g5-receiver-firmware/recordings/`. - [ ] Capture with the receiver into `its-g5-receiver-firmware/recordings/`.
- [x] `pcap_gn_tally.py` on capture_20260914_132126.pcap: our station sends SHB, port 2001, - [ ] Run `python obu-firmware/test/pcap_gn_tally.py its-g5-receiver-firmware/recordings/<capture>.pcap`.
lifetime `0x05`, same as both bench stations. It was `0x83` in the August captures. The rows for the phone's pseudonym MACs must show SHB, port 2001, lifetime `0x05`, exactly
- [x] Real-station CAMs/DENMs/SPATEM still reach the app (logcat: `handleCamUper`, like every other station's CAMs.
`handleDenmUper`, `handleSpatUper` all decoding, 2026-09-14). - [ ] While the phone is connected: real-station CAMs/DENMs still reach the app (RX path unchanged).
- [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. Since 2026-09-14 the spare board on COM10 runs it as a bench beacon: IDF 5.5.4. No board runs this firmware right now (the production OBU runs obu-firmware), so this
only matters if it is flashed again:
- [x] Done 2026-09-14: flashed on COM10 and captured on COM8. All 72 CAMs from station - [ ] After flashing it: capture, run `pcap_gn_tally.py`, and decode the CAM payload with
195936478 (0x0BADC0DE) decode with asn1tools and re-encode byte-identically, so the asn1tools (`py -3.11`, modules in `asn1/`).
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)
@@ -50,21 +255,69 @@ obu-firmware's `gn_unwrap.c` now unwraps TS 103 097 signed packets (signature no
reported as V2X_RX flags bit1) and cuts every message to the length its header declares, dropping 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 flashed to the production OBU on 2026-09-14. The remaining gap is signed 50M-iteration fuzz) and built on IDF 6.1, but not flashed: the production OBU still runs the
traffic to receive: real vehicles or 2026-09-10 build. Needs the OBU with this build, the phone, and signed traffic - real vehicles or
RSUs, since the bench CiT One sends unsigned. A second ESP32 running the receiver firmware is 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.
- [x] Flash obu-firmware (done 2026-09-14, COM3). - [ ] Flash obu-firmware (this also carries the GN lifetime fix above).
- [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`). NOT possible at this bench: the them on air (`pcap_gn_tally.py` lists them as `secured`).
CiT One transmits unsigned (`ItsGnSecurity = 0`) and nothing else here signs. Needs a drive - [ ] Unsigned bench traffic still decodes in the app as before (messages now arrive 8 bytes
past real RSUs, the CiT One switched to signed mode if its API allows, or a replay firmware shorter).
on a spare board that re-transmits the recorded signed frames. - [ ] The heartbeat's oversize counter still counts over-long messages (e.g. road SPATEMs).
- [ ] The heartbeat's oversize counter still counts over-long messages. Not exercised at the
bench: the SPATEMs here are ~340 bytes on air, far below the cap. ### CiT One custom CAM injection over `v2x/tx/v2/cam` (added 2026-09-14)
The haw-002 unit now runs the special firmware: Cohda's own CAM transmission disabled, and a
V2X-Gateway build that accepts a `SendV2XMessage` (schemas.consider-innovation.de/its-s/
v2x_interface.proto) carrying a UPER CAM on `v2x/tx/v2/cam`. `tools/cit_one_cam_tx.py` builds
and publishes those from a PC; its `--self-test` passes offline, proving only that the bytes
match `CamEncodeGoldenTest.kt` and that the protobuf wrapper round-trips. Nothing about what
the OBU does with them is established.
Reach the broker over Wi-Fi or Ethernet for now - the USB-peripheral-mode link needs the phone
to be USB host on a `172.25.1.0/24` interface with no DHCP server, which Android cannot
configure from inside an app.
Needs: the CiT One haw-002 on the same network as a PC, and a second ESP32-C5 running
`its-g5-receiver-firmware` sniffing G5CC (`-c 5900`) to capture with.
Bench run 2026-09-14, PC -> haw-002 (192.168.3.201), captured on the RSU (192.168.3.202,
**not** .2.202 - that address does not route). `tools/cit_one_rx_watch.py` decodes what a unit
hears. Result: the injection path works end to end, with one blocker found.
- [x] Publishes without the broker refusing the topic. 1.00 Hz, confirmed by subscribing to
`v2x/tx/v2/cam` on the OBU itself.
- [x] The RSU hears our CAMs on air, 1.00 Hz, matching what we publish.
- [x] `ItsPduHeader` **is** expected in the payload - we send it included and it decodes.
- [x] BTP destination port 2001. GN source address `08:00:26:93:92:01:91:dc`, the OBU's.
- [x] **Our CAM content goes out intact**: position, speed (417), heading (639), width (7) and
length (18) arrive byte-exact. The gateway does not touch the content.
- [x] **The gateway overwrites `stationID`** with the OBU's own (999999 -> 4033890855, which
matches `own_info.stationID` on `v2x/rx/obu_gnss`). This is what the "OBU owns identity"
decision wants, so `--follow-obu-identity` is not needed on this unit.
- [x] ~~BLOCKER: Cohda's own CAM is still transmitting.~~ Fixed 2026-09-14 by disabling CAM in
a second conf file: the RSU now hears only our stream, 0 CAMs with the stack's
unavailable dimensions over 30 s. Note the stack restart gave the unit a new identity
(stationID 4033890855 -> 2553426533, GN source `08:00:26:...` -> `08:00:a2:...`), which is
expected under `ItsGnLocalAddrConfMethod = 2` (anonymous, random at boot).
- [x] Re-checked: 41 published / 41 heard over 40 s, 1.02 Hz both ends, inter-arrival a steady
1.0 s. 100% delivery, no gateway rate limiting. An earlier 0.40 Hz sample was the stack
still settling after the restart and did not persist.
Two topics the v6 API does not document, found by subscribing to `#` on haw-002:
- `v2x/loopback/cam` - a `RecvV2XMessage` (btpHeader.type=2) carrying each CAM the unit
transmits, 1:1 with what we publish and **after** the gateway's stationID rewrite. This is the
TX confirmation we were going to ask consider it for: it makes "did my CAM go out, and under
which identity" answerable on the transmitting unit alone, without an RSU or a second ESP32.
- `v2x/rx/obuinfo` at 10 Hz - the protobuf `OwnStationInfo` (binary twin of `obu_gnss`;
field 2 decodes to the same stationID, field 10 to the same heading). Output only, so it is
not the content-feed input we speculated about.
- [ ] Wire `v2x/loopback/cam` into `cit_one_rx_watch.py` as a local TX check.
- [ ] Sanity-check the rate: `--rate 4` should produce 4 CAMs/s on air, since `ItsDCCEnabled = 0`
on this unit.
## Set up host testing ## Set up host testing
@@ -101,19 +354,6 @@ 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
+11 -1
View File
@@ -1,5 +1,6 @@
<?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" />
@@ -10,6 +11,15 @@
<!-- 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,10 +26,12 @@ 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.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.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
@@ -40,6 +42,7 @@ import com.hawhamburg.micr0bu.ui.screens.MqttTopicViewerScreen
import com.hawhamburg.micr0bu.ui.screens.RecordingScreen import com.hawhamburg.micr0bu.ui.screens.RecordingScreen
import com.hawhamburg.micr0bu.ui.screens.SensorScreen import com.hawhamburg.micr0bu.ui.screens.SensorScreen
import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen import com.hawhamburg.micr0bu.ui.screens.SessionLogScreen
import com.hawhamburg.micr0bu.ui.screens.V2xMapScreen
import com.hawhamburg.micr0bu.ui.screens.MapScreen import com.hawhamburg.micr0bu.ui.screens.MapScreen
import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen import com.hawhamburg.micr0bu.ui.screens.TripHistoryScreen
import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen import com.hawhamburg.micr0bu.ui.screens.TripReviewScreen
@@ -86,7 +89,10 @@ 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 usbSerialState by mqttViewModel.usbSerialState.collectAsState() val esp32LinkState by mqttViewModel.esp32LinkState.collectAsState()
val esp32Transport by mqttViewModel.esp32Transport.collectAsState()
val outgoingMessage by mqttViewModel.outgoingMessage.collectAsState()
val signOutgoing by mqttViewModel.signOutgoing.collectAsState()
// Received hazards and live signal state, for the Dashboard's V2X summary cards. // 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.
@@ -104,6 +110,13 @@ class MainActivity : AppCompatActivity() {
} }
val navController = rememberNavController() val navController = rememberNavController()
// The V2X live map is a full-bleed destination: the app's own chrome would eat a
// third of the display on the one screen whose entire job is showing where things
// are relative to each other. It carries its own floating back button, and system
// back still works, so nothing becomes unreachable.
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val isFullBleed = currentBackStackEntry?.destination?.route == Screen.V2xMap.route
val locationLauncher = rememberLauncherForActivityResult( val locationLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
) { permissions -> ) { permissions ->
@@ -119,11 +132,17 @@ 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)
@@ -132,14 +151,17 @@ class MainActivity : AppCompatActivity() {
Scaffold( Scaffold(
topBar = { topBar = {
if (!isFullBleed) {
StatusTopBar( StatusTopBar(
state = state, state = state,
mqttConnectionState = mqttConnectionState, mqttConnectionState = mqttConnectionState,
isEsp32 = obuHardware == ObuHardware.ESP32_C5, isEsp32 = obuHardware == ObuHardware.ESP32_C5,
usbSerialState = usbSerialState, esp32LinkState = esp32LinkState,
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
) )
}
}, },
bottomBar = { BottomNavBar(navController) }, bottomBar = { if (!isFullBleed) BottomNavBar(navController) },
) { innerPadding -> ) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
@@ -152,7 +174,8 @@ class MainActivity : AppCompatActivity() {
mqttConnectionState = mqttConnectionState, mqttConnectionState = mqttConnectionState,
activeTransport = activeTransport, activeTransport = activeTransport,
obuHardware = obuHardware, obuHardware = obuHardware,
usbSerialState = usbSerialState, esp32LinkState = esp32LinkState,
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
usbCableConnected = usbConnected, usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning, obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType, obuStationType = obuStationType,
@@ -191,7 +214,7 @@ class MainActivity : AppCompatActivity() {
state = state, state = state,
mqttConnectionState = mqttConnectionState, mqttConnectionState = mqttConnectionState,
obuConnected = if (obuHardware == ObuHardware.ESP32_C5) obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
usbSerialState == UsbSerialState.CONNECTED esp32LinkState == Esp32LinkState.CONNECTED
else else
mqttConnectionState == MqttConnectionState.CONNECTED, mqttConnectionState == MqttConnectionState.CONNECTED,
tripServiceState = tripServiceState, tripServiceState = tripServiceState,
@@ -253,7 +276,19 @@ class MainActivity : AppCompatActivity() {
} }
composable(Screen.MqttViewer.route) { composable(Screen.MqttViewer.route) {
MqttTopicViewerScreen(viewModel = mqttViewModel) MqttTopicViewerScreen(
viewModel = mqttViewModel,
onOpenMap = { navController.navigate(Screen.V2xMap.route) },
)
}
composable(Screen.V2xMap.route) {
// Activity-scoped instance, like Connection below: a hiltViewModel()
// here would be scoped to this NavBackStackEntry and torn down on the
// way back out, taking the shared transport with it.
V2xMapScreen(
viewModel = mqttViewModel,
onBack = { navController.popBackStack() },
)
} }
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
SettingsScreen( SettingsScreen(
@@ -281,6 +316,12 @@ 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,7 +21,13 @@ 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,
* typically indoors before any satellite fix since boot, the wall clock is used unchanged. * the last error measured is kept, because the wall clock's error changes slowly while GNSS time
* comes and goes indoors. Only before any GNSS time since the app started is the wall clock used
* unchanged.
*
* Keeping it matters: with the bench phone 14 minutes fast (2026-09-23), dropping back to the raw
* wall clock whenever GNSS time blinked out made every transmitted timestamp jump 14 minutes back
* and forth, and the micrOBU restarted its stack at each jump back (time_regression).
* *
* Which clock is in use is logged whenever it changes, with the measured error, so a capture shows * 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.
@@ -36,6 +42,9 @@ 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()
@@ -44,8 +53,9 @@ 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, gnssNow, systemNow) return ItsTime.onGnssTime(systemMs, lastErrorMs?.let { systemNow + it }, systemNow)
} }
private fun noteSource(gnssNow: Long?, systemNow: Long) { private fun noteSource(gnssNow: Long?, systemNow: Long) {
@@ -55,6 +65,9 @@ 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.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport import com.hawhamburg.micr0bu.data.transport.Esp32Link
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 usbSerialTransport: UsbSerialTransport, private val esp32Link: Esp32Link,
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 {
usbSerialTransport.incomingFrames.collect { frame -> esp32Link.incomingFrames.collect { frame ->
if (frame.type != SerialFrameType.V2X_RX) return@collect if (frame.type != SerialFrameType.V2X_RX) return@collect
if (usbSerialTransport.state.value != UsbSerialState.CONNECTED) return@collect if (esp32Link.state.value != Esp32LinkState.CONNECTED) return@collect
val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect 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 {
usbSerialTransport.state.collect { state -> esp32Link.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 != UsbSerialState.CONNECTED) { if (currentHardware == ObuHardware.ESP32_C5 && state != Esp32LinkState.CONNECTED) {
engine.reset() engine.reset()
_rsuStations.value = emptyMap() _rsuStations.value = emptyMap()
} }
@@ -1,11 +1,14 @@
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
@@ -32,6 +35,10 @@ 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 ->
@@ -42,6 +49,37 @@ 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.
* *
@@ -0,0 +1,466 @@
package com.hawhamburg.micr0bu.data.transport
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "BleLinkTransport"
/**
* BLE GATT central for the micrOBU's station link: the Android counterpart of the firmware's
* `obu-firmware/main/simple_ble.cpp` (from the colleague's microbu-esp32c5) and of their Python
* `microbu_link/ble_transport.py`, which this follows step for step.
*
* ## The GATT layout (what the firmware actually implements)
* The station-link README describes a Nordic-UART-shaped service with fragmentation. The firmware
* does something else, and the firmware is what counts here: a custom service
* `0000C175-BA5E-4C17-8000-00805F9B34FB` with one characteristic per primitive, and each GATT value
* is one complete link message, never fragmented. Requests are written with response to the
* characteristic of their opcode ([writeTarget]); replies, STATUS and V2X_RX arrive as
* notifications. A message may be up to 512 octets, so the ATT MTU must be raised to 517 first:
* the firmware refuses to notify a message that does not fit rather than send it cut short.
*
* ## Pairing
* Every characteristic needs an encrypted, authenticated link. The firmware uses LE Secure
* Connections with a fixed passkey, [PASSKEY] (DisplayOnly). Android shows its own pairing dialog
* the first time; the user types the passkey there, and the bond is kept on both sides. The
* passkey is public, so this gives encryption but no protection against an active attacker
* during that first pairing; acceptable for the demo PKI this carries.
*
* ## RF
* BLE shares the C5's single RF front end with 5.9 GHz ITS-G5. The firmware stops advertising
* while the USB link is in use; whether an active BLE connection disturbs ITS-G5 has not been
* measured yet (TODO.md, "Waiting on hardware").
*
* Like [UsbSerialTransport], an app-scoped singleton: only an explicit disconnect or the process
* dying closes it, never a screen or ViewModel going away.
*/
@Singleton
class BleLinkTransport @Inject constructor(
@ApplicationContext private val context: Context,
) {
companion object {
const val PASSKEY = "123456"
const val NAME_PREFIX = "micrOBU"
private val SERVICE: UUID = UUID.fromString("0000c175-ba5e-4c17-8000-00805f9b34fb")
private val BTP_REQUEST: UUID = UUID.fromString("0000c176-ba5e-4c17-8000-00805f9b34fb")
private val BTP_INDICATION: UUID = UUID.fromString("0000c177-ba5e-4c17-8000-00805f9b34fb")
private val POTI: UUID = UUID.fromString("0000c178-ba5e-4c17-8000-00805f9b34fb")
/** Read-encrypted, returns nothing useful: only used to find out whether the link is secure. */
private val STATUS_CHAR: UUID = UUID.fromString("0000c179-ba5e-4c17-8000-00805f9b34fb")
private val ID_EVENT: UUID = UUID.fromString("0000c17a-ba5e-4c17-8000-00805f9b34fb")
private val CONFIG: UUID = UUID.fromString("0000c17b-ba5e-4c17-8000-00805f9b34fb")
private val RESULT: UUID = UUID.fromString("0000c17c-ba5e-4c17-8000-00805f9b34fb")
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val REQUESTED_MTU = 517
private const val SCAN_TIMEOUT_MS = 15_000L
/** Long enough for the user to find and type the passkey in the system dialog. */
private const val BOND_TIMEOUT_MS = 60_000L
private const val GATT_OP_TIMEOUT_MS = 5_000L
/** After a working link dropped: try again soon. */
private const val RECONNECT_DELAY_MS = 1_000L
/** After failed attempts: 2, 4, 8, 16, then every 30 s, so a broken pairing does not spin. */
private const val RETRY_BASE_MS = 2_000L
private const val RETRY_MAX_MS = 30_000L
/** ATT status codes Android reports when the link lacks the encryption a characteristic needs. */
private val AUTH_FAILURES = setOf(5, 8, 15, 137)
/** Which characteristic a phone -> micrOBU message is written to, by opcode (as ble_transport.py). */
fun writeTarget(opcode: Int): UUID = when (opcode) {
LinkOpcode.BTP_DATA_REQUEST -> BTP_REQUEST
LinkOpcode.POTI_UPDATE -> POTI
0x08 /* SF_IDCHANGE_EVENT_RESPONSE */ -> ID_EVENT
else -> CONFIG
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
val state: StateFlow<Esp32LinkState> = _state.asStateFlow()
/** Why the last attempt failed, or what the user has to do (e.g. type the passkey); null when fine. */
private val _detail = MutableStateFlow<String?>(null)
val detail: StateFlow<String?> = _detail.asStateFlow()
private val _incoming = MutableSharedFlow<ByteArray>(extraBufferCapacity = 256)
/** Every link message the micrOBU notifies, one GATT value each. */
val incoming: SharedFlow<ByteArray> = _incoming.asSharedFlow()
/** Name of the connected micrOBU, e.g. "micrOBU-4AF8". */
@Volatile var deviceName: String? = null
private set
@Volatile private var gatt: BluetoothGatt? = null
/** Whether the current GATT connection is up, as the last connection-state callback said. */
@Volatile private var linkUp = false
@Volatile private var wanted = false
private var sessionJob: Job? = null
/** One GATT operation at a time: Android drops a second one issued before the first completes. */
private val gattMutex = Mutex()
@Volatile private var pendingOp: CompletableDeferred<Int>? = null
@Volatile private var connected: CompletableDeferred<Boolean>? = null
@Volatile private var mtuDone: CompletableDeferred<Int>? = null
@Volatile private var servicesDone: CompletableDeferred<Boolean>? = null
fun hasPermissions(): Boolean {
val needed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
return needed.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
}
/** Scans for (or reuses the bond with) a micrOBU, pairs if needed, and opens the link. No-op if already under way. */
fun connect() {
if (sessionJob?.isActive == true) return
wanted = true
sessionJob = scope.launch {
var failures = 0
while (wanted) {
val ok = try {
session()
} catch (e: CancellationException) {
throw e // disconnect(): not a failure to report
} catch (e: Exception) {
fail("BLE session failed: ${e.message}")
}
closeGatt()
if (!wanted) break
failures = if (ok) 0 else failures + 1
_state.value = if (ok) Esp32LinkState.DEVICE_ATTACHED else Esp32LinkState.ERROR
delay(if (ok) RECONNECT_DELAY_MS
else minOf(RETRY_MAX_MS, RETRY_BASE_MS shl (failures - 1).coerceAtMost(4)))
}
_state.value = Esp32LinkState.DISCONNECTED
}
}
fun disconnect() {
wanted = false
sessionJob?.cancel()
sessionJob = null
closeGatt()
_state.value = Esp32LinkState.DISCONNECTED
_detail.value = null
}
/**
* Writes one complete link message to the characteristic of its opcode, with response.
* Suspends until the micrOBU acknowledged the write; false when not connected or it failed.
*/
@SuppressLint("MissingPermission")
suspend fun send(message: ByteArray): Boolean {
val g = gatt ?: return false
if (_state.value != Esp32LinkState.CONNECTED || message.isEmpty()) return false
val characteristic = g.getService(SERVICE)?.getCharacteristic(writeTarget(message[0].toInt() and 0xFF))
?: return false
return gattOp {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeCharacteristic(characteristic, message, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) ==
BluetoothGatt.GATT_SUCCESS
} else {
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
characteristic.value = message
@Suppress("DEPRECATION")
g.writeCharacteristic(characteristic)
}
} == BluetoothGatt.GATT_SUCCESS
}
/** One connection lifetime. Returns true if it reached CONNECTED before ending. */
@SuppressLint("MissingPermission")
private suspend fun session(): Boolean {
if (!hasPermissions()) {
wanted = false
return fail("Bluetooth permission not granted (Android Settings > Apps > MicrOBU > Permissions)")
}
val adapter = bluetoothManager?.adapter
if (adapter == null || !adapter.isEnabled) return fail("Bluetooth is off")
_state.value = Esp32LinkState.DEVICE_ATTACHED
// A bonded micrOBU is reused without scanning: its address is stable (public address), and
// this is what makes a reconnect after a dropout fast.
val device = adapter.bondedDevices.firstOrNull { it.name?.startsWith(NAME_PREFIX) == true }
?: scan() ?: return fail("No micrOBU advertising nearby (is the phone on its USB port?)")
deviceName = device.name
_detail.value = null
Log.i(TAG, "connecting to ${device.name} ${device.address} (bond state ${device.bondState})")
connected = CompletableDeferred()
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
if (withTimeoutOrNull(GATT_OP_TIMEOUT_MS * 2) { connected!!.await() } != true) {
return fail("Could not connect to ${device.name}")
}
val g = gatt ?: return false
// Services first: discovery needs no encryption, and the encryption probe below needs the
// STATUS characteristic.
servicesDone = CompletableDeferred()
g.discoverServices()
if (withTimeoutOrNull(GATT_OP_TIMEOUT_MS) { servicesDone!!.await() } != true) {
return fail("Service discovery on ${device.name} timed out")
}
if (g.getService(SERVICE) == null) {
return fail("${device.name} does not offer the station-link service (old firmware?)")
}
// Encryption before anything else. Every characteristic needs an encrypted, authenticated
// link; the board asks for security as soon as a phone connects. A phone it has a bond with
// encrypts with the stored key. Otherwise Android pairs, with its passkey dialog, which takes
// as long as the user takes. A GATT operation with a short timeout during that cuts the
// pairing off, the link drops, and the next attempt starts pairing again: a loop.
val wasBonded = device.bondState == BluetoothDevice.BOND_BONDED
if (!awaitEncryption(g, device)) {
if (!linkUp) return fail("${device.name} dropped the link while pairing")
return fail(
if (wasBonded) "${device.name} refused this phone's stored pairing. In Android's Bluetooth " +
"settings, forget ${device.name}, then Connect again (passkey $PASSKEY)."
else "Pairing with ${device.name} failed or timed out (passkey $PASSKEY)"
)
}
_state.value = Esp32LinkState.DEVICE_ATTACHED
_detail.value = null
mtuDone = CompletableDeferred()
g.requestMtu(REQUESTED_MTU)
val mtu = withTimeoutOrNull(GATT_OP_TIMEOUT_MS) { mtuDone!!.await() } ?: 23
Log.i(TAG, "ATT MTU $mtu")
if (mtu < LINK_MAX_MESSAGE + 3) {
// The board drops a notification that does not fit rather than truncate it (simple_ble.cpp).
Log.w(TAG, "MTU $mtu is below ${LINK_MAX_MESSAGE + 3}: large V2X_RX messages will not arrive")
}
for (uuid in listOf(RESULT, BTP_INDICATION, ID_EVENT)) {
if (!enableNotifications(g, uuid)) return fail("Could not subscribe to ${device.name} notifications")
}
_state.value = Esp32LinkState.CONNECTED
Log.i(TAG, "BLE station link ready: ${device.name}")
// Wait until the link drops (callback completes `connected` anew with false).
val dropped = CompletableDeferred<Boolean>()
connected = dropped
dropped.await()
Log.w(TAG, "BLE link to ${device.name} lost")
return true
}
@SuppressLint("MissingPermission")
private suspend fun scan(): BluetoothDevice? {
val scanner = bluetoothManager?.adapter?.bluetoothLeScanner ?: return null
val found = CompletableDeferred<BluetoothDevice>()
val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val name = result.scanRecord?.deviceName ?: result.device.name
val offersService = result.scanRecord?.serviceUuids?.any { it.uuid == SERVICE } == true
if (offersService || name?.startsWith(NAME_PREFIX) == true) found.complete(result.device)
}
override fun onScanFailed(errorCode: Int) {
Log.w(TAG, "BLE scan failed: $errorCode")
}
}
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
scanner.startScan(null, settings, scanCallback)
return try {
withTimeoutOrNull(SCAN_TIMEOUT_MS) { found.await() }
} finally {
runCatching { scanner.stopScan(scanCallback) }
}
}
/**
* Returns once the link is encrypted, pairing first if needed; false if that fails or the user
* does not finish within [BOND_TIMEOUT_MS].
*
* The probe is a read of the STATUS characteristic, which needs an encrypted and authenticated
* link, as the colleague's Python transport does. Android answers a read the link is not
* secure enough for by encrypting, or by pairing and showing the passkey dialog, and then
* retries the read itself. While it pairs, the card says which passkey to type.
*/
@SuppressLint("MissingPermission")
private suspend fun awaitEncryption(g: BluetoothGatt, device: BluetoothDevice): Boolean {
val status = g.getService(SERVICE)?.getCharacteristic(STATUS_CHAR) ?: return false
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val changed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else {
@Suppress("DEPRECATION") intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
if (changed?.address != device.address) return
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR)
Log.i(TAG, "bond state of ${device.name}: $state")
if (state == BluetoothDevice.BOND_BONDING) {
_state.value = Esp32LinkState.PERMISSION_REQUESTED
_detail.value = "Pair with ${device.name}: enter passkey $PASSKEY"
}
}
}
register(receiver)
try {
if (device.bondState == BluetoothDevice.BOND_BONDING) {
_state.value = Esp32LinkState.PERMISSION_REQUESTED
_detail.value = "Pair with ${device.name}: enter passkey $PASSKEY"
}
// Up to two reads: the first can come back with an authentication error at the moment
// pairing completes, before Android's own retry.
repeat(2) { attempt ->
val result = gattOp(BOND_TIMEOUT_MS) { g.readCharacteristic(status) }
Log.i(TAG, "encryption probe ${attempt + 1}: status $result, bond state ${device.bondState}")
if (result == BluetoothGatt.GATT_SUCCESS) return true
if (result !in AUTH_FAILURES) return false
}
return false
} finally {
runCatching { context.unregisterReceiver(receiver) }
}
}
private fun register(receiver: BroadcastReceiver) {
val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
}
}
/** Logs [reason], shows it on the connection card, and ends the attempt. */
private fun fail(reason: String): Boolean {
Log.w(TAG, reason)
_detail.value = reason
return false
}
@SuppressLint("MissingPermission")
private suspend fun enableNotifications(g: BluetoothGatt, uuid: UUID): Boolean {
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid) ?: return false
if (!g.setCharacteristicNotification(characteristic, true)) return false
val cccd = characteristic.getDescriptor(CCCD) ?: return false
val value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
return gattOp {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
g.writeDescriptor(cccd, value) == BluetoothGatt.GATT_SUCCESS
} else {
@Suppress("DEPRECATION") cccd.value = value
@Suppress("DEPRECATION") g.writeDescriptor(cccd)
}
} == BluetoothGatt.GATT_SUCCESS
}
/** Starts one GATT operation and waits for its callback's status; -1 if it could not start or timed out. */
private suspend fun gattOp(timeoutMs: Long = GATT_OP_TIMEOUT_MS, start: () -> Boolean): Int = gattMutex.withLock {
val op = CompletableDeferred<Int>()
pendingOp = op
if (!start()) {
pendingOp = null
return@withLock -1
}
withTimeoutOrNull(timeoutMs) { op.await() } ?: -1
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
gatt?.let { runCatching { it.disconnect(); it.close() } }
gatt = null
pendingOp?.complete(-1)
connected?.complete(false)
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
Log.i(TAG, "connection state $newState (status $status)")
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
linkUp = true
connected?.complete(true)
}
BluetoothProfile.STATE_DISCONNECTED -> {
linkUp = false
connected?.complete(false)
pendingOp?.complete(-1)
if (_state.value == Esp32LinkState.CONNECTED) _state.value = Esp32LinkState.ERROR
}
}
}
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
mtuDone?.complete(mtu)
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
servicesDone?.complete(status == BluetoothGatt.GATT_SUCCESS)
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
pendingOp?.complete(status)
}
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
pendingOp?.complete(status)
}
// API 33+ calls this overload; older versions the deprecated one below.
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic,
value: ByteArray, status: Int) {
pendingOp?.complete(status)
}
@Deprecated("Deprecated in API 33")
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
pendingOp?.complete(status)
}
// API 33+ delivers the value here and no longer calls the deprecated overload below.
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
_incoming.tryEmit(value.copyOf())
}
@Deprecated("Deprecated in API 33")
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
@Suppress("DEPRECATION")
characteristic.value?.let { _incoming.tryEmit(it.copyOf()) }
}
}
}
@@ -0,0 +1,422 @@
package com.hawhamburg.micr0bu.data.transport
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.domain.asn1.ItsTime
import com.hawhamburg.micr0bu.domain.cam.StationType
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
/** Connection lifecycle of the ESP32-C5 link, over either transport. */
enum class Esp32LinkState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
/** Which physical link the phone uses to reach the ESP32-C5 (Settings). */
enum class Esp32Transport(val id: String) { USB("usb"), BLE("ble") }
/** What the phone transmits while a trip records (Settings). */
enum class OutgoingMessage(val id: String) { CAM("cam"), VAM("vam") }
/**
* What the board on the other end speaks. The phone cannot ask, so it listens: the previous
* obu-firmware sends a [SerialFrameType.STATUS] heartbeat, the current one a station-link STATUS.
*/
enum class Esp32Protocol { UNKNOWN, LEGACY_SERIAL, STATION_LINK }
/** One ITS message for the air, with what the micrOBU needs to know about the sender. */
class OutgoingIts(
val kind: OutgoingMessage,
val uper: ByteArray,
/** Pseudonym MAC, station type and position; its [GnPositionVector.tstMs] is the fix time. */
val positionVector: GnPositionVector,
/** Android horizontal accuracy, metres (68 %); null when unknown. */
val accuracyM: Float?,
val signed: Boolean,
)
/**
* The one entry point the app uses to talk to the ESP32-C5: picks USB ([UsbSerialTransport]) or
* BLE ([BleLinkTransport]) from the setting, works out which firmware protocol is on the other end,
* and runs the station-link session the current firmware needs.
*
* ## Station-link session (obu-firmware since 2026-09-23)
* The firmware keeps no state the phone depends on, except what it stores itself (credentials in
* NVS), so the phone sets it up each time it sees it unconfigured:
* 1. STATION_CONFIGURE, with the current pseudonym MAC as the GN address and 802.11 source. This
* also starts the radio, which until then neither transmits nor receives.
* 2. If the answer reports no authorization ticket, CREDENTIALS_PROVISION of the demo bundle in
* `assets/demo-chain.vcr` (a disposable chain, not EU-registered: receivers that verify against
* the EU trust list will drop what it signs). The firmware keeps it in NVS from then on.
* 3. Per message: POTI_UPDATE (the fix, which also sets the micrOBU's ITS clock for the signature
* time), then BTP_DATA_REQUEST, secured or unsecured per the "Sign outgoing messages" setting.
* A pseudonym change reconfigures with the new MAC before the next message goes out. A STATUS
* saying "not configured" (the board reset) starts again at 1.
*
* ## Legacy firmware
* A board still on the previous obu-firmware (0xAA55 frames 0x01-0x05, no signing, USB only)
* keeps working for CAM exactly as before. VAM needs the current firmware.
*
* Everything received is surfaced the old way, as [DecodedFrame]s of [SerialFrameType.V2X_RX], so
* the receive side of the app did not change.
*/
@Singleton
class Esp32Link @Inject constructor(
@ApplicationContext private val context: Context,
private val usb: UsbSerialTransport,
private val ble: BleLinkTransport,
private val prefs: ObuHardwarePreferences,
private val pseudonymManager: PseudonymManager,
) {
companion object {
private const val TAG = "Esp32Link"
private const val REPLY_TIMEOUT_MS = 3_000L
/** Applying a bundle verifies the chain and rebuilds the stack on the C5: seconds, not ms. */
private const val PROVISION_TIMEOUT_MS = 15_000L
private const val SEGMENT_SIZE = 240
private const val DEMO_BUNDLE_ASSET = "demo-chain.vcr"
/**
* A PoTi this far behind the last one sent is a real correction of the phone's clock (e.g.
* GNSS time taking over from a wrong system clock), not a repeated fix; it goes through
* and the micrOBU restarts its stack at the new time once.
*/
private const val CLOCK_STEP_BACK_MS = 60_000L
/** How long a refusal stays on the connection card. */
private const val DETAIL_HOLD_MS = 10_000L
const val BTP_PORT_CAM = 2001
const val BTP_PORT_VAM = 2018
const val ITS_AID_CAM = 36L
const val ITS_AID_VAM = 638L
/** CAM SSP version 1, no special-vehicle permissions: what the demo ticket grants for ITS-AID 36. */
val SSP_CAM = byteArrayOf(0x01, 0x00, 0x00)
/** VRU SSP as the demo ticket grants for ITS-AID 638 (same as the colleague's VBS). */
val SSP_VAM = byteArrayOf(0x01)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val transport: StateFlow<Esp32Transport> =
prefs.esp32TransportFlow.stateIn(scope, SharingStarted.Eagerly, Esp32Transport.USB)
val state: StateFlow<Esp32LinkState> = combine(transport, usb.state, ble.state) { t, u, b ->
if (t == Esp32Transport.USB) u else b
}.stateIn(scope, SharingStarted.Eagerly, Esp32LinkState.DISCONNECTED)
private val _protocol = MutableStateFlow(Esp32Protocol.UNKNOWN)
val protocol: StateFlow<Esp32Protocol> = _protocol.asStateFlow()
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
/** Received ITS messages ([SerialFrameType.V2X_RX]) and, from legacy firmware, its heartbeats. */
val incomingFrames: SharedFlow<DecodedFrame> = _incomingFrames.asSharedFlow()
private val _linkStatus = MutableStateFlow<EspLinkStatus?>(null)
/** Heartbeat counters in the old shape, from either firmware; null before the first one. */
val linkStatus: StateFlow<EspLinkStatus?> = _linkStatus.asStateFlow()
private val _stationStatus = MutableStateFlow<StationStatus?>(null)
/** Full station-link STATUS (signing counters, tickets); null with legacy firmware. */
val stationStatus: StateFlow<StationStatus?> = _stationStatus.asStateFlow()
private val _detail = MutableStateFlow<String?>(null)
/** One line for the UI about the session: pairing, provisioning, or why it is stuck. */
val detail: StateFlow<String?> = _detail.asStateFlow()
private val _consecutiveWriteFailures = MutableStateFlow(0)
val consecutiveWriteFailures: StateFlow<Int> = _consecutiveWriteFailures.asStateFlow()
private val _refusedRequests = MutableStateFlow(0)
/** BTP_DATA_REQUESTs the micrOBU answered with anything but accepted (e.g. no ticket). */
val refusedRequests: StateFlow<Int> = _refusedRequests.asStateFlow()
private val sequence = AtomicInteger(0)
private val pending = ConcurrentHashMap<Int, CompletableDeferred<LinkResult>>()
private val sessionMutex = Mutex()
/** The STATION_CONFIGURE the micrOBU is known to run, or null when it has to be sent again. */
@Volatile private var configured: StationConfigure? = null
@Volatile private var lastRefusalLogMs = 0L
/**
* Timestamp of the last POTI_UPDATE sent in this session, or null when the micrOBU's ITS clock
* has to be set again (new session, board reset).
*/
@Volatile private var lastPotiMs: Long? = null
/** [SystemClock.elapsedRealtime] when [lastPotiMs] was sent: with it, where the micrOBU's clock stands now. */
@Volatile private var lastPotiElapsedMs = 0L
/** The fix time (before any clamping) of the last PoTi sent, to send each fix only once. */
@Volatile private var lastPotiFixMs: Long? = null
init {
scope.launch { usb.incomingFrames.collect { onUsbFrame(it) } }
scope.launch { ble.incoming.collect { onLinkMessage(it) } }
scope.launch { usb.linkStatus.collect { if (it != null) _linkStatus.value = it } }
// A new connection, on either transport, starts a new session.
scope.launch {
state.collect { s ->
if (s != Esp32LinkState.CONNECTED) {
configured = null; lastPotiMs = null; lastPotiFixMs = null
_protocol.value = Esp32Protocol.UNKNOWN
_stationStatus.value = null
_linkStatus.value = null
pending.values.forEach { it.cancel() }
pending.clear()
} else if (transport.value == Esp32Transport.BLE) {
_protocol.value = Esp32Protocol.STATION_LINK // BLE exists only on the current firmware
scope.launch { ensureConfigured(null) }
}
}
}
scope.launch { ble.detail.collect { if (transport.value == Esp32Transport.BLE) _detail.value = it } }
// Switching transport in Settings closes the other one; connecting stays a user action.
scope.launch {
transport.collect { t ->
if (t == Esp32Transport.USB) ble.disconnect() else usb.disconnect()
_detail.value = null
}
}
}
fun connect() {
if (transport.value == Esp32Transport.USB) usb.connect() else ble.connect()
}
fun disconnect() {
usb.disconnect()
ble.disconnect()
}
/**
* Hands one message to the micrOBU for transmission. False when it could not be handed over
* (no link, legacy firmware asked for a VAM, session setup failed); the next message retries.
* Acceptance by the micrOBU is not awaited: a refusal shows up in [refusedRequests].
*/
suspend fun send(its: OutgoingIts): Boolean = withContext(Dispatchers.IO) {
val ok = when (_protocol.value) {
Esp32Protocol.LEGACY_SERIAL -> {
if (its.kind == OutgoingMessage.CAM) {
usb.sendCamTx(its.uper, its.positionVector)
} else {
noteRefusal("VAM needs the current obu-firmware; this board runs the previous one")
false
}
}
Esp32Protocol.STATION_LINK -> sendStationLink(its)
Esp32Protocol.UNKNOWN -> false // no heartbeat yet: nothing to address
}
if (ok) _consecutiveWriteFailures.value = 0 else _consecutiveWriteFailures.value++
ok
}
private suspend fun sendStationLink(its: OutgoingIts): Boolean {
val pv = its.positionVector
if (!ensureConfigured(StationConfigure(stationType = pv.stationType, mid = pv.mac))) return false
// The micrOBU's ITS clock must never be sent backwards: past 1 s it answers
// time_regression and rebuilds its whole stack. Two things tried to. The transmit loops
// re-send the latest GNSS fix every tick while fused location pauses, so an old fix time
// arrived again and again while the micrOBU's clock ran on. And the GNSS-corrected fix
// times themselves wobble by seconds indoors (measured 2026-09-23: -2.1 s, +4.9 s between
// consecutive CAMs). So a fix goes over once, and its timestamp is never below where the
// micrOBU's clock stands now, except for a real correction of the phone clock.
val poti = potiFor(its)
if (poti.timestampMs != lastPotiFixMs) {
val last = lastPotiMs
val microbuNow = last?.let { it + (SystemClock.elapsedRealtime() - lastPotiElapsedMs) }
val timestamp = when {
microbuNow == null -> poti.timestampMs
poti.timestampMs < microbuNow - CLOCK_STEP_BACK_MS -> poti.timestampMs
else -> maxOf(poti.timestampMs, microbuNow)
}
if (!write(LinkOpcode.POTI_UPDATE, poti.copy(timestampMs = timestamp).encode())) return false
lastPotiFixMs = poti.timestampMs
lastPotiMs = timestamp
lastPotiElapsedMs = SystemClock.elapsedRealtime()
}
val request = BtpDataRequest(
destinationPort = if (its.kind == OutgoingMessage.CAM) BTP_PORT_CAM else BTP_PORT_VAM,
itsAid = if (its.kind == OutgoingMessage.CAM) ITS_AID_CAM else ITS_AID_VAM,
securityProfile = if (its.signed) LinkSecurityProfile.SECURED else LinkSecurityProfile.UNSECURED,
permissions = if (its.kind == OutgoingMessage.CAM) SSP_CAM else SSP_VAM,
flSdu = its.uper,
)
return write(LinkOpcode.BTP_DATA_REQUEST, request.encode())
}
/**
* Makes sure the micrOBU runs [wanted] (or, when null, any configuration: used right after a
* BLE connect or a board reset, to start its receiver before the first message goes out).
*/
private suspend fun ensureConfigured(wanted: StationConfigure?): Boolean = sessionMutex.withLock {
val current = configured
if (current != null && (wanted == null || current == wanted)) return@withLock true
val config = wanted ?: StationConfigure(stationType = StationType.CYCLIST, mid = pseudonymManager.current().mac)
_detail.value = "Configuring the micrOBU"
val result = request(LinkOpcode.STATION_CONFIGURE, config.encode(), REPLY_TIMEOUT_MS)
if (result == null || !result.accepted) {
_detail.value = "micrOBU did not accept the configuration (${result?.let { LinkResultCode.name(it.code) } ?: "no reply"})"
return@withLock false
}
val info = StationInfo.decode(result.detail)
Log.i(TAG, "station configured: $info")
if (info == null || !info.credentialsLoaded || info.tickets == 0) {
if (!provisionDemoCredentials()) return@withLock false
}
configured = config
_detail.value = null
true
}
private suspend fun provisionDemoCredentials(): Boolean {
_detail.value = "Provisioning the demo credentials"
val bundle = runCatching { context.assets.open(DEMO_BUNDLE_ASSET).use { it.readBytes() } }.getOrElse {
_detail.value = "Demo credential bundle missing from the app"
return false
}
var offset = 0
while (offset < bundle.size) {
val segment = bundle.copyOfRange(offset, minOf(bundle.size, offset + SEGMENT_SIZE))
val last = offset + segment.size == bundle.size
val result = request(LinkOpcode.CREDENTIALS_PROVISION, credentialsSegment(bundle.size, offset, segment),
if (last) PROVISION_TIMEOUT_MS else REPLY_TIMEOUT_MS)
if (result == null || !result.accepted) {
_detail.value = "micrOBU refused the demo credentials (${result?.let { LinkResultCode.name(it.code) } ?: "no reply"})"
return false
}
if (last) {
val d = result.detail
Log.i(TAG, "demo credentials provisioned: " +
if (d.size == 3) "${d[0]} root(s), ${d[1]} authorit(ies), ${d[2]} ticket(s)" else "no report")
}
offset += segment.size
}
return true
}
private fun potiFor(its: OutgoingIts): PotiUpdate {
val pv = its.positionVector
// Semi-axes of the 95 % ellipse from Android's 68 % radius (circular error), as GnPositionVector's PAI bound.
val semiCm = its.accuracyM?.takeIf { it > 0f && it.isFinite() }
?.let { (it * 1.62f * 100).roundToInt().coerceAtMost(65_535) } ?: 0
return PotiUpdate(
timestampMs = fullTimestampIts(pv.tstMs),
latTenMicroDeg = pv.latTenMicroDeg,
lonTenMicroDeg = pv.lonTenMicroDeg,
semiMajorCm = semiCm,
semiMinorCm = semiCm,
speedCms = pv.speedCms.coerceAtLeast(0),
headingDeciDeg = pv.headingDeciDeg,
pai = pv.pai,
)
}
/** [GnPositionVector.tstMs] is already the full TimestampIts; guard against a reduced one anyway. */
private fun fullTimestampIts(tstMs: Long): Long {
if (tstMs > 0xFFFF_FFFFL) return tstMs
val now = ItsTime.timestampIts(System.currentTimeMillis())
return now - ((now - tstMs) and 0xFFFF_FFFFL)
}
private suspend fun request(opcode: Int, body: ByteArray, timeoutMs: Long): LinkResult? {
val seq = nextSequence()
val reply = CompletableDeferred<LinkResult>()
pending[seq] = reply
return try {
if (!writeMessage(LinkMessage(opcode, seq, body).encode())) null
else withTimeoutOrNull(timeoutMs) { reply.await() }
} finally {
pending.remove(seq)
}
}
private suspend fun write(opcode: Int, body: ByteArray): Boolean =
writeMessage(LinkMessage(opcode, nextSequence(), body).encode())
private suspend fun writeMessage(message: ByteArray): Boolean =
if (transport.value == Esp32Transport.USB) usb.sendFrame(SERIAL_FRAME_LINK, message)
else ble.send(message)
private fun nextSequence(): Int = sequence.incrementAndGet() and 0xFFFF
private fun onUsbFrame(frame: DecodedFrame) {
when (frame.type) {
SerialFrameType.STATUS -> _protocol.value = Esp32Protocol.LEGACY_SERIAL
SerialFrameType.V2X_RX -> _incomingFrames.tryEmit(frame)
SERIAL_FRAME_LINK -> onLinkMessage(frame.payload)
}
}
private fun onLinkMessage(octets: ByteArray) {
val message = LinkMessage.decode(octets) ?: return
when (message.opcode) {
LinkOpcode.V2X_RX -> _incomingFrames.tryEmit(DecodedFrame(SerialFrameType.V2X_RX, message.body))
LinkOpcode.RESULT -> {
val result = LinkResult.decode(message.body) ?: return
val waiting = pending.remove(message.sequence)
if (waiting != null) {
waiting.complete(result)
} else if (result.code == LinkResultCode.TIME_REGRESSION) {
// Only sent for a deliberate clock correction (see CLOCK_STEP_BACK_MS): the
// micrOBU accepted the new time and restarted its stack. Not a refusal.
Log.i(TAG, "micrOBU followed a step back of the phone's clock and restarted its stack")
} else if (!result.accepted) {
// A POTI_UPDATE or BTP_DATA_REQUEST the micrOBU refused (they are not awaited).
_refusedRequests.value++
if (result.code == LinkResultCode.NOT_CONFIGURED) { configured = null; lastPotiMs = null; lastPotiFixMs = null }
noteRefusal("micrOBU refused a request: ${LinkResultCode.name(result.code)}")
}
}
LinkOpcode.STATUS -> {
val status = StationStatus.decode(message.body) ?: return
val first = _protocol.value != Esp32Protocol.STATION_LINK
_protocol.value = Esp32Protocol.STATION_LINK
_stationStatus.value = status
_linkStatus.value = EspLinkStatus(
status = 0,
oversizeDrops = 0,
txFailures = status.radioFailed.coerceAtMost(0xFFFF).toInt(),
rxCrcErrors = status.linkCrcErrors.coerceAtMost(0xFFFF).toInt(),
rxQueueDrops = status.radioDropped.coerceAtMost(0xFFFF).toInt(),
)
// Board reset (or first contact): configure now, so its receiver runs even before
// the first message is sent.
if (!status.configured) { configured = null; lastPotiMs = null; lastPotiFixMs = null }
if (first || !status.configured) scope.launch { ensureConfigured(null) }
}
}
}
private fun noteRefusal(line: String) {
val now = System.currentTimeMillis()
if (now - lastRefusalLogMs < 5_000) return
lastRefusalLogMs = now
Log.w(TAG, line)
_detail.value = line
// A refusal is news, not a state: it leaves the card again unless something replaced it.
scope.launch {
delay(DETAIL_HOLD_MS)
_detail.compareAndSet(line, null)
}
}
}
@@ -57,8 +57,9 @@ const val SERIAL_LINK_MAX_PAYLOAD = 512
/** /**
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE] * Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
* [rxCrcErrors:2 LE]` (7 bytes). Counters are free-running totals since firmware boot and * [rxCrcErrors:2 LE][capabilities:1][rxQueueDrops:2 LE]` (10 bytes; the last two fields are an
* saturate at 0xFFFF rather than wrapping. * optional tail — see [capabilities] and [rxQueueDrops]). Counters are free-running totals since
* firmware boot and saturate at 0xFFFF rather than wrapping.
* *
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side * Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't * drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
@@ -79,6 +80,15 @@ data class EspLinkStatus(
* the phone needs from such firmware: it accepts nothing beyond the original messages. * the phone needs from such firmware: it accepts nothing beyond the original messages.
*/ */
val capabilities: Int = 0, val capabilities: Int = 0,
/**
* Promiscuously-captured frames the firmware's `wifi_promisc_rx_cb` had to drop because its
* RX queue (8 deep) was still full of frames `rx_forward_task` hadn't finished forwarding —
* bytes 8-9 of the payload. 0 for firmware that predates this field (payload of 7 or 8 bytes),
* which is the honest answer: such firmware drops these frames identically, it just never
* counted them. A nonzero, growing value here — as opposed to [oversizeDrops] — points at
* bursty RX outrunning the forward task rather than any one frame being too large.
*/
val rxQueueDrops: Int = 0,
) { ) {
/** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */ /** True when the firmware accepts [SerialFrameType.CAM_TX_PV]. */
val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0 val supportsCamTxPv: Boolean get() = capabilities and CAP_CAM_TX_PV != 0
@@ -99,6 +109,7 @@ data class EspLinkStatus(
txFailures = u16(3), txFailures = u16(3),
rxCrcErrors = u16(5), rxCrcErrors = u16(5),
capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0, capabilities = if (payload.size > PAYLOAD_SIZE) payload[7].toInt() and 0xFF else 0,
rxQueueDrops = if (payload.size >= 10) u16(8) else 0,
) )
} }
} }
@@ -0,0 +1,274 @@
package com.hawhamburg.micr0bu.data.transport
/**
* Phone side of the station-link message layer, version 1: the protocol of the colleague's
* vanetza-idf ESP32-C5 firmware (microbu-esp32c5/station-link/README.md), which obu-firmware runs
* since 2026-09-23. Kotlin counterpart of `obu-firmware/main/link_protocol.hpp` and of the
* colleague's Python `microbu_link/messages.py`; the unit test pins these encoders to bytes that
* Python module produced.
*
* Transport independent: over USB each message is the payload of one serial frame of type
* [SERIAL_FRAME_LINK] (same 0xAA55 framing as before, see [SerialFrameEncoder]); over BLE each
* message is one GATT value (see [BleLinkTransport]).
*
* Message: `[opcode:1][flags:1][sequence:2 LE][body]`, at most [LINK_MAX_MESSAGE] octets, all
* integers little-endian. The phone numbers its requests; the firmware answers with a RESULT
* carrying the same sequence.
*
* Only what this app uses is implemented: configure, PoTi, BTP-DATA.request, credential
* provisioning, RESULT, STATUS, and the MicrOBU extension [LinkOpcode.V2X_RX]. The SF-SAP
* identifier-change primitives are not used: the app owns its pseudonym (see
* [com.hawhamburg.micr0bu.data.cam.PseudonymManager]) and reconfigures the station on a change.
*/
const val SERIAL_FRAME_LINK = 0x10
const val LINK_MAX_MESSAGE = 512
const val LINK_HEADER_SIZE = 4
object LinkOpcode {
const val STATION_CONFIGURE = 0x01
const val POTI_UPDATE = 0x02
const val BTP_DATA_REQUEST = 0x03
const val CREDENTIALS_PROVISION = 0x04
const val CREDENTIALS_ERASE = 0x05
const val STATUS_REQUEST = 0x0C
const val RESULT = 0x80
const val BTP_DATA_INDICATION = 0x81
const val STATUS = 0x84
/** MicrOBU extension: body is exactly the old [SerialFrameType.V2X_RX] payload ([V2xRxFrame]). */
const val V2X_RX = 0x85
}
/** RESULT codes: vanetza_idf::Result first, then the link's own. */
object LinkResultCode {
const val ACCEPTED = 0
const val TIME_REGRESSION = 7
const val NOT_CONFIGURED = 0x12
fun name(code: Int): String = when (code) {
0 -> "accepted"; 1 -> "invalid_argument"; 2 -> "unsupported"; 3 -> "wrong_entry_point"
4 -> "security_unavailable"; 5 -> "resource_limit"; 6 -> "rejected"; 7 -> "time_regression"
8 -> "identity_change_pending"; 0x10 -> "unknown_opcode"; 0x11 -> "malformed"
0x12 -> "not_configured"; 0x13 -> "busy"; 0x14 -> "no_credentials"
else -> "code_$code"
}
}
/** GN security profile of a BTP-DATA.request (TS 103 300-3 Table 4). */
object LinkSecurityProfile {
const val STATION_DEFAULT = 0
const val UNSECURED = 1
const val SECURED = 2
}
class LinkMessage(val opcode: Int, val sequence: Int, val body: ByteArray, val flags: Int = 0) {
fun encode(): ByteArray {
require(LINK_HEADER_SIZE + body.size <= LINK_MAX_MESSAGE) {
"link message 0x%02x too long: %d".format(opcode, LINK_HEADER_SIZE + body.size)
}
return byteArrayOf(opcode.toByte(), flags.toByte(), sequence.toByte(), (sequence shr 8).toByte()) + body
}
companion object {
fun decode(octets: ByteArray): LinkMessage? {
if (octets.size < LINK_HEADER_SIZE || octets.size > LINK_MAX_MESSAGE) return null
val sequence = (octets[2].toInt() and 0xFF) or ((octets[3].toInt() and 0xFF) shl 8)
return LinkMessage(octets[0].toInt() and 0xFF, sequence,
octets.copyOfRange(LINK_HEADER_SIZE, octets.size), octets[1].toInt() and 0xFF)
}
}
}
internal class LinkWriter {
private val out = java.io.ByteArrayOutputStream()
fun u8(v: Int) = apply { out.write(v and 0xFF) }
fun u16(v: Int) = apply { u8(v); u8(v shr 8) }
fun u32(v: Long) = apply { for (i in 0 until 4) u8((v ushr (8 * i)).toInt()) }
fun i32(v: Int) = u32(v.toLong())
fun u64(v: Long) = apply { for (i in 0 until 8) u8((v ushr (8 * i)).toInt()) }
fun bytes(b: ByteArray) = apply { out.write(b) }
fun toByteArray(): ByteArray = out.toByteArray()
}
/**
* STATION_CONFIGURE body. (Re)creates the GeoNetworking stack and security entity on the micrOBU.
* [mid] is the pseudonym MAC: with [addressConfiguration] 0 (AUTO) it becomes both the GN_ADDR MID
* and the 802.11 source address, as with the old CAM_TX_PV prefix.
*/
data class StationConfigure(
val stationType: Int,
val mid: ByteArray,
val security: Int = 1,
val addressConfiguration: Int = 0,
val beaconing: Int = 0,
val channelNumber: Int = 180,
val transmitPowerDbm: Int = 20,
/** 0 off, 1 receive only, 2 transmit and receive. */
val radio: Int = 2,
/** Raw GN traffic class octet: TC-ID 2, as the previous firmware's geonet.c. */
val defaultTrafficClass: Int = 2,
/** Raw GN lifetime octet: 1 s. */
val defaultLifetime: Int = 0x05,
) {
init { require(mid.size == 6) }
fun encode(): ByteArray = LinkWriter()
.u8(stationType).u8(security).u8(addressConfiguration).bytes(mid).u8(beaconing)
.u16(channelNumber).u8(transmitPowerDbm).u8(radio).u8(defaultTrafficClass).u8(defaultLifetime)
.toByteArray()
override fun equals(other: Any?): Boolean = other is StationConfigure && encode().contentEquals(other.encode())
override fun hashCode(): Int = encode().contentHashCode()
}
/** RESULT detail of STATION_CONFIGURE. */
data class StationInfo(val credentialsLoaded: Boolean, val tickets: Int) {
companion object {
fun decode(detail: ByteArray): StationInfo? =
if (detail.size != 18) null
else StationInfo(detail[16].toInt() != 0, detail[17].toInt() and 0xFF)
}
}
/**
* POTI_UPDATE body (EN 302 890-2 minimum data set). [timestampMs] is TimestampIts under
* [com.hawhamburg.micr0bu.domain.asn1.ItsTime]'s convention; it also sets the micrOBU's ITS clock,
* which the security entity stamps into every signed message's generationTime.
*/
data class PotiUpdate(
val timestampMs: Long,
val latTenMicroDeg: Int,
val lonTenMicroDeg: Int,
val semiMajorCm: Int = 0,
val semiMinorCm: Int = 0,
val orientationDeciDeg: Int = 0,
val altitudeCm: Int? = null,
val speedCms: Int? = null,
val headingDeciDeg: Int? = null,
val pai: Boolean = false,
) {
fun encode(): ByteArray {
val flags = (if (altitudeCm != null) 1 else 0) or (if (speedCms != null) 2 else 0) or
(if (headingDeciDeg != null) 4 else 0) or (if (pai) 8 else 0)
return LinkWriter().u64(timestampMs).i32(latTenMicroDeg).i32(lonTenMicroDeg)
.u16(semiMajorCm).u16(semiMinorCm).u16(orientationDeciDeg).u8(flags)
.i32(altitudeCm ?: 0).u16(speedCms ?: 0).u16(headingDeciDeg ?: 0)
.toByteArray()
}
}
/**
* BTP_DATA_REQUEST body for a BTP-B single-hop broadcast, the only shape this app sends (CAM, VAM).
* [permissions] is the SSP the authorization ticket must carry for [itsAid].
*/
data class BtpDataRequest(
val destinationPort: Int,
val itsAid: Long,
val securityProfile: Int,
val permissions: ByteArray,
val flSdu: ByteArray,
/** Raw GN lifetime octet; 0xFF = station default. */
val maximumPacketLifetime: Int = 0xFF,
) {
fun encode(): ByteArray = LinkWriter()
.u8(1) // BTP-B
.u16(destinationPort)
.u16(0) // destination port info
.u8(1) // SHB
.u8(1) // communication profile ITS-G5
.u8(securityProfile)
.u8(0xFF) // traffic class: station default
.u8(maximumPacketLifetime)
.u8(0) // hop limit: station default
.u16(0).u16(0) // no repetition
.u32(itsAid)
.u8(permissions.size).bytes(permissions)
.u8(0) // no SN-ENCAP context
.u16(flSdu.size).bytes(flSdu)
.toByteArray()
override fun equals(other: Any?): Boolean = other is BtpDataRequest && encode().contentEquals(other.encode())
override fun hashCode(): Int = encode().contentHashCode()
}
/** One CREDENTIALS_PROVISION segment of a `VCR1` bundle. */
fun credentialsSegment(totalLength: Int, offset: Int, segment: ByteArray): ByteArray {
require(segment.size <= 255)
return LinkWriter().u16(totalLength).u16(offset).u8(segment.size).bytes(segment).toByteArray()
}
data class LinkResult(val code: Int, val detail: ByteArray) {
val accepted: Boolean get() = code == LinkResultCode.ACCEPTED
override fun toString(): String = "LinkResult(${LinkResultCode.name(code)}, ${detail.size} B detail)"
override fun equals(other: Any?): Boolean = other is LinkResult && code == other.code && detail.contentEquals(other.detail)
override fun hashCode(): Int = 31 * code + detail.contentHashCode()
companion object {
fun decode(body: ByteArray): LinkResult? {
if (body.size < 2) return null
val length = body[1].toInt() and 0xFF
if (body.size != 2 + length) return null
return LinkResult(body[0].toInt() and 0xFF, body.copyOfRange(2, body.size))
}
}
}
/** STATUS body (103 octets), sent by the micrOBU every second. Counters are since the last configure. */
data class StationStatus(
val uptimeMs: Long,
val configured: Boolean,
val identifier: ByteArray,
val tickets: Int,
val signedMessages: Long,
val refusedNoTicket: Long,
val refusedChangePending: Long,
val refusedPermission: Long,
val signFailed: Long,
val verified: Long,
val rejected: Long,
val requestsAccepted: Long,
val requestsRefused: Long,
val radioSubmitted: Long,
val radioFailed: Long,
val radioReceived: Long,
val radioDropped: Long,
val linkCrcErrors: Long,
val linkMalformed: Long,
val potiUpdates: Long,
val itsTimeMs: Long,
) {
/** Signing refusals of every kind: no usable ticket, a pending id change, or a missing permission. */
val signRefused: Long get() = refusedNoTicket + refusedChangePending + refusedPermission + signFailed
override fun equals(other: Any?): Boolean = other is StationStatus && toString() == other.toString() &&
identifier.contentEquals(other.identifier)
override fun hashCode(): Int = toString().hashCode()
companion object {
const val SIZE = 103
fun decode(body: ByteArray): StationStatus? {
if (body.size != SIZE) return null
fun u8(i: Int) = body[i].toInt() and 0xFF
fun u32(i: Int) = (0 until 4).fold(0L) { acc, k -> acc or ((body[i + k].toLong() and 0xFF) shl (8 * k)) }
fun u64(i: Int) = (0 until 8).fold(0L) { acc, k -> acc or ((body[i + k].toLong() and 0xFF) shl (8 * k)) }
// [0] uptime u32, [4] configured, [5] gn_address 8, [13] identifier 8, [21] change_pending,
// [22] tickets, [23] 18 x u32 counters, [95] its_time u64
val c = 23
return StationStatus(
uptimeMs = u32(0),
configured = u8(4) != 0,
identifier = body.copyOfRange(13, 21),
tickets = u8(22),
signedMessages = u32(c), refusedNoTicket = u32(c + 4), refusedChangePending = u32(c + 8),
refusedPermission = u32(c + 12), signFailed = u32(c + 16), verified = u32(c + 20),
rejected = u32(c + 24), requestsAccepted = u32(c + 28), requestsRefused = u32(c + 32),
// c + 36: indications (the stack's own verified deliveries; the app uses V2X_RX)
radioSubmitted = u32(c + 40), radioFailed = u32(c + 44), radioReceived = u32(c + 48),
radioDropped = u32(c + 52),
// c + 56: link_rx_frames
linkCrcErrors = u32(c + 60), linkMalformed = u32(c + 64), potiUpdates = u32(c + 68),
itsTimeMs = u64(95),
)
}
}
}
@@ -35,9 +35,6 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.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"
@@ -63,6 +60,11 @@ 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
@@ -111,8 +113,8 @@ class UsbSerialTransport @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED) private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
val state: StateFlow<UsbSerialState> = _state.asStateFlow() val state: StateFlow<Esp32LinkState> = _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]. */
@@ -150,7 +152,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 = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
} }
} }
UsbManager.ACTION_USB_DEVICE_DETACHED -> { UsbManager.ACTION_USB_DEVICE_DETACHED -> {
@@ -174,7 +176,7 @@ class UsbSerialTransport @Inject constructor(
* repeatedly (e.g. from a "retry" UI action) — no-ops if already connected. * repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
*/ */
fun connect() { fun connect() {
if (_state.value == UsbSerialState.CONNECTED) { if (_state.value == Esp32LinkState.CONNECTED) {
Log.i(TAG, "connect(): already connected, no-op") Log.i(TAG, "connect(): already connected, no-op")
return return
} }
@@ -197,7 +199,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 = UsbSerialState.DISCONNECTED _state.value = Esp32LinkState.DISCONNECTED
return return
} }
if (espDriver == null) { if (espDriver == null) {
@@ -211,14 +213,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 = UsbSerialState.DEVICE_ATTACHED _state.value = Esp32LinkState.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 = UsbSerialState.PERMISSION_REQUESTED _state.value = Esp32LinkState.PERMISSION_REQUESTED
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 val 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,
@@ -269,14 +271,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 = UsbSerialState.ERROR _state.value = Esp32LinkState.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 = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
return return
} }
@@ -287,7 +289,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 = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
return return
} }
@@ -330,13 +332,15 @@ class UsbSerialTransport @Inject constructor(
prev.txFailures != status.txFailures || prev.txFailures != status.txFailures ||
prev.rxCrcErrors != status.rxCrcErrors || prev.rxCrcErrors != status.rxCrcErrors ||
prev.status != status.status || prev.status != status.status ||
prev.capabilities != status.capabilities prev.capabilities != status.capabilities ||
prev.rxQueueDrops != status.rxQueueDrops
) { ) {
Log.i(TAG, "ESP32 counters: status=${status.status} " + Log.i(TAG, "ESP32 counters: status=${status.status} " +
"oversizeDrops=${status.oversizeDrops} " + "oversizeDrops=${status.oversizeDrops} " +
"txFailures=${status.txFailures} " + "txFailures=${status.txFailures} " +
"rxCrcErrors=${status.rxCrcErrors} " + "rxCrcErrors=${status.rxCrcErrors} " +
"capabilities=${status.capabilities}") "capabilities=${status.capabilities} " +
"rxQueueDrops=${status.rxQueueDrops}")
} }
_linkStatus.value = status _linkStatus.value = status
} }
@@ -347,7 +351,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 = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
} }
}) })
ioManager = manager ioManager = manager
@@ -360,13 +364,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 = UsbSerialState.CONNECTED _state.value = Esp32LinkState.CONNECTED
startWatchdog() startWatchdog()
} }
} }
/** /**
* Flips the link to [UsbSerialState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been * Flips the link to [Esp32LinkState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
* missing for [LINK_TIMEOUT_MS]. Without this, "connected" only ever means "the port opened * 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.
*/ */
@@ -375,14 +379,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 != UsbSerialState.CONNECTED) continue if (_state.value != Esp32LinkState.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 = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
} }
} }
} }
@@ -443,7 +447,29 @@ 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 = UsbSerialState.ERROR _state.value = Esp32LinkState.ERROR
false
}
}
/**
* Writes one frame of any [type] (the station-link messages go as [SERIAL_FRAME_LINK]). Same
* failure accounting as [sendCamTx]. Blocking, 200 ms timeout: call from a background dispatcher.
*/
fun sendFrame(type: Int, payload: ByteArray): Boolean {
val p = port
if (p == null) {
_consecutiveWriteFailures.update { it + 1 }
return false
}
return try {
p.write(SerialFrameEncoder.encode(type, payload), /* timeout ms */ 200)
_consecutiveWriteFailures.value = 0
true
} catch (e: Exception) {
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
Log.w(TAG, "sendFrame(0x${type.toString(16)}): write failed (consecutive failures: $failures)", e)
_state.value = Esp32LinkState.ERROR
false false
} }
} }
@@ -459,7 +485,7 @@ class UsbSerialTransport @Inject constructor(
openDeviceName = null openDeviceName = null
_linkStatus.value = null _linkStatus.value = null
_consecutiveWriteFailures.value = 0 _consecutiveWriteFailures.value = 0
_state.value = UsbSerialState.DISCONNECTED _state.value = Esp32LinkState.DISCONNECTED
} }
} }
@@ -8,7 +8,7 @@ import javax.inject.Singleton
* UPER (Unaligned Packed Encoding Rules) codec for CAM, used on the ESP32-C5 hardware path * 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.UsbSerialTransport], and UPER-decodes whatever the * to [com.hawhamburg.micr0bu.data.transport.Esp32Link], and UPER-decodes whatever the
* ESP32-C5 forwards back on receive (already stripped of 802.11/GeoNetworking/BTP framing by * 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).
* *
@@ -0,0 +1,145 @@
package com.hawhamburg.micr0bu.domain.asn1
import com.hawhamburg.micr0bu.domain.cam.StationType
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* One VAM's content, in the units the phone has. Everything optional here is encoded as the ASN.1
* "unavailable" value when null, never as a made-up number.
*/
data class VamContent(
val stationId: Long,
/** Wall-clock epoch ms of the fix, GNSS-corrected; the generationDeltaTime source. */
val timestamp: Long,
val latitude: Double,
val longitude: Double,
/** Android horizontal accuracy, metres (68 %). Null or 0: unknown. */
val accuracyM: Float?,
val speedMps: Double,
val headingDeg: Double,
/** Along-track acceleration, m/s². */
val accelerationMps2: Double? = null,
/** Include the low-frequency container (profile and size class). */
val includeLowFrequency: Boolean,
)
/**
* UPER encoder for the VAM of ETSI TS 103 300-3 V2.3.1 (VAM-PDU-Descriptions major version 3,
* over the CDD of TS 102 894-2 V2.4.1), for a bicyclist in VRU profile 2.
*
* Covers the same field set as the colleague's reference VBS (microbu-esp32c5,
* station-link/python/microbu_link/vbs.py, `VbsLite.assemble`), which encodes with asn1tools from
* the ETSI modules: header, basic container, the three mandatory fields of the high-frequency
* container, and optionally the low-frequency container with profile and size class. No cluster
* or motion-prediction containers. The unit test cross-checks the bytes against asn1tools.
*
* Transmit only for now: the app neither decodes received VAMs nor shows them.
*/
object VamUperCodec {
const val PROTOCOL_VERSION = 3
const val MESSAGE_ID_VAM = 16
/** VruSubProfileBicyclist.bicyclist */
private const val SUBPROFILE_BICYCLIST = 1
/** VruSizeClass.low */
private const val SIZE_CLASS_LOW = 1
/** VruProfileAndSubprofile CHOICE index of bicyclistAndLightVruVehicle (root: 4 alternatives). */
private const val PROFILE_BICYCLIST_INDEX = 1
private const val SEMI_AXIS_OUT_OF_RANGE = 4094
private const val SEMI_AXIS_UNAVAILABLE = 4095
private const val WGS84_ANGLE_UNAVAILABLE = 3601
private const val ANGLE_CONFIDENCE_UNAVAILABLE = 127
private const val SPEED_OUT_OF_RANGE = 16382
private const val SPEED_CONFIDENCE_UNAVAILABLE = 127
private const val ACCEL_UNAVAILABLE = 161
private const val ACCEL_CONFIDENCE_UNAVAILABLE = 102
private const val ALTITUDE_UNAVAILABLE = 800001
private const val ALTITUDE_CONFIDENCE_UNAVAILABLE = 15
/**
* Android's accuracy is a 68 % radius; the confidence ellipse is 95 %. For a circular 2-D error
* the ratio is about 1.62, the same factor [com.hawhamburg.micr0bu.data.transport.GnPositionVector]
* uses for its PAI bound.
*/
private const val ACCURACY_68_TO_95 = 1.62
private const val ENCODE_BUFFER_BYTES = 64
fun encode(vam: VamContent): ByteArray {
val w = BitWriter(ENCODE_BUFFER_BYTES)
// VAM ::= SEQUENCE { header, vam } -- not extensible
// ItsPduHeader
w.putBits(PROTOCOL_VERSION, 8)
w.putBits(MESSAGE_ID_VAM, 8)
w.putBits(vam.stationId and 0xFFFFFFFFL, 32)
// VruAwareness ::= SEQUENCE { generationDeltaTime, vamParameters }
w.putBits(CamUperCodec.generationDeltaTime(vam.timestamp), 16)
// VamParameters ::= SEQUENCE { basic, hf, lf OPT, clusterInfo OPT, clusterOp OPT, motion OPT, ... }
w.putBits(0, 1) // extension bit
w.putBits(if (vam.includeLowFrequency) 0b1000 else 0b0000, 4)
// BasicContainer ::= SEQUENCE { stationType, referencePosition, ... }
w.putBits(0, 1)
w.putBits(StationType.CYCLIST, 8)
// ReferencePositionWithConfidence ::= SEQUENCE { latitude, longitude, ellipse, altitude }
w.putBits(latitude(vam.latitude) + 900_000_000L, 31)
w.putBits(longitude(vam.longitude) + 1_800_000_000L, 32)
val semiAxis = semiAxisCm(vam.accuracyM)
w.putBits(semiAxis, 12) // semiMajorAxisLength
w.putBits(semiAxis, 12) // semiMinorAxisLength
// Circular error: the orientation of the major axis says nothing, so it is unavailable.
w.putBits(WGS84_ANGLE_UNAVAILABLE, 12)
// Altitude: GnssReading carries no "has altitude" flag, so an honest unavailable.
w.putBits(ALTITUDE_UNAVAILABLE + 100_000, 20)
w.putBits(ALTITUDE_CONFIDENCE_UNAVAILABLE, 4)
// VruHighFrequencyContainer ::= SEQUENCE { heading, speed, longitudinalAcceleration, 11 OPTIONAL, ... }
w.putBits(0, 1)
w.putBits(0, 11)
w.putBits(headingDeciDeg(vam.headingDeg), 12)
w.putBits(ANGLE_CONFIDENCE_UNAVAILABLE - 1, 7) // Wgs84AngleConfidence (1..127)
w.putBits(speedCms(vam.speedMps), 14)
w.putBits(SPEED_CONFIDENCE_UNAVAILABLE - 1, 7) // SpeedConfidence (1..127)
w.putBits(accelDeciMps2(vam.accelerationMps2) + 160, 9)
w.putBits(ACCEL_CONFIDENCE_UNAVAILABLE, 7)
if (vam.includeLowFrequency) {
// VruLowFrequencyContainer ::= SEQUENCE { profileAndSubprofile, sizeClass OPT, exteriorLights OPT, ... }
w.putBits(0, 1)
w.putBits(0b10, 2)
// VruProfileAndSubprofile ::= CHOICE { pedestrian, bicyclistAndLightVruVehicle, motorcyclist, animal, ... }
w.putBits(0, 1)
w.putBits(PROFILE_BICYCLIST_INDEX, 2)
w.putBits(SUBPROFILE_BICYCLIST, 4)
w.putBits(SIZE_CLASS_LOW, 4)
}
return w.toByteArray()
}
private fun latitude(deg: Double): Long =
if (deg.isFinite()) (deg * 1e7).roundToLong().coerceIn(-900_000_000L, 900_000_000L) else 900_000_001L
private fun longitude(deg: Double): Long =
if (deg.isFinite()) (deg * 1e7).roundToLong().coerceIn(-1_799_999_999L, 1_800_000_000L) else 1_800_000_001L
private fun semiAxisCm(accuracyM: Float?): Int {
if (accuracyM == null || !accuracyM.isFinite() || accuracyM <= 0f) return SEMI_AXIS_UNAVAILABLE
val cm = (accuracyM * ACCURACY_68_TO_95 * 100).roundToInt()
return if (cm >= SEMI_AXIS_OUT_OF_RANGE) SEMI_AXIS_OUT_OF_RANGE else cm.coerceAtLeast(1)
}
private fun headingDeciDeg(deg: Double): Int =
if (deg.isFinite()) Math.floorMod((deg * 10).roundToInt(), 3600) else WGS84_ANGLE_UNAVAILABLE
private fun speedCms(mps: Double): Int =
if (mps.isFinite()) (mps * 100).roundToInt().coerceIn(0, SPEED_OUT_OF_RANGE) else 16383
private fun accelDeciMps2(mps2: Double?): Int =
if (mps2 == null || !mps2.isFinite()) ACCEL_UNAVAILABLE else (mps2 * 10).roundToInt().coerceIn(-160, 160)
}
@@ -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(...)` → `UsbSerialTransport` (write). * `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `Esp32Link` (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
@@ -0,0 +1,60 @@
package com.hawhamburg.micr0bu.domain.vam
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
import kotlin.math.abs
/**
* When to send an individual VAM: ETSI TS 103 300-3 V2.3.1 clause 6.4, items 1 to 4, with the
* recommended values of Tables 16 and 17 — the same rules the colleague's reference VBS applies
* (microbu-esp32c5/station-link/python/microbu_link/vbs.py, `VbsLite.due`). No clustering, no
* redundancy mitigation, T_GenVam fixed at its minimum (no DCC input).
*
* Pure and clock-free (callers pass times in ms), so it can be tested without Android.
*/
class VamGenerationRules {
data class Kinematics(val latitude: Double, val longitude: Double, val speedMps: Double, val headingDeg: Double)
private var last: Kinematics? = null
private var lastAtMs = 0L
private var lastLowFrequencyAtMs: Long? = null
/** True if a VAM is due at [nowMs] for the VRU now at [now]. */
fun due(nowMs: Long, now: Kinematics): Boolean {
val previous = last ?: return true
val elapsed = nowMs - lastAtMs
if (elapsed < T_GEN_VAM_MIN_MS) return false
if (elapsed > T_GEN_VAM_MAX_MS) return true // item 1
if (GeoMath.haversineMeters(now.latitude, now.longitude, previous.latitude, previous.longitude) >
MIN_POSITION_CHANGE_M) return true // item 2
if (abs(now.speedMps - previous.speedMps) > MIN_SPEED_CHANGE_MPS) return true // item 3
val headingDelta = abs(((now.headingDeg - previous.headingDeg + 180.0) % 360.0 + 360.0) % 360.0 - 180.0)
return headingDelta > MIN_ORIENTATION_CHANGE_DEG // item 4
}
/** True if the VAM sent at [nowMs] carries the low-frequency container (first VAM, then every T_GenVamLFMin). */
fun includeLowFrequency(nowMs: Long): Boolean =
lastLowFrequencyAtMs.let { it == null || nowMs - it >= T_GEN_VAM_LF_MIN_MS }
/** Records that a VAM went out at [nowMs] for [sent], with or without the low-frequency container. */
fun onSent(nowMs: Long, sent: Kinematics, withLowFrequency: Boolean) {
last = sent
lastAtMs = nowMs
if (withLowFrequency) lastLowFrequencyAtMs = nowMs
}
fun reset() {
last = null
lastAtMs = 0L
lastLowFrequencyAtMs = null
}
companion object {
const val T_GEN_VAM_MIN_MS = 100L
const val T_GEN_VAM_MAX_MS = 5_000L
const val T_GEN_VAM_LF_MIN_MS = 2_000L
const val MIN_POSITION_CHANGE_M = 4.0
const val MIN_SPEED_CHANGE_MPS = 0.5
const val MIN_ORIENTATION_CHANGE_DEG = 4.0
}
}
@@ -6,7 +6,10 @@ 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.transport.UsbSerialTransport import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.data.transport.OutgoingIts
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec import com.hawhamburg.micr0bu.domain.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
@@ -21,6 +24,7 @@ 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
@@ -49,7 +53,8 @@ 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 usbSerialTransport: UsbSerialTransport, private val esp32Link: Esp32Link,
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)
@@ -114,7 +119,8 @@ 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)
if (usbSerialTransport.sendCamTx(bytes, pv)) { val signed = prefs.signOutgoingFlow.first()
if (esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes, pv, gnss.accuracyM, signed))) {
_sentCount.update { it + 1 } _sentCount.update { it + 1 }
} }
} }
@@ -6,13 +6,18 @@ 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.UsbSerialTransport import com.hawhamburg.micr0bu.data.transport.OutgoingIts
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec import com.hawhamburg.micr0bu.domain.asn1.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
@@ -45,12 +50,19 @@ 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 usbSerialTransport: UsbSerialTransport, private val esp32Link: Esp32Link,
private val codec: RealAsn1UperCodec, private val codec: RealAsn1UperCodec,
private val pseudonymManager: PseudonymManager, private val pseudonymManager: PseudonymManager,
) { ) {
@@ -67,6 +79,10 @@ 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
@@ -85,6 +101,7 @@ 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
@@ -102,10 +119,27 @@ 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()
@@ -114,10 +148,35 @@ 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)
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac)) esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes,
} 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)
} }
/** /**
@@ -158,6 +217,9 @@ 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,6 +20,8 @@ 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
@@ -39,7 +41,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.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.viewmodel.SensorUiState import com.hawhamburg.micr0bu.viewmodel.SensorUiState
private val GreenActive = Color(0xFF4CAF50) private val GreenActive = Color(0xFF4CAF50)
@@ -53,7 +55,9 @@ fun StatusTopBar(
state: SensorUiState, state: SensorUiState,
mqttConnectionState: MqttConnectionState, mqttConnectionState: MqttConnectionState,
isEsp32: Boolean = false, isEsp32: Boolean = false,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED, esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
esp32Bluetooth: Boolean = false,
) { ) {
TopAppBar( TopAppBar(
title = { title = {
@@ -93,7 +97,7 @@ fun StatusTopBar(
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState) ObuStatusIcon(mqttConnectionState, isEsp32, esp32LinkState, esp32Bluetooth = esp32Bluetooth)
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
@@ -106,20 +110,21 @@ 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 glyph there rather than the * report the OBU as offline while CAMs were streaming in. Uses a USB or Bluetooth glyph there
* Wi-Fi one, since that is literally what the connection is. * rather than the 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,
usbSerialState: UsbSerialState, esp32LinkState: Esp32LinkState,
esp32Bluetooth: Boolean,
) { ) {
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState val state = if (isEsp32) esp32LinkState.asConnectionState() else mqttState
val linkUp = state == MqttConnectionState.CONNECTED || state == MqttConnectionState.CONNECTING
val icon = when { val icon = when {
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb isEsp32 && esp32Bluetooth -> if (linkUp) Icons.Default.Bluetooth else Icons.Default.BluetoothDisabled
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb isEsp32 -> if (linkUp) Icons.Default.Usb else Icons.Default.UsbOff
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
@@ -194,10 +199,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 UsbSerialState.asConnectionState(): MqttConnectionState = when (this) { private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
UsbSerialState.DEVICE_ATTACHED, Esp32LinkState.DEVICE_ATTACHED,
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
UsbSerialState.ERROR -> MqttConnectionState.ERROR Esp32LinkState.ERROR -> MqttConnectionState.ERROR
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
} }
@@ -34,6 +34,8 @@ sealed class Screen(val route: String, val labelRes: Int) {
data object Connection : Screen("connection", R.string.nav_connection) data object Connection : Screen("connection", R.string.nav_connection)
data object Map : Screen("map", R.string.map_title) data object Map : Screen("map", R.string.map_title)
data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x) data object MqttViewer : Screen("mqtt_viewer", R.string.nav_v2x)
/** Full-screen V2X live map, opened from the V2X Monitor's map button. */
data object V2xMap : Screen("v2x_map", R.string.v2x_map_title)
// Phase A — Trip Recording // Phase A — Trip Recording
data object TripHistory : Screen("trip_history", R.string.nav_trips) data object TripHistory : Screen("trip_history", R.string.nav_trips)
@@ -83,6 +85,7 @@ private fun Screen.ownsRoute(route: String?): Boolean {
route == Screen.Map.route || route == Screen.Map.route ||
route == Screen.Sensors.route route == Screen.Sensors.route
Screen.Record -> route == Screen.Log.route Screen.Record -> route == Screen.Log.route
Screen.MqttViewer -> route == Screen.V2xMap.route
else -> false else -> false
} }
} }
@@ -44,7 +44,9 @@ 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.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import androidx.compose.material.icons.filled.Bluetooth
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
private val UsbGreen = Color(0xFF4CAF50) private val UsbGreen = Color(0xFF4CAF50)
@@ -65,7 +67,11 @@ 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 usbSerialState by viewModel.usbSerialState.collectAsState() val esp32LinkState by viewModel.esp32LinkState.collectAsState()
val esp32Transport by viewModel.esp32Transport.collectAsState()
val esp32Detail by viewModel.esp32Detail.collectAsState()
val stationStatus by viewModel.stationStatus.collectAsState()
val signOutgoing by viewModel.signOutgoing.collectAsState()
val isConnected = connectionState == MqttConnectionState.CONNECTED val isConnected = connectionState == MqttConnectionState.CONNECTED
val isConnecting = connectionState == MqttConnectionState.CONNECTING val isConnecting = connectionState == MqttConnectionState.CONNECTING
@@ -229,22 +235,22 @@ fun ConnectionSetupScreen(
} }
} else { } else {
// ── ESP32-C5 real connection card (Phase 03) ──────────────────────── // ── ESP32-C5 real connection card (Phase 03) ────────────────────────
// Backed by UsbSerialTransport (native USB Serial/JTAG CDC-ACM link) - see that // Backed by Esp32Link: USB (UsbSerialTransport, native USB Serial/JTAG CDC-ACM - see
// class's KDoc for the VID/PID (0x303A/0x1001) and native-vs-UART-bridge port note. // its KDoc for the VID/PID and native-vs-UART-bridge port note) or BLE (BleLinkTransport).
val isEspConnected = usbSerialState == UsbSerialState.CONNECTED val isEspConnected = esp32LinkState == Esp32LinkState.CONNECTED
val isEspBusy = usbSerialState == UsbSerialState.DEVICE_ATTACHED || val isEspBusy = esp32LinkState == Esp32LinkState.DEVICE_ATTACHED ||
usbSerialState == UsbSerialState.PERMISSION_REQUESTED esp32LinkState == Esp32LinkState.PERMISSION_REQUESTED
val espContainerColor = when { val espContainerColor = when {
isEspConnected -> UsbGreenBg isEspConnected -> UsbGreenBg
isEspBusy -> UsbAmberBg isEspBusy -> UsbAmberBg
else -> UsbGrayBg else -> UsbGrayBg
} }
val (espColor, espStateLabel) = when (usbSerialState) { val (espColor, espStateLabel) = when (esp32LinkState) {
UsbSerialState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected) Esp32LinkState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
UsbSerialState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached) Esp32LinkState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
UsbSerialState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested) Esp32LinkState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
UsbSerialState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error) Esp32LinkState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
UsbSerialState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected) Esp32LinkState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
} }
Card( Card(
@@ -257,7 +263,7 @@ fun ConnectionSetupScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Icon( Icon(
Icons.Default.Usb, if (esp32Transport == Esp32Transport.BLE) Icons.Default.Bluetooth else Icons.Default.Usb,
contentDescription = null, contentDescription = null,
tint = espColor, tint = espColor,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
@@ -285,11 +291,36 @@ 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) { if (isEspConnected || isEspBusy) {
OutlinedButton( OutlinedButton(
onClick = { viewModel.disconnectUsbSerial() }, onClick = { viewModel.disconnectEsp32() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFFFF5252), contentColor = Color(0xFFFF5252),
@@ -297,13 +328,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))
Text(stringResource(R.string.conn_disconnect)) // While connecting (BLE retries until it succeeds) this cancels the attempt.
Text(stringResource(if (isEspConnected) R.string.conn_disconnect else R.string.conn_esp32_cancel))
} }
} else { } else {
Button( Button(
onClick = { viewModel.connectUsbSerial() }, onClick = { viewModel.connectEsp32() },
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.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
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,7 +78,9 @@ 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,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED, esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
esp32Bluetooth: Boolean = false,
usbCableConnected: Boolean = false, usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false, obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null, obuStationType: Int? = null,
@@ -281,14 +283,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) usbSerialState == UsbSerialState.CONNECTED else mqttConnected val obuConnected = if (isEsp32) esp32LinkState == Esp32LinkState.CONNECTED else mqttConnected
val transportIcon = when (activeTransport) { val transportIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.Bluetooth else 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 = when (activeTransport) { val transportInactiveIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.BluetoothDisabled else 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
@@ -324,12 +326,12 @@ fun DashboardScreen(
) )
Text( Text(
text = if (isEsp32) { text = if (isEsp32) {
when (usbSerialState) { when (esp32LinkState) {
UsbSerialState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected) Esp32LinkState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
UsbSerialState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached) Esp32LinkState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
UsbSerialState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested) Esp32LinkState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
UsbSerialState.ERROR -> stringResource(R.string.conn_esp32_state_error) Esp32LinkState.ERROR -> stringResource(R.string.conn_esp32_state_error)
UsbSerialState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect) Esp32LinkState.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)
@@ -362,15 +364,16 @@ 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 = activeTransport == TransportType.USB_SERIAL, active = !esp32Bluetooth,
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 = activeTransport == TransportType.BLUETOOTH, active = isEsp32 && esp32Bluetooth,
dimmed = true, // Phase 03 — production BT transport still under discussion // Only the ESP32-C5 has a BLE link; the CiT One has none.
dimmed = !isEsp32,
) )
} }
} }
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.VerticalAlignBottom import androidx.compose.material.icons.filled.VerticalAlignBottom
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
@@ -69,7 +70,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.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
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
@@ -87,11 +88,15 @@ import java.util.Date
import java.util.Locale import java.util.Locale
/** /**
* View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST), the raw MQTT topic list * View toggle for [TopicListPane]: decoded CAM/DENM traffic (LIST) or the raw MQTT topic list
* (TOPICS, CiT One only - there is no broker on the ESP32-C5 path), or the V2X live map * (TOPICS, CiT One only - there is no broker on the ESP32-C5 path).
* (MAP, Section 13). *
* The live map used to be a third mode here. It is now its own full-screen destination
* ([V2xMapScreen]), reached from the map button in this screen's header: sharing the screen with
* the alert panel and the TX cards left the map about a third of a phone display tall, which is
* not enough to see where anything is relative to anything else.
*/ */
private enum class TopicViewMode { LIST, TOPICS, MAP } private enum class TopicViewMode { LIST, TOPICS }
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
@@ -116,6 +121,7 @@ private val WarningRedBg = Color(0xFF3A0A0A)
@Composable @Composable
fun MqttTopicViewerScreen( fun MqttTopicViewerScreen(
viewModel: MqttViewModel = hiltViewModel(), viewModel: MqttViewModel = hiltViewModel(),
onOpenMap: () -> Unit = {},
) { ) {
val connectionState by viewModel.connectionState.collectAsState() val connectionState by viewModel.connectionState.collectAsState()
val topicMessages by viewModel.topicMessages.collectAsState() val topicMessages by viewModel.topicMessages.collectAsState()
@@ -130,7 +136,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 usbSerialState by viewModel.usbSerialState.collectAsState() val esp32LinkState by viewModel.esp32LinkState.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()
@@ -155,7 +161,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) usbSerialState.asConnectionState() else connectionState val effectiveState = if (isEsp32) esp32LinkState.asConnectionState() else connectionState
val isConnected = effectiveState == MqttConnectionState.CONNECTED val isConnected = effectiveState == MqttConnectionState.CONNECTED
val isConnecting = effectiveState == MqttConnectionState.CONNECTING val isConnecting = effectiveState == MqttConnectionState.CONNECTING
@@ -193,14 +199,25 @@ 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.disconnectUsbSerial() if (isConnected || isConnecting) viewModel.disconnectEsp32()
else viewModel.connectUsbSerial() else viewModel.connectEsp32()
} else { } else {
if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect() if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect()
} }
@@ -236,7 +253,7 @@ fun MqttTopicViewerScreen(
isEsp32 = isEsp32, isEsp32 = isEsp32,
denmEvents = denmEvents, denmEvents = denmEvents,
spatIntersections = spatIntersections, spatIntersections = spatIntersections,
usbSerialState = usbSerialState, esp32LinkState = esp32LinkState,
camPingerActive = camPingerActive, camPingerActive = camPingerActive,
camPingerSentCount = camPingerSentCount, camPingerSentCount = camPingerSentCount,
camPingerHasFix = camPingerHasFix, camPingerHasFix = camPingerHasFix,
@@ -277,7 +294,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(),
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED, esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
camPingerActive: Boolean = false, camPingerActive: Boolean = false,
camPingerSentCount: Int = 0, camPingerSentCount: Int = 0,
camPingerHasFix: Boolean = false, camPingerHasFix: Boolean = false,
@@ -321,7 +338,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 = usbSerialState == UsbSerialState.CONNECTED, usbConnected = esp32LinkState == Esp32LinkState.CONNECTED,
pingerActive = camPingerActive, pingerActive = camPingerActive,
sentCount = camPingerSentCount, sentCount = camPingerSentCount,
hasFix = camPingerHasFix, hasFix = camPingerHasFix,
@@ -335,12 +352,15 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
} }
// ── List / Topics / Map toggle ──────────────────────────────────────────────────── // ── List / Topics toggle ──────────────────────────────────────────────
// Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is // Decoded traffic is the default on BOTH hardware paths: what a tester wants to see is
// the road users and hazards, not the transport that carried them. The raw MQTT topic // the road users and hazards, not the transport that carried them. The raw MQTT topic
// list stays one tap away on the CiT One path (Section 13 asks for the map "in addition // list stays one tap away on the CiT One path (Section 13 asks for the map "in addition
// to", not instead of, the topic list). It is hidden on the ESP32-C5 path, where there is // to", not instead of, the topic list).
// no broker and `topics` is permanently empty. //
// The whole row is hidden on the ESP32-C5 path: there is no broker there, `topics` is
// permanently empty, and a toggle offering a single choice is just noise.
if (!isEsp32) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -350,34 +370,20 @@ private fun TopicListPane(
selected = viewMode == TopicViewMode.LIST, selected = viewMode == TopicViewMode.LIST,
) { viewMode = TopicViewMode.LIST } ) { viewMode = TopicViewMode.LIST }
if (!isEsp32) {
ViewModeButton( ViewModeButton(
label = stringResource(R.string.mqtt_view_topics), label = stringResource(R.string.mqtt_view_topics),
selected = viewMode == TopicViewMode.TOPICS, selected = viewMode == TopicViewMode.TOPICS,
) { viewMode = TopicViewMode.TOPICS } ) { viewMode = TopicViewMode.TOPICS }
} }
ViewModeButton(
label = stringResource(R.string.mqtt_view_map),
selected = viewMode == TopicViewMode.MAP,
) { viewMode = TopicViewMode.MAP }
} }
// ── Decoded traffic / raw topics / live map ─────────────────────────── // ── Decoded traffic / raw topics ──────────────────────────────────────
// TOPICS can still be the saved selection from a CiT One session after switching hardware // TOPICS can still be the saved selection from a CiT One session after switching hardware
// to the ESP32-C5, where that button no longer exists - fall back to the decoded list // to the ESP32-C5, where that button no longer exists - fall back to the decoded list
// rather than stranding the user on a pane they can't navigate away from. // rather than stranding the user on a pane they can't navigate away from.
val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode val shownMode = if (viewMode == TopicViewMode.TOPICS && isEsp32) TopicViewMode.LIST else viewMode
if (shownMode == TopicViewMode.MAP) { if (shownMode == TopicViewMode.LIST) {
V2xLiveMapView(
own = ownCamPosition,
remotes = remoteCamPositions,
alerts = useCaseAlerts,
denms = denmEvents,
modifier = Modifier.fillMaxSize(),
)
} else if (shownMode == TopicViewMode.LIST) {
ReceivedCamPane( ReceivedCamPane(
own = ownCamPosition, own = ownCamPosition,
remotes = remoteCamPositions, remotes = remoteCamPositions,
@@ -1213,11 +1219,12 @@ private fun CamPingerCard(
Text( Text(
stringResource( stringResource(
R.string.mqtt_cam_pinger_fw_counters, R.string.mqtt_cam_pinger_fw_counters,
s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.txFailures, s.oversizeDrops, s.rxCrcErrors, s.rxQueueDrops,
), ),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0) color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0 ||
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant, s.rxQueueDrops > 0
) ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
) )
} }
@@ -1466,10 +1473,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 UsbSerialState.asConnectionState(): MqttConnectionState = when (this) { private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
UsbSerialState.DEVICE_ATTACHED, Esp32LinkState.DEVICE_ATTACHED,
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
UsbSerialState.ERROR -> MqttConnectionState.ERROR Esp32LinkState.ERROR -> MqttConnectionState.ERROR
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
} }
@@ -47,7 +47,9 @@ 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
@@ -165,13 +167,19 @@ 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). Everything // OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). The transport
// below (transport, USB-C options) only really applies to CiT One; ESP32-C5 uses // cards below apply to the CiT One; the ESP32-C5 has its own card (USB-C or BLE,
// USB Serial exclusively and has no transport choice to make here. // CAM or VAM, signing).
Text( Text(
stringResource(R.string.settings_obu_hardware), stringResource(R.string.settings_obu_hardware),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -212,6 +220,48 @@ fun ConnectionSettingsScreen(
} }
} }
if (obuHardware == ObuHardware.ESP32_C5) {
SectionCard {
TwoWayChoice(
label = stringResource(R.string.settings_esp32_link),
first = stringResource(R.string.settings_transport_usbc),
second = stringResource(R.string.settings_esp32_link_ble),
firstSelected = esp32Transport == Esp32Transport.USB,
onFirst = { onEsp32TransportChange(Esp32Transport.USB) },
onSecond = { onEsp32TransportChange(Esp32Transport.BLE) },
)
if (esp32Transport == Esp32Transport.BLE) {
Text(
stringResource(R.string.settings_esp32_link_ble_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Divider()
TwoWayChoice(
label = stringResource(R.string.settings_esp32_message),
first = stringResource(R.string.settings_esp32_message_cam),
second = stringResource(R.string.settings_esp32_message_vam),
firstSelected = outgoingMessage == OutgoingMessage.CAM,
onFirst = { onOutgoingMessageChange(OutgoingMessage.CAM) },
onSecond = { onOutgoingMessageChange(OutgoingMessage.VAM) },
)
Divider()
SettingToggleRow(
label = stringResource(R.string.settings_esp32_sign),
checked = signOutgoing,
onCheckedChange = onSignOutgoingChange,
)
Text(
stringResource(R.string.settings_esp32_sign_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
if (obuHardware == ObuHardware.CIT_ONE) { if (obuHardware == ObuHardware.CIT_ONE) {
SectionCard { SectionCard {
// Active transport selector // Active transport selector
@@ -611,6 +661,40 @@ private fun LanguageSection() {
} }
} }
/** A labelled pair of outlined buttons, the selected one filled — the style of the OBU hardware picker. */
@Composable
private fun TwoWayChoice(
label: String,
first: String,
second: String,
firstSelected: Boolean,
onFirst: () -> Unit,
onSecond: () -> Unit,
) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for ((text, selected, onClick) in listOf(Triple(first, firstSelected, onFirst), Triple(second, !firstSelected, onSecond))) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(text, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) }
}
}
}
@Composable @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,12 +1,13 @@
package com.hawhamburg.micr0bu.ui.screens package com.hawhamburg.micr0bu.ui.screens
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable
import android.view.MotionEvent
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GpsOff import androidx.compose.material.icons.filled.GpsOff
@@ -30,25 +31,37 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.domain.cam.Cam import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.denm.DenmEvent import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
/** /**
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus * V2X Monitor live map (Phase 03, Section 13) — the map body behind [V2xMapScreen], plotting the
* every currently-tracked remote road user's last-known CAM position, in addition to (not * ego bike's own position, every currently-tracked remote road user's last-known CAM position,
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as * every live hazard (DENM) and every signalised intersection heard over SPATEM.
* [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here
* always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]
* currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc).
* *
* Remote markers are colored by that station's most severe active alert level, if any, so a * Marker vocabulary, one shape per message type so the map reads without a legend:
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same * - CAM — teardrop pin, tinted by that station's most severe active alert level
* severity coloring already used by [UseCaseAlertPanel]. * - DENM — hazard warning triangle
* - SPATEM — traffic light, with the lamp for the intersection's leading phase lit
*
* Unlike [MapScreen] this has no phone-GNSS-only fallback: [own] always reflects whichever ego
* source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] currently trusts (obu_gnss /
* phone GNSS / CAM-topic-own — see that class's KDoc).
*
* **Markers are reused across updates, not rebuilt.** CAMs arrive at up to 10 Hz per station, and
* every arrival recomposes this view; the previous version cleared the overlay list and rebuilt
* every Marker — decoding and mutating a fresh Drawable per marker per update — which is what
* made panning stutter under live traffic. Drawables are now loaded once per level/phase and
* shared (osmdroid sets the icon's bounds on each draw, so sharing one instance across markers is
* safe), and Marker objects are cached by key. The overlay list is still reordered each update,
* which costs nothing: it moves existing references, it does not allocate.
*/ */
@Composable @Composable
fun V2xLiveMapView( fun V2xLiveMapView(
@@ -56,6 +69,9 @@ fun V2xLiveMapView(
remotes: Map<Long, Cam>, remotes: Map<Long, Cam>,
alerts: List<UseCaseAlert>, alerts: List<UseCaseAlert>,
denms: List<DenmEvent> = emptyList(), denms: List<DenmEvent> = emptyList(),
spats: List<SpatIntersection> = emptyList(),
followOwn: Boolean = true,
onUserPanned: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -71,8 +87,17 @@ fun V2xLiveMapView(
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel } .mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
} }
// Loaded once and shared by every marker that needs them. mutate() on the remote pin is still
// essential: without it all four tinted copies would share one ConstantState and the last
// tint applied would recolour every pin on the map.
val icons = remember(context) { MapIcons(context) }
val markers = remember { mutableMapOf<String, Marker>() }
val mapViewRef = remember { mutableStateOf<MapView?>(null) } val mapViewRef = remember { mutableStateOf<MapView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
// Tracks whether the last update already recentred for this follow session, so re-enabling
// follow animates once instead of fighting the rider's own panning on every frame.
val wasFollowing = remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
@@ -89,42 +114,58 @@ fun V2xLiveMapView(
} }
} }
Column(modifier = modifier.fillMaxSize()) {
Text(
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
initOsmForV2xMap(ctx) initOsmForV2xMap(ctx)
MapView(ctx).apply { MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK) setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true) setMultiTouchControls(true)
// Raster tiles are authored for ~160 dpi; without this they are upscaled by the
// display density and labels come out soft on a modern phone.
isTilesScaledToDpi = true
// The floating +/- buttons sit exactly where the rider's thumb lands and
// duplicate pinch-zoom. Pinch and double-tap still work.
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
setMinZoomLevel(4.0)
setMaxZoomLevel(20.0)
controller.setZoom(17.0) controller.setZoom(17.0)
controller.setCenter(ownGeoPoint) controller.setCenter(ownGeoPoint)
// Any touch means the rider is driving the map; follow-own hands over to them
// until they ask for it back. false: the MapView's own gesture handling still
// runs, this only observes.
setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) onUserPanned()
false
}
mapViewRef.value = this mapViewRef.value = this
} }
}, },
update = { mv -> update = { mv ->
mv.overlays.clear() val now = System.currentTimeMillis()
// Own position: a centred "you are here" dot, not a pin. Own position is a fact // Intersections we can actually place: SPATEM carries signal state but no geometry
// about the viewer rather than one of the tracked objects, and when both used // (that is MAPEM's job), so the only position available is the sending RSU's own CAM.
// osmdroid's identical default pin the two were indistinguishable at a glance. val locatedSpats = spats.mapNotNull { spat ->
mv.overlays.add( remotes[spat.stationId]?.let { rsu -> spat to rsu }
Marker(mv).apply { }
// An RSU drawn as a traffic light must not also be drawn as a CAM pin underneath it:
// two markers on one point, the lower one unreachable.
val spatStationIds = locatedSpats.map { (spat, _) -> spat.stationId }.toSet()
val live = mutableSetOf<String>()
// Own position: a centred "you are here" dot, not a pin. Own position is a fact about
// the viewer rather than one of the tracked objects, and when both used osmdroid's
// identical default pin the two were indistinguishable at a glance.
markers.marker(mv, KEY_OWN, live).apply {
position = ownGeoPoint position = ownGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = ContextCompat.getDrawable(context, R.drawable.ic_map_own) icon = icons.own
title = context.getString(R.string.v2x_map_own_label) title = context.getString(R.string.v2x_map_own_label)
} }
)
remotes.forEach { (stationId, cam) -> remotes.forEach { (stationId, cam) ->
if (stationId in spatStationIds) return@forEach
val level = alertByStation[stationId] val level = alertByStation[stationId]
val label = when (level) { val label = when (level) {
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId) AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
@@ -132,32 +173,23 @@ fun V2xLiveMapView(
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId) AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
null -> context.getString(R.string.v2x_map_remote_plain, stationId) null -> context.getString(R.string.v2x_map_remote_plain, stationId)
} }
// Teardrop pin anchored at its tip, tinted by severity. Now that these are // Teardrop pin anchored at its tip, tinted by severity, so severity no longer
// custom drawables, per-instance tinting is possible - severity no longer // depends on tapping the marker to read its label.
// depends on tapping the marker to read its label. mutate() is essential: markers.marker(mv, "$KEY_CAM$stationId", live).apply {
// without it every marker shares one ConstantState and the last tint applied
// would recolour all of them.
val pin = ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
?.mutate()
?.apply { setTint(level.toMarkerColor()) }
mv.overlays.add(
Marker(mv).apply {
position = GeoPoint(cam.latitude, cam.longitude) position = GeoPoint(cam.latitude, cam.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = pin icon = icons.remotePin(level)
title = label title = label
} }
)
} }
// DENM hazard pins, added last so they draw on top of vehicle markers - a hazard // Hazards and signals are added after the vehicle pins, so they draw on top: a hazard
// hidden behind a CAM pin defeats the point of showing it. // hidden behind a CAM pin defeats the point of showing it.
denms.forEach { denm -> denms.forEach { denm ->
mv.overlays.add( markers.marker(mv, "$KEY_DENM${denm.dedupKey}", live).apply {
Marker(mv).apply {
position = GeoPoint(denm.latitude, denm.longitude) position = GeoPoint(denm.latitude, denm.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning) icon = icons.denm
title = denm.causeCode?.let { title = denm.causeCode?.let {
context.getString( context.getString(
R.string.v2x_map_denm_labeled, R.string.v2x_map_denm_labeled,
@@ -167,10 +199,45 @@ fun V2xLiveMapView(
) )
} ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId) } ?: context.getString(R.string.v2x_map_denm_plain, denm.stationId)
} }
)
} }
mv.controller.animateTo(ownGeoPoint) locatedSpats.forEach { (spat, rsu) ->
val phase = spat.leadingPhase(now)
markers.marker(mv, "$KEY_SPAT${spat.key}", live).apply {
position = GeoPoint(rsu.latitude, rsu.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = icons.signal(phase)
title = context.getString(
R.string.v2x_spat_rx_title, spat.state.key, spat.stationId,
)
snippet = spat.state.movements.joinToString(" · ") { movement ->
val seconds = movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
context.getString(R.string.v2x_spat_group, movement.signalGroup) +
(seconds?.let { " " + context.getString(R.string.v2x_spat_countdown, it) } ?: "")
}
}
}
// Drop markers for stations, hazards and intersections that have expired, then rebuild
// the overlay list in draw order from the cached Markers. Reordering moves references;
// nothing here allocates a Marker or decodes a Drawable.
markers.keys.retainAll { key ->
(key in live).also { kept -> if (!kept) markers[key]?.closeInfoWindow() }
}
mv.overlays.clear()
markers.entries
.sortedBy { (key, _) -> key.drawOrder() }
.forEach { (_, marker) -> mv.overlays.add(marker) }
// setCenter, not animateTo: an animation restarted on every CAM never finishes, which
// is exactly the judder this used to show under live traffic. The one animated move is
// the rider re-enabling follow, where the travel is worth seeing.
if (followOwn) {
if (wasFollowing.value) mv.controller.setCenter(ownGeoPoint)
else mv.controller.animateTo(ownGeoPoint)
}
wasFollowing.value = followOwn
mv.invalidate() mv.invalidate()
}, },
// osmdroid's onDetach() permanently tears the MapView down: afterwards its // osmdroid's onDetach() permanently tears the MapView down: afterwards its
@@ -186,9 +253,97 @@ fun V2xLiveMapView(
// //
// onRelease is the callback that actually means "this View is gone": Compose // onRelease is the callback that actually means "this View is gone": Compose
// guarantees no further update after it. // guarantees no further update after it.
onRelease = { it.onDetach() }, onRelease = {
modifier = Modifier.fillMaxSize(), markers.clear()
it.onDetach()
},
modifier = modifier.fillMaxSize(),
) )
}
// ── Marker cache ──────────────────────────────────────────────────────────────
private const val KEY_OWN = "own"
private const val KEY_CAM = "cam:"
private const val KEY_DENM = "denm:"
private const val KEY_SPAT = "spat:"
/** Draw order: own dot at the bottom, then vehicles, with hazards and signals on top. */
private fun String.drawOrder(): Int = when {
this == KEY_OWN -> 0
startsWith(KEY_CAM) -> 1
startsWith(KEY_DENM) -> 2
else -> 3
}
/**
* The cached [Marker] for [key], created against [mv] on first use, recording the key in [live]
* so the caller can drop whatever it did not ask for this update.
*/
private fun MutableMap<String, Marker>.marker(
mv: MapView,
key: String,
live: MutableSet<String>,
): Marker {
live += key
return getOrPut(key) { Marker(mv) }
}
/**
* Marker artwork, loaded once per composition rather than per update.
*
* The remote pin is drawn white and tinted per severity here; [mutate] is what keeps the four
* tinted copies independent, since without it they would share one ConstantState and the last
* tint applied would recolour all of them.
*/
private class MapIcons(context: Context) {
val own: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_map_own)
val denm: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_denm_warning)
private val pins: Map<AlertLevel?, Drawable?> =
(listOf(null) + AlertLevel.entries).associateWith { level ->
ContextCompat.getDrawable(context, R.drawable.ic_map_remote_station)
?.mutate()
?.apply { setTint(level.toMarkerColor()) }
}
private val signals: Map<SignalLamp, Drawable?> = SignalLamp.entries.associateWith { lamp ->
ContextCompat.getDrawable(context, lamp.drawableRes)
}
fun remotePin(level: AlertLevel?): Drawable? = pins[level]
fun signal(lamp: SignalLamp): Drawable? = signals[lamp]
}
// ── Signal phase → lamp ───────────────────────────────────────────────────────
/** Which lamp of the traffic-light marker is lit. */
private enum class SignalLamp(val drawableRes: Int) {
RED(R.drawable.ic_map_spat_red),
AMBER(R.drawable.ic_map_spat_amber),
GREEN(R.drawable.ic_map_spat_green),
DARK(R.drawable.ic_map_spat_dark),
}
/**
* The lamp to light for this intersection.
*
* Without MAPEM there is no lane geometry, so there is no way to know which of an intersection's
* signal groups applies to the rider's own approach. This follows the rule the Dashboard's
* SignalCard already uses — the group changing soonest speaks for the intersection — so the same
* intersection reads the same way in both places rather than inventing a second convention.
*/
private fun SpatIntersection.leadingPhase(nowMs: Long): SignalLamp {
val leading = state.movements.minByOrNull { movement ->
movement.current?.secondsUntil(nowMs)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
}
val phase = leading?.current?.phase
return when {
phase == null -> SignalLamp.DARK
phase.isGo -> SignalLamp.GREEN
phase.isStop -> SignalLamp.RED
phase.isTransition -> SignalLamp.AMBER
else -> SignalLamp.DARK // UNAVAILABLE / DARK / caution
} }
} }
@@ -228,5 +383,10 @@ private fun initOsmForV2xMap(context: Context) {
Configuration.getInstance().apply { Configuration.getInstance().apply {
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
userAgentValue = context.packageName userAgentValue = context.packageName
// Panning off the edge of the cache is what makes a raster map feel slow: the default
// 600 MB cap is plenty, but the default 2 download threads are not when a pan exposes a
// screenful of new tiles at once.
tileDownloadThreads = 6.toShort()
tileFileSystemThreads = 6.toShort()
} }
} }
@@ -0,0 +1,203 @@
package com.hawhamburg.micr0bu.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Traffic
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
/**
* Full-screen V2X live map — the map and nothing else, reached from the map button on the V2X
* Monitor screen.
*
* Identical on both hardware paths. Everything drawn here comes from
* [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository], which already merges the CiT One's MQTT
* feed and the ESP32-C5's serial feed into one set of flows, so this screen never has to know
* which OBU is connected.
*
* The chrome is deliberately minimal and floats over the map rather than boxing it in: a back
* button, a live count per message type, and a recentre button. The counts double as the map's
* legend — each one carries the same icon family as the marker it counts.
*/
@Composable
fun V2xMapScreen(
viewModel: MqttViewModel,
onBack: () -> Unit,
) {
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
// Road users from the detection engine PLUS roadside units, which it deliberately does not
// track - an RSU is what carries the traffic lights below.
val stations by viewModel.stationsInRange.collectAsState()
val alerts by viewModel.useCaseAlerts.collectAsState()
val denms by viewModel.denmEvents.collectAsState()
val spats by viewModel.spatIntersections.collectAsState()
// Follow is on until the rider touches the map, and comes back when they ask for it. Without
// the hand-over, every incoming CAM would drag the viewport back to the ego position and the
// map could not be panned at all while traffic is flowing.
var followOwn by remember { mutableStateOf(true) }
// SPATEM carries no geometry of its own, so an intersection can only be placed if its RSU has
// also been heard over CAM. Saying so is better than silently dropping it: "the map shows two
// of the three lights I can see in the list" is otherwise an unexplained discrepancy.
val unlocatedSpats = spats.count { it.stationId !in stations.keys }
Box(modifier = Modifier.fillMaxSize()) {
V2xLiveMapView(
own = ownCamPosition,
remotes = stations,
alerts = alerts,
denms = denms,
spats = spats,
followOwn = followOwn,
onUserPanned = { followOwn = false },
modifier = Modifier.fillMaxSize(),
)
// ── Floating header: back + live counts, which double as the legend ──
Row(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MapChrome {
IconButton(onClick = onBack, modifier = Modifier.size(36.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.v2x_map_back),
)
}
}
Spacer(Modifier.width(8.dp))
MapChrome {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MapCount(
icon = Icons.Default.Place,
tint = CamPinBlue,
count = stations.size,
label = stringResource(R.string.v2x_map_legend_cam),
)
MapCount(
icon = Icons.Default.Warning,
tint = HazardAmber,
count = denms.size,
label = stringResource(R.string.v2x_map_legend_denm),
)
MapCount(
icon = Icons.Default.Traffic,
tint = SignalGreenDot,
count = spats.size,
label = stringResource(R.string.v2x_map_legend_spat),
)
}
}
}
if (unlocatedSpats > 0) {
MapChrome(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(12.dp),
) {
Text(
text = stringResource(R.string.v2x_map_spat_unlocated, unlocatedSpats),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
// Recentre: lit while following, so the button also reports which mode the map is in.
FloatingActionButton(
onClick = { followOwn = true },
containerColor = if (followOwn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) {
Icon(
Icons.Default.MyLocation,
contentDescription = stringResource(R.string.v2x_map_follow),
tint = if (followOwn) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** A translucent pill for anything floating over the map, so chrome stays readable over tiles. */
@Composable
private fun MapChrome(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
tonalElevation = 3.dp,
shadowElevation = 2.dp,
modifier = modifier,
) { content() }
}
@Composable
private fun MapCount(icon: ImageVector, tint: Color, count: Int, label: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(16.dp))
Text(
text = count.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
// Legend tints, matching the marker artwork rather than the theme: these name the drawables on
// the map, so they must not shift with light/dark mode the way theme colours do.
private val CamPinBlue = Color(0xFF78909C)
private val HazardAmber = Color(0xFFFFC107)
private val SignalGreenDot = Color(0xFF4CAF50)
@@ -13,8 +13,11 @@ 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.UsbSerialState import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.data.transport.StationStatus
import com.hawhamburg.micr0bu.domain.denm.DenmEvent import com.hawhamburg.micr0bu.domain.denm.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
@@ -45,7 +48,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 usbSerialTransport: UsbSerialTransport, private val esp32Link: Esp32Link,
private val camPinger: CamPinger, private val camPinger: CamPinger,
) : ViewModel() { ) : ViewModel() {
@@ -80,14 +83,42 @@ 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 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */ /** ESP32-C5 link state, over USB or BLE per [esp32Transport] — see [Esp32Link]. */
val usbSerialState: StateFlow<UsbSerialState> = usbSerialTransport.state val esp32LinkState: StateFlow<Esp32LinkState> = esp32Link.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?> = usbSerialTransport.linkStatus val espLinkStatus: StateFlow<EspLinkStatus?> = esp32Link.linkStatus
/** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */ /** Non-zero means CAMs are being built and dropped — see [Esp32Link.send]. */
val camSendFailures: StateFlow<Int> = usbSerialTransport.consecutiveWriteFailures val camSendFailures: StateFlow<Int> = esp32Link.consecutiveWriteFailures
/** Signing and radio counters of the current obu-firmware; null with the previous firmware. */
val stationStatus: StateFlow<StationStatus?> = esp32Link.stationStatus
/** One line about the link session (pairing passkey, provisioning, refusals); null when quiet. */
val esp32Detail: StateFlow<String?> = esp32Link.detail
// ── ESP32-C5 settings ─────────────────────────────────────────────────────
val esp32Transport: StateFlow<Esp32Transport> = esp32Link.transport
fun setEsp32Transport(transport: Esp32Transport) {
viewModelScope.launch { obuHardwarePrefs.setEsp32Transport(transport) }
}
val outgoingMessage: StateFlow<OutgoingMessage> = obuHardwarePrefs.outgoingMessageFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, OutgoingMessage.CAM)
fun setOutgoingMessage(message: OutgoingMessage) {
viewModelScope.launch { obuHardwarePrefs.setOutgoingMessage(message) }
}
val signOutgoing: StateFlow<Boolean> = obuHardwarePrefs.signOutgoingFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setSignOutgoing(sign: Boolean) {
viewModelScope.launch { obuHardwarePrefs.setSignOutgoing(sign) }
}
// ── ESP32-C5 CAM pinger (manual bench test, Phase 03) ───────────────────── // ── 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-
@@ -352,10 +383,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 USB-serial link — separate from [connect]/[disconnect], /** Connect/disconnect the ESP32-C5 link (USB or BLE per [esp32Transport]) — separate from
* which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. See [ConnectionSetupScreen]. */ * [connect]/[disconnect], which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. */
fun connectUsbSerial() = usbSerialTransport.connect() fun connectEsp32() = esp32Link.connect()
fun disconnectUsbSerial() = usbSerialTransport.disconnect() fun disconnectEsp32() = esp32Link.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 }
@@ -389,7 +420,7 @@ class MqttViewModel @Inject constructor(
super.onCleared() super.onCleared()
repo.disconnect() repo.disconnect()
camPinger.stop() camPinger.stop()
// Deliberately NOT usbSerialTransport.disconnect(): the transport is an app-scoped // Deliberately NOT esp32Link.disconnect(): the link is an app-scoped
// @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop). // @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
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FF5A2220"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FFFFC107"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF1E4D2B"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FF5A2220"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4A2E1C"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF1E4D2B"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FF5A2220"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4A2E1C"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4CAF50"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
@@ -0,0 +1,38 @@
<!--
Live-map marker for a signalised intersection heard over SPATEM: a traffic light housing with
the lamp for the intersection's leading phase lit and the other two dimmed.
One drawable per lit lamp rather than one drawable tinted at runtime: setTint recolours every
path in a vector, so a single shared asset could not keep the unlit lamps dark while colouring
the lit one - the whole light would turn one flat colour and stop reading as a traffic light.
Anchored at the bottom in V2xLiveMapView, so the housing sits above the intersection rather
than covering it.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- White outline first, so the marker stays legible over dark map features. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4.4,0.8H19.6V23.2H4.4z" />
<!-- Housing. -->
<path
android:fillColor="#FF263238"
android:pathData="M5.8,2.0H18.2V22.0H5.8z" />
<!-- Lamps, top to bottom: red, amber, green. -->
<path
android:fillColor="#FFFF5252"
android:pathData="M12,6.6m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF4A2E1C"
android:pathData="M12,12.0m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
<path
android:fillColor="#FF1E4D2B"
android:pathData="M12,17.4m-2.6,0a2.6,2.6 0 1,0 5.2,0a2.6,2.6 0 1,0 -5.2,0" />
</vector>
+22 -3
View File
@@ -142,12 +142,18 @@
<string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string> <string name="gnss_no_fix">Noch kein GPS-Signal - gehen Sie ins Freie</string>
<string name="map_title">Standortkarte</string> <string name="map_title">Standortkarte</string>
<string name="map_location_label">Aktueller Standort</string> <string name="map_location_label">Aktueller Standort</string>
<string name="v2x_map_remote_count">%1$d erfasste externe Verkehrsteilnehmer</string>
<string name="v2x_map_own_label">Eigen (Ego)</string> <string name="v2x_map_own_label">Eigen (Ego)</string>
<string name="v2x_map_remote_plain">Extern #%1$d</string> <string name="v2x_map_remote_plain">Extern #%1$d</string>
<string name="v2x_map_remote_info">Extern #%1$d · Info</string> <string name="v2x_map_remote_info">Extern #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string> <string name="v2x_map_remote_awareness">Extern #%1$d · Aufmerksamkeit</string>
<string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string> <string name="v2x_map_remote_warning">Extern #%1$d · Warnung</string>
<string name="v2x_map_title">V2X-Live-Karte</string>
<string name="v2x_map_back">Zurück</string>
<string name="v2x_map_follow">Auf eigene Position zentrieren</string>
<string name="v2x_map_legend_cam">Verkehrsteilnehmer (CAM)</string>
<string name="v2x_map_legend_denm">Gefahren (DENM)</string>
<string name="v2x_map_legend_spat">Signale (SPATEM)</string>
<string name="v2x_map_spat_unlocated">%1$d Signal(e) nicht dargestellt - Senderposition unbekannt</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Einstellungen</string> <string name="settings_title">Einstellungen</string>
@@ -183,7 +189,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">Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string> <string name="settings_obu_hardware_esp32_note">Das Smartphone erstellt CAM oder VAM und übergibt sie per USB-C oder Bluetooth an den ESP32-C5; der ESP32 ergänzt GeoNetworking, signiert (optional) und sendet über ITS-G5. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
<string name="settings_usb_transport">Aktiver Transport</string> <string name="settings_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>
@@ -197,7 +203,6 @@
<string name="mqtt_no_topics">Noch keine Nachrichten</string> <string name="mqtt_no_topics">Noch keine Nachrichten</string>
<string name="mqtt_view_list">Liste</string> <string name="mqtt_view_list">Liste</string>
<string name="mqtt_view_topics">Topics</string> <string name="mqtt_view_topics">Topics</string>
<string name="mqtt_view_map">Karte</string>
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string> <string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string> <string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
@@ -313,4 +318,18 @@
<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>
+23 -4
View File
@@ -143,12 +143,18 @@
<string name="gnss_no_fix">No GPS fix yet - move to an open area</string> <string name="gnss_no_fix">No GPS fix yet - move to an open area</string>
<string name="map_title">Location Map</string> <string name="map_title">Location Map</string>
<string name="map_location_label">Current Location</string> <string name="map_location_label">Current Location</string>
<string name="v2x_map_remote_count">%1$d tracked remote road user(s)</string>
<string name="v2x_map_own_label">Own (ego)</string> <string name="v2x_map_own_label">Own (ego)</string>
<string name="v2x_map_remote_plain">Remote #%1$d</string> <string name="v2x_map_remote_plain">Remote #%1$d</string>
<string name="v2x_map_remote_info">Remote #%1$d · Info</string> <string name="v2x_map_remote_info">Remote #%1$d · Info</string>
<string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string> <string name="v2x_map_remote_awareness">Remote #%1$d · Awareness</string>
<string name="v2x_map_remote_warning">Remote #%1$d · Warning</string> <string name="v2x_map_remote_warning">Remote #%1$d · Warning</string>
<string name="v2x_map_title">V2X Live Map</string>
<string name="v2x_map_back">Back</string>
<string name="v2x_map_follow">Centre on own position</string>
<string name="v2x_map_legend_cam">Road users (CAM)</string>
<string name="v2x_map_legend_denm">Hazards (DENM)</string>
<string name="v2x_map_legend_spat">Signals (SPATEM)</string>
<string name="v2x_map_spat_unlocated">%1$d signal(s) not shown - sender position unknown</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
@@ -184,7 +190,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">ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string> <string name="settings_obu_hardware_esp32_note">The phone builds CAM or VAM and hands it to the ESP32-C5 over USB-C or Bluetooth; the ESP32 adds GeoNetworking, signs it (optional) and broadcasts it over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
<string name="settings_usb_transport">Active transport</string> <string name="settings_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>
@@ -198,7 +204,6 @@
<string name="mqtt_no_topics">No messages yet</string> <string name="mqtt_no_topics">No messages yet</string>
<string name="mqtt_view_list">List</string> <string name="mqtt_view_list">List</string>
<string name="mqtt_view_topics">Topics</string> <string name="mqtt_view_topics">Topics</string>
<string name="mqtt_view_map">Map</string>
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string> <string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
<string name="mqtt_no_messages">No messages on this topic yet</string> <string name="mqtt_no_messages">No messages on this topic yet</string>
@@ -259,7 +264,7 @@
<string name="mqtt_cam_pinger_active">Pinging - 1 CAM/s over the serial link</string> <string name="mqtt_cam_pinger_active">Pinging - 1 CAM/s over the serial link</string>
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string> <string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string> <string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive - CAMs are not reaching the ESP32</string>
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d</string> <string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d · rx queue drop %4$d</string>
<string name="mqtt_cam_pinger_loopback">Own TX heard back: %1$d frames · %2$d dBm</string> <string name="mqtt_cam_pinger_loopback">Own TX heard back: %1$d frames · %2$d dBm</string>
<string name="mqtt_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string> <string name="mqtt_cam_pinger_loopback_no_rssi">Own TX heard back: %1$d frames</string>
<string name="mqtt_start_pinger">Start Pinger</string> <string name="mqtt_start_pinger">Start Pinger</string>
@@ -320,4 +325,18 @@
<!-- 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,6 +166,21 @@ class CamTxPvSerialTest {
assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv) assertFalse(EspLinkStatus.parse("0000000000000002".hexToBytes())!!.supportsCamTxPv)
} }
@Test
fun `firmware that predates the rx queue drop counter reports zero`() {
// 8-byte heartbeat (status + counters + capabilities, no rx queue drops tail).
val status = EspLinkStatus.parse("0000000000000001".hexToBytes())!!
assertEquals(0, status.rxQueueDrops)
}
@Test
fun `rx queue drops are read little-endian from the 10-byte payload`() {
// status=0, oversize=0, txFail=0, rxCrc=0, capabilities=0x01, rxQueueDrops=0x0102 (LE: 02 01)
val status = EspLinkStatus.parse("00000000000000010201".hexToBytes())!!
assertEquals(0x0102, status.rxQueueDrops)
assertTrue(status.supportsCamTxPv)
}
private val mac = "024d49435230".hexToBytes() private val mac = "024d49435230".hexToBytes()
private fun vectorAt(tstMs: Long) = GnPositionVector( private fun vectorAt(tstMs: Long) = GnPositionVector(
@@ -0,0 +1,119 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.data.transport.BtpDataRequest
import com.hawhamburg.micr0bu.data.transport.LinkMessage
import com.hawhamburg.micr0bu.data.transport.LinkOpcode
import com.hawhamburg.micr0bu.data.transport.LinkResult
import com.hawhamburg.micr0bu.data.transport.LinkSecurityProfile
import com.hawhamburg.micr0bu.data.transport.PotiUpdate
import com.hawhamburg.micr0bu.data.transport.StationConfigure
import com.hawhamburg.micr0bu.data.transport.StationInfo
import com.hawhamburg.micr0bu.data.transport.StationStatus
import com.hawhamburg.micr0bu.data.transport.credentialsSegment
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the phone side of the station-link message layer.
*
* ## Where the expected bytes come from
* The colleague's Python implementation of the same protocol, microbu-esp32c5
* station-link/python/microbu_link/messages.py (`encode_message` over each body's `encode`),
* run with the same field values. That module is what their phone emulator drives the firmware
* with, so an agreement here is agreement with a second, independent implementation.
*/
class StationLinkTest {
private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
@Test
fun `STATION_CONFIGURE matches the Python encoder`() {
val body = StationConfigure(stationType = 2, mid = "021122334455".hexToBytes()).encode()
assertEquals(
"0100070002010002112233445500b40014020205",
LinkMessage(LinkOpcode.STATION_CONFIGURE, 7, body).encode().hex(),
)
}
@Test
fun `POTI_UPDATE matches the Python encoder`() {
val body = PotiUpdate(
timestampMs = 717_254_800_123L, latTenMicroDeg = 535_546_667, lonTenMicroDeg = 100_223_889,
semiMajorCm = 486, semiMinorCm = 486, speedCms = 543, headingDeciDeg = 1234, pai = true,
).encode()
assertEquals(
"02000800fbb2b7ffa60000002bcbeb1f914bf905e601e60100000e000000001f02d204",
LinkMessage(LinkOpcode.POTI_UPDATE, 8, body).encode().hex(),
)
}
@Test
fun `BTP_DATA_REQUEST for a signed CAM matches the Python encoder`() {
val body = BtpDataRequest(
destinationPort = 2001, itsAid = 36, securityProfile = LinkSecurityProfile.SECURED,
permissions = "010000".hexToBytes(), flSdu = "0102030405".hexToBytes(),
).encode()
assertEquals(
"0300090001d1070000010102ffff000000000024000000030100000005000102030405",
LinkMessage(LinkOpcode.BTP_DATA_REQUEST, 9, body).encode().hex(),
)
}
@Test
fun `CREDENTIALS_PROVISION segment matches the Python encoder`() {
val body = credentialsSegment(totalLength = 695, offset = 240, segment = ByteArray(3) { 0xAB.toByte() })
assertEquals("04000a00b702f00003ababab", LinkMessage(LinkOpcode.CREDENTIALS_PROVISION, 10, body).encode().hex())
}
@Test
fun `STATUS from the Python encoder decodes field by field`() {
val message = LinkMessage.decode(
("8400341240e20100010800021122334455b80b49387a4c12eb00010b0000000c0000000d0000000e000000" +
"0f000000100000001100000012000000130000001400000015000000160000001700000018000000" +
"190000001a0000001b0000001c000000fbb2b7ffa6000000").hexToBytes(),
)
assertNotNull(message)
assertEquals(LinkOpcode.STATUS, message!!.opcode)
assertEquals(0x1234, message.sequence)
val status = StationStatus.decode(message.body)!!
assertEquals(123_456L, status.uptimeMs)
assertTrue(status.configured)
assertArrayEquals("b80b49387a4c12eb".hexToBytes(), status.identifier)
assertEquals(1, status.tickets)
assertEquals(11L, status.signedMessages)
assertEquals(12L, status.refusedNoTicket)
assertEquals(13L, status.refusedChangePending)
assertEquals(14L, status.refusedPermission)
assertEquals(15L, status.signFailed)
assertEquals(16L, status.verified)
assertEquals(17L, status.rejected)
assertEquals(18L, status.requestsAccepted)
assertEquals(19L, status.requestsRefused)
assertEquals(21L, status.radioSubmitted)
assertEquals(22L, status.radioFailed)
assertEquals(23L, status.radioReceived)
assertEquals(24L, status.radioDropped)
assertEquals(26L, status.linkCrcErrors)
assertEquals(27L, status.linkMalformed)
assertEquals(28L, status.potiUpdates)
assertEquals(717_254_800_123L, status.itsTimeMs)
}
@Test
fun `a STATUS of the wrong length is refused, as the Python decoder does`() {
assertNull(StationStatus.decode(ByteArray(StationStatus.SIZE + 1)))
}
@Test
fun `RESULT of STATION_CONFIGURE carries the credential state`() {
val message = LinkMessage.decode("8000070000120800021122334455b80b49387a4c12eb0101".hexToBytes())!!
val result = LinkResult.decode(message.body)!!
assertTrue(result.accepted)
assertEquals(StationInfo(credentialsLoaded = true, tickets = 1), StationInfo.decode(result.detail))
}
}
@@ -0,0 +1,60 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules.Kinematics
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** TS 103 300-3 clause 6.4 items 1 to 4, with the Table 16/17 values. */
class VamGenerationRulesTest {
private val here = Kinematics(53.5546667, 10.0223889, speedMps = 3.0, headingDeg = 90.0)
private fun sentAt(ms: Long, k: Kinematics = here) = VamGenerationRules().apply { onSent(ms, k, withLowFrequency = true) }
@Test
fun `the first VAM is always due`() {
assertTrue(VamGenerationRules().due(0, here))
}
@Test
fun `nothing is due within T_GenVamMin even after a big jump`() {
assertFalse(sentAt(1_000).due(1_050, here.copy(latitude = here.latitude + 0.001)))
}
@Test
fun `a stationary VRU gets one VAM every T_GenVamMax`() {
val rules = sentAt(1_000)
assertFalse(rules.due(5_900, here))
assertTrue(rules.due(6_001, here))
}
@Test
fun `position, speed and heading changes trigger past their thresholds only`() {
val rules = sentAt(1_000)
// ~3.3 m north: under 4 m. ~5.6 m: over.
assertFalse(rules.due(2_000, here.copy(latitude = here.latitude + 0.00003)))
assertTrue(rules.due(2_000, here.copy(latitude = here.latitude + 0.00005)))
assertFalse(rules.due(2_000, here.copy(speedMps = 3.4)))
assertTrue(rules.due(2_000, here.copy(speedMps = 3.6)))
assertFalse(rules.due(2_000, here.copy(headingDeg = 93.0)))
assertTrue(rules.due(2_000, here.copy(headingDeg = 95.0)))
}
@Test
fun `heading change is measured the short way round north`() {
val rules = sentAt(1_000, here.copy(headingDeg = 358.0))
assertFalse(rules.due(2_000, here.copy(headingDeg = 1.0))) // 3 degrees across north
assertTrue(rules.due(2_000, here.copy(headingDeg = 3.0))) // 5 degrees
}
@Test
fun `the low-frequency container goes with the first VAM, then every T_GenVamLFMin`() {
val rules = VamGenerationRules()
assertTrue(rules.includeLowFrequency(0))
rules.onSent(0, here, withLowFrequency = true)
assertFalse(rules.includeLowFrequency(1_999))
assertTrue(rules.includeLowFrequency(2_000))
}
}
@@ -0,0 +1,59 @@
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.asn1.VamContent
import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pins the VAM encoder against bytes this code did not produce.
*
* ## Where the expected bytes come from
* asn1tools 0.167, compiling the ETSI modules vanetza-idf ships (asn1/release2:
* TS102894-2v241-CDD.asn, TS103300-3v231/VAM-PDU-Descriptions.asn and its motorcyclist container),
* encoding the same values as a Python dict: the same toolchain the colleague's reference VBS
* (microbu-esp32c5/station-link/python/microbu_link/vbs.py) builds its VAMs with. Station
* 0x12345678, fix at Unix ms 1790170000123 (generationDeltaTime 45819), 53.5546667 N
* 10.0223889 E, 5.43 m/s, heading 123.4 deg, -1.26 m/s^2; every confidence and the altitude
* unavailable, as the encoder sends them.
*/
class VamUperCodecTest {
private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
private fun content(includeLowFrequency: Boolean, accuracyM: Float? = 3.0f) = VamContent(
stationId = 0x12345678,
timestamp = 1_790_170_000_123L,
latitude = 53.5546667,
longitude = 10.0223889,
accuracyM = accuracyM,
speedMps = 5.43,
headingDeg = 123.4,
accelerationMps2 = -1.26,
includeLowFrequency = includeLowFrequency,
)
@Test
fun `VAM with the low-frequency container matches asn1tools`() {
assertEquals(
"031012345678b2fb400aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f322220",
VamUperCodec.encode(content(includeLowFrequency = true)).hex(),
)
}
@Test
fun `VAM without the low-frequency container matches asn1tools`() {
assertEquals(
"031012345678b2fb000aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f30",
VamUperCodec.encode(content(includeLowFrequency = false)).hex(),
)
}
@Test
fun `unknown accuracy encodes the confidence ellipse as unavailable`() {
assertEquals(
"031012345678b2fb000aac85a15b8a18ec8fffffff08eddd0f8002697e087ff24f30",
VamUperCodec.encode(content(includeLowFrequency = false, accuracyM = null)).hex(),
)
}
}
-102
View File
@@ -1,102 +0,0 @@
# Sniffer board and capture tooling
How to put an ESP32-C5 on the ITS-G5 channel as a passive sniffer, pull its captures onto this
PC, and check what is on air. The sniffer firmware itself is the third-party
`its-g5-receiver-firmware` checkout beside this repo; only the tooling and these notes are ours.
| File | What it does |
|---|---|
| `live_capture.py` | Streams the device's captures into a growing `.pcap` while it runs. The usual choice. |
| `dump_pcap.py` | Pulls one capture out of the device's in-memory buffer after the fact. |
| `../obu-firmware/test/pcap_gn_tally.py` | Tallies GeoNetworking headers per station over a `.pcap`. |
One-time: `pip install pyserial` (present in Python 3.11 on the bench PC, so `py -3.11` works).
## Which port
The sniffer firmware's console, and with it the pcap stream, goes out **UART0** - the board's
USB-bridge port (a CH343, its own COM number), not the native USB-C port. A board with only one
USB-C port cannot be used as a sniffer for this reason. On the bench this has been COM5 and, after
a re-enumeration, COM8.
## Live capture (preferred)
```powershell
cd capture
py -3.11 live_capture.py COM8
```
It writes `recordings/capture_<timestamp>.pcap` next to itself, flushing after every packet, so
the file can be read while it grows. Stop it with Ctrl+C. Use `-o <dir>` to write elsewhere;
`recordings/` is gitignored, since captures are large and are data rather than source. Captures
taken before 2026-09-14 are still in `its-g5-receiver-firmware/recordings/`; the host tests read
both directories.
### The CR insertion, and why captures used to be corrupt
ESP-IDF's newlib console converts LF to CRLF on its way out, and that applies to every `0x0a` byte
of the **binary** pcap stream, not only to log text. Each inserted CR shifts everything after it,
so pcap record headers and captured frames alike come out corrupt, and the file stops being
parseable at the first occurrence.
Measured on 2026-09-14: a 787 KB capture parsed cleanly for only 82 of about 2000 records, and
DENMs appeared on nonsense BTP ports because their payloads contain `0x0a` often. `undo_crlf()` in
`live_capture.py` reverses it on the raw stream before any framing, which is exact; afterwards a
capture parsed to EOF and DENMs read as port 2002 again.
**Captures taken before 2026-09-14 are truncated at their first corrupted record.** Anything
measured from them is worth re-checking. `dump_pcap.py` reads the same console and has not been
given the same treatment yet.
## Checking a capture
```powershell
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
```
One row per station, packet type, BTP port and GN lifetime. For the messages themselves, decode
the payloads with `asn1tools` against the modules in `../asn1/` and re-encode them: identical bytes
mean the message was read exactly, wrong bytes mean it was not. `obu-firmware/test/check_replay.py`
does this over a whole capture.
## Flashing the sniffer firmware
From the receiver checkout, with its **pinned** ESP-IDF (not the global 5.5.4 install):
```powershell
cd its-g5-receiver-firmware
git submodule update --init --recursive
.\esp-idf\install.bat
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\esp-idf\export.ps1
idf.py set-target esp32c5
idf.py -p COM8 -b 921600 flash
```
Its `sdkconfig` for a bare board (nothing wired) needs SPI Ethernet off, and the pcap destination
set to Memory, both under `idf.py menuconfig`. Wired variants have ready-made configs in that
checkout: `sdkconfig.proto-w5500`, `sdkconfig.proto-enc28j60`, `sdkconfig.proto-spi-eppp`.
To reflash a board that already has a built image, without a toolchain terminal:
```powershell
cd its-g5-receiver-firmware\build
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM8 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 16MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x1e000 ota_data_initial.bin 0x20000 its-g5-receiver-firmware.bin
```
## Pulling a capture after the fact
Only for the Memory destination, and the buffer is small (`SNIFFER_PCAP_MEMORY_SIZE`, 4096 bytes
by default) - a smoke test, not a session. In the device console (`idf.py -p COM8 monitor`, exit
with Ctrl+T then Ctrl+X):
```
sniffer -P
sniffer --stop
```
Then, with the port free:
```powershell
py -3.11 dump_pcap.py COM8
```
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""
Pulls a capture off the ITS-G5 receiver's in-memory pcap buffer over the existing USB serial
connection and saves it as a real .pcap file on this machine.
Requires the firmware to be built with:
Example Configuration -> Select destination to store pcap file -> Memory
Usage (typical):
1. Close idf.py monitor (only one program can hold the COM port at a time).
2. Run a capture on the device: `sniffer -P` ... let it run ... `sniffer --stop`
3. python dump_pcap.py COM5
The device has no access to this computer's filesystem, so it can't write here directly. Instead,
`pcap --dump` streams the raw pcap bytes back over the same serial link, wrapped in plain-text
markers ("===PCAP-DUMP-START:<len>===" ... raw bytes ... "===PCAP-DUMP-END==="). This script finds
those markers and writes just the raw bytes out as a .pcap file.
Install dependency once: pip install pyserial
"""
import argparse
import datetime
import re
import sys
try:
import serial
except ImportError:
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
sys.exit(1)
START_RE = re.compile(rb"===PCAP-DUMP-START:(\d+)===\n")
END_MARKER = b"\n===PCAP-DUMP-END===\n"
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
parser.add_argument("-t", "--timeout", type=float, default=15.0, help="Seconds to wait for the dump to start")
args = parser.parse_args()
import os
os.makedirs(args.outdir, exist_ok=True)
print(f"Opening {args.port} @ {args.baud}...")
with serial.Serial(args.port, args.baud, timeout=1) as ser:
# Nudge the console in case there's stale input, then request the dump.
ser.reset_input_buffer()
ser.write(b"\r\n")
ser.write(b"pcap -f dump --dump\r\n")
print("Waiting for dump to start...")
buf = b""
match = None
deadline = datetime.datetime.now() + datetime.timedelta(seconds=args.timeout)
while datetime.datetime.now() < deadline:
chunk = ser.read(256)
if chunk:
buf += chunk
match = START_RE.search(buf)
if match:
break
if not match:
print("Timed out waiting for '===PCAP-DUMP-START:...===' marker.\n"
"Check that: the firmware is built with the Memory pcap destination, a capture was\n"
"actually taken ('sniffer -P' then 'sniffer --stop'), and no other program (like\n"
"idf.py monitor) is holding the serial port open.", file=sys.stderr)
sys.exit(1)
length = int(match.group(1))
print(f"Dump starting, {length} bytes expected.")
# Anything after the marker in our buffer is already part of the payload.
payload = buf[match.end():]
remaining = length - len(payload)
while remaining > 0:
chunk = ser.read(min(remaining, 4096))
if not chunk:
print(f"Serial read timed out with {remaining} bytes still missing.", file=sys.stderr)
sys.exit(1)
payload += chunk
remaining -= len(chunk)
# Drain (and sanity-check) the trailing end marker, but don't fail hard if it's not exact.
tail = ser.read(len(END_MARKER))
if tail != END_MARKER:
print("Warning: end marker didn't match exactly - payload may still be fine.", file=sys.stderr)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
with open(outpath, "wb") as f:
f.write(payload)
print(f"Saved {len(payload)} bytes to {outpath}")
if __name__ == "__main__":
main()
-226
View File
@@ -1,226 +0,0 @@
#!/usr/bin/env python3
"""
Continuously listens on the ITS-G5 receiver's serial console and writes every captured packet into a
live-growing .pcap file, with no console commands needed on the device side.
The firmware streams every packet it captures out over the same serial connection the console runs on,
automatically, as soon as the sniffer is running (which happens on boot by default). Each packet is framed
with plain-text markers so this script can pull the binary pcap bytes out of the stream even though
regular log lines are interleaved with it:
===PCAP-LIVE-HEADER:<len>===\\n<24 raw bytes>\\n (sent once, the pcap global header)
===PCAP-LIVE-PKT:<len>===\\n<raw bytes>\\n (sent once per captured packet)
Usage:
python live_capture.py COM5
Runs until you press Ctrl+C. Writes to recordings/capture_<timestamp>.pcap, flushing after every packet
so you can open the file in Wireshark while it's still being written (use "File > Open" again, or
Wireshark's own "Follow" won't auto-refresh but re-opening will show the latest packets).
Install dependency once: pip install pyserial
"""
import argparse
import datetime
import os
import re
import sys
import time
try:
import serial
except ImportError:
print("Missing dependency. Install it with: pip install pyserial", file=sys.stderr)
sys.exit(1)
# \r? because the ESP console emits CRLF: on Windows the markers arrive as
# "===PCAP-LIVE-PKT:310===\r\n", which never matched a bare \n and left the capture silently
# empty while the device was streaming perfectly well.
HEADER_RE = re.compile(rb"===PCAP-LIVE-HEADER:(\d+)===\r?\n")
PKT_RE = re.compile(rb"===PCAP-LIVE-PKT:(\d+)===\r?\n")
LINKTYPE_ETHERNET = 1
LINKTYPE_IEEE802_11_RADIOTAP = 127
def mac_str(b):
return ":".join(f"{x:02x}" for x in b)
def build_default_pcap_header(link_type):
"""Synthesizes the same 24-byte global pcap header the firmware would have sent, for when we
connect after the device's one-time header already went out (see the race note in main())."""
header = bytearray(24)
header[0:4] = bytes([0xD4, 0xC3, 0xB2, 0xA1]) # magic (LE bytes of 0xA1B2C3D4)
header[4:6] = (2).to_bytes(2, "little") # major version
header[6:8] = (4).to_bytes(2, "little") # minor version
header[16:20] = (0x40000).to_bytes(4, "little") # snaplen
header[20:24] = link_type.to_bytes(4, "little")
return bytes(header)
def summarize_packet(link_type, record_bytes, index):
"""Best-effort human-readable one-line summary of a captured packet, for live feedback.
record_bytes is the raw 16-byte pcap record header followed by the captured frame."""
seconds = int.from_bytes(record_bytes[0:4], "little")
microseconds = int.from_bytes(record_bytes[4:8], "little")
cap_len = int.from_bytes(record_bytes[8:12], "little")
frame = record_bytes[16:]
ts = f"{seconds}.{microseconds:06d}"
if link_type == LINKTYPE_IEEE802_11_RADIOTAP and len(frame) >= 24:
radiotap_len = int.from_bytes(frame[2:4], "little")
rssi = frame[8] - 256 if frame[8] >= 128 else frame[8]
station_id = int.from_bytes(frame[16:24], "little")
mac_frame = frame[radiotap_len:]
if len(mac_frame) >= 16:
dst = mac_str(mac_frame[4:10])
src = mac_str(mac_frame[10:16])
else:
dst = src = "?"
station = f"{station_id:012x}" if station_id else "unknown"
return (f"#{index:<5} [{ts}] len={cap_len:<5} rssi={rssi:>4}dBm "
f"station={station} {src} -> {dst}")
if link_type == LINKTYPE_ETHERNET and len(frame) >= 14:
dst = mac_str(frame[0:6])
src = mac_str(frame[6:12])
ethertype = int.from_bytes(frame[12:14], "big")
return f"#{index:<5} [{ts}] len={cap_len:<5} eth {src} -> {dst} type=0x{ethertype:04x}"
return f"#{index:<5} [{ts}] len={cap_len:<5} (unrecognized frame format)"
def undo_crlf(chunk, state):
"""Undo the CR the device console inserts before every LF.
ESP-IDF's newlib console converts LF to CRLF on its way out, and that happens to every 0x0A
byte of the binary pcap stream too, not only to log text. Each inserted CR shifts everything
after it, so pcap record headers and captured frames alike come out corrupt. This is the
"byte inserted mid-frame" seen in older recordings; with DENM traffic on air it wrecks most
of a capture (measured 2026-09-14: a 787 KB file parsed cleanly for only 82 records).
Dropping one CR immediately before each LF undoes it exactly, provided it is done on the raw
stream before any framing and a trailing CR is carried across read boundaries. CR and LF are
written as byte values here so the transformation cannot be confused with an escape.
"""
CR, LF = bytes([13]), bytes([10])
if state["pending_cr"]:
chunk = CR + chunk
state["pending_cr"] = False
if chunk.endswith(CR):
chunk = chunk[:-1]
state["pending_cr"] = True
return chunk.replace(CR + LF, LF)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("port", help="Serial port the device is on, e.g. COM5")
parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: 115200)")
parser.add_argument("-o", "--outdir", default="recordings", help="Output directory (default: ./recordings)")
args = parser.parse_args()
os.makedirs(args.outdir, exist_ok=True)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
outpath = os.path.join(args.outdir, f"capture_{timestamp}.pcap")
print(f"Opening {args.port} @ {args.baud}...")
print(f"Writing live capture to {outpath}")
print("Press Ctrl+C to stop.")
header_written = False
link_type = None
packet_count = 0
buf = b""
total_bytes = 0
last_status = time.monotonic()
printed_raw_preview = False
crlf_state = {"pending_cr": False}
with serial.Serial(args.port, args.baud, timeout=1) as ser, open(outpath, "wb") as outfile:
def read_bytes(n):
return undo_crlf(ser.read(n), crlf_state)
try:
while True:
chunk = read_bytes(256)
if chunk:
buf += chunk
total_bytes += len(chunk)
now = time.monotonic()
if now - last_status >= 2:
last_status = now
print(f"[diagnostic] {total_bytes} raw bytes received so far, "
f"{packet_count} packets recognized, header_written={header_written}")
if total_bytes > 0 and not printed_raw_preview and not header_written and not PKT_RE.search(buf):
# We're getting bytes but none of them look like our markers - show a preview
# so we can tell whether this is plain log text (markers just haven't shown up
# yet), garbage (baud/port mismatch), or something else entirely.
preview = buf[:200]
print(f"[diagnostic] no markers matched yet - raw preview: {preview!r}")
printed_raw_preview = True
elif total_bytes == 0:
print("[diagnostic] zero bytes received from the port at all - this points at "
"the wrong COM port, another program holding the port, or a port that "
"isn't actually wired to the console/sniffer output.")
# The device only sends the global header once, right when the sniffer first starts
# (typically within a second or two of boot). If this script connects even slightly
# late - very likely right after a fresh flash, since esptool itself resets the board -
# that header is already gone before we ever see it. Rather than blocking forever
# waiting for a header that's never coming, look for whichever marker shows up first.
header_match = None if header_written else HEADER_RE.search(buf)
pkt_match = PKT_RE.search(buf)
if header_match and (not pkt_match or header_match.start() < pkt_match.start()):
length = int(header_match.group(1))
buf = buf[header_match.end():]
while len(buf) < length:
buf += read_bytes(length - len(buf))
header_bytes = buf[:length]
outfile.write(header_bytes)
outfile.flush()
buf = buf[length:]
header_written = True
if length >= 24:
link_type = int.from_bytes(header_bytes[20:24], "little")
print(f"Got pcap global header (link type {link_type}) - device is streaming.\n")
continue
if not header_written and pkt_match:
link_type = LINKTYPE_IEEE802_11_RADIOTAP
outfile.write(build_default_pcap_header(link_type))
outfile.flush()
header_written = True
print("Note: missed the device's one-time pcap header (it was likely sent before "
"this script connected, e.g. right after a flash/reset) - assuming WLAN "
"radiotap capture and writing a default header instead.\n")
# fall through and process pkt_match below, don't discard this packet
if not pkt_match:
# Keep the buffer from growing unbounded while waiting for a marker, but don't
# discard anything - a marker could be split across reads.
if len(buf) > 65536:
buf = buf[-4096:]
continue
length = int(pkt_match.group(1))
buf = buf[pkt_match.end():]
while len(buf) < length:
buf += read_bytes(length - len(buf))
record_bytes = buf[:length]
outfile.write(record_bytes)
outfile.flush()
buf = buf[length:]
packet_count += 1
print(summarize_packet(link_type, record_bytes, packet_count))
except KeyboardInterrupt:
print(f"\nStopped. {packet_count} packets saved to {outpath}")
if __name__ == "__main__":
main()
+139
View File
@@ -0,0 +1,139 @@
# 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.
+123 -50
View File
@@ -1,7 +1,7 @@
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon) # OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon)
Started. See `docs/04-transmit-setup.md` in the project root for build/flash Build and flash steps are under "Build and flash" below. (`docs/04-transmit-setup.md`,
steps and how to validate this against your own sniffer. referenced here and in the sources, is not in the repo.)
## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4) ## Toolchain: use a dedicated terminal (ESP-IDF 5.5.4)
@@ -29,6 +29,123 @@ referenced an older source path under `micrOBU_workspace/v2x-obu-esp32c5/`,
which makes `idf.py fullclean` refuse to run). If that error reappears, delete which makes `idf.py fullclean` refuse to run). If that error reappears, delete
`build/` manually rather than fighting it. `build/` manually rather than fighting it.
## Build and flash
The firmware needs no button, phone or serial connection to start. On every power-up or reset,
`app_main` sets up the radio and starts `tx_task`, which starts driving the simulated route and
sending CAMs on 5900 MHz. A board flashed with this image starts beaconing on its own as soon as it
gets power.
In a fresh PowerShell window:
```powershell
$env:IDF_PYTHON_ENV_PATH = $null; $env:IDF_PATH = $null
. C:\Espressif\frameworks\esp-idf-v5.5.4\export.ps1
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
```
1. **Check what the board is running before you flash it.** Both this project and `obu-firmware`
name their image `obu_firmware.bin`, so the file name tells you nothing. Read the app
descriptor instead (replace `COMx` with the board's port):
```powershell
python -m esptool --chip esp32c5 -p COMx read_flash 0x10000 0x100 $env:TEMP\desc.bin
$b = [IO.File]::ReadAllBytes("$env:TEMP\desc.bin")
function S($o,$n){ [Text.Encoding]::ASCII.GetString($b,$o,$n).Trim([char]0) }
"time=" + (S 0x70 16) + " date=" + (S 0x80 16) + " idf=" + (S 0x90 32)
```
`idf=v6.1...` means the board runs the production OBU (`obu-firmware`). Don't flash this beacon
over it. `idf=v5.5.4` means this transmitter, or another bench image.
2. **Build:**
```powershell
idf.py build
```
A rebuild after small changes takes about 1-2 minutes. The output is
`build\obu_firmware.bin`.
3. **Flash** (the board must be on its UART bridge port or its native USB port):
```powershell
idf.py -p COMx flash
```
The flash is good when esptool prints `Hash of data verified.`, then resets the board.
4. **Check that it's transmitting:**
```powershell
idf.py -p COMx monitor
```
(Exit with `Ctrl+]`.) About 1.4 s after reset you should see:
```
W obu-tx: OCB @ 5900 MHz - CAM beacon armed, driving a 103-point street loop
I obu-tx: CAM sent (119 bytes) @ 5900 MHz genDeltaT=1087 pos=53.5531770,10.0220980 50.0 km/h heading 77.0 pt1
```
After that, a `CAM sent` line appears about 3 times a second, with the position, speed and
heading changing. These lines only mean each frame was
handed to the radio. To confirm the frames actually went out, capture them with a second
ESP32-C5 running the receiver firmware.
Don't open the console of the production OBU (COM3) while the phone is attached. Opening the port
resets that board and drops the phone's USB link. The beacon boards have no phone attached, so
this doesn't apply to them.
## Simulated drive
The beacon pretends to be a car driving a loop through St. Georg / Berliner Tor in Hamburg, on
the real streets. The route comes from six waypoints, which you set in `tools/make_route.py`.
**Changing the route:** edit `WAYPOINTS` in `tools/make_route.py`, then run
```powershell
py -3.11 tools/make_route.py
```
The script asks the OSRM demo server (router.project-osrm.org) for a legal driving route through the
waypoints in order and back to the first, then writes three files:
- `main/route_points.h`: the street geometry, thinned to points no more than 1.5 m off the line
(currently 103 points).
- `tools/route_osrm.json`: OSRM's raw answer. `--offline` rebuilds the header from it without the
network.
- `tools/route_map.html`: the route on an OpenStreetMap map. Open it in a browser and check it
before building.
Then build and flash as above. Route data (c) OpenStreetMap contributors, ODbL; routing by OSRM.
**Things to know about the routing:**
- OSRM follows one-way streets and turn bans, so the loop can be longer than the waypoints suggest.
The current one is 5.2 km, with two turn-round detours: a loop via Borgfelder Straße and
Anckelmannsplatz between wp2 and wp3, and one round Nagelsweg, Norderstraße and Repsoldstraße
between wp5 and wp6. To avoid a detour, move the waypoint on either side of it.
- Each waypoint is sent with the direction towards the next one. Without it, points on divided
roads (Beim Strohhause, for example) snap to the carriageway going the other way, and the loop
grows to 8.4 km of U-turns.
**How the car drives** (`main/route.c`):
- **Speed:** it cruises at 50 km/h (`CRUISE_MPS` in `main/main.c`). Each bend gets a speed limit
from its radius, keeping sideways acceleration at 2 m/s², so a 90° junction turn is taken at
about 15 km/h and a gentle curve barely slows the car. It never drops below 10 km/h
(`MIN_CORNER_MPS`). It brakes at 2 m/s² and accelerates at 1.5 m/s², planning braking across as
many points as a bend needs. A lap takes about 7.7 min, averaging 40 km/h.
- **Heading:** the compass bearing of the current straight piece. It changes gradually through
curves but jumps at sharp junction turns.
- **When CAMs are sent:** following ETSI EN 302 637-2, the state is checked every 100 ms. A CAM goes
out when the heading changed by more than 4°, the position by more than 4 m, or the speed by more
than 0.5 m/s since the last one, and at least once a second. That's about 3 CAMs a second at
50 km/h.
- **What's filled in:** position, speed and heading go into both the CAM and the GeoNetworking
source position vector. `genDeltaT` is milliseconds since boot.
- **Testing:** `route.c` only uses standard headers, so you can compile it on the PC with MSYS2 gcc
and simulate a lap.
## CAM encoding ## CAM encoding
`main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a `main/cam.c` IS compiled here (unlike `obu-firmware`'s copy, which is a
@@ -44,56 +161,12 @@ hazard-light GPIO is grounded. No location/alacarte containers.
- `main/main.c` - entry point, the `phy_11p_set`/`phy_change_channel(5900,...)` - `main/main.c` - entry point, the `phy_11p_set`/`phy_change_channel(5900,...)`
register hack, GPIO polling, TX loop register hack, GPIO polling, TX loop
- `main/denm.c` / `.h` - ASN.1 UPER encoding of a minimal DENM - `main/denm.c` / `.h` - ASN.1 UPER encoding of a minimal DENM
- `main/route.c` / `.h` - simulated drive round the route loop
- `main/route_points.h` - the route, generated by `tools/make_route.py`
- `main/geonet.c` / `.h` - GeoNetworking Basic/Common/SHB headers + BTP-B - `main/geonet.c` / `.h` - GeoNetworking Basic/Common/SHB headers + BTP-B
- `main/dot11p.c` / `.h` - 802.11 OCB (QoS Data, broadcast) frame + LLC/SNAP - `main/dot11p.c` / `.h` - 802.11 OCB (QoS Data, broadcast) frame + LLC/SNAP
Known gaps, tracked as TODOs in the source: no real GNSS (lat/long hardcoded Known gaps, tracked as TODOs in the source: no real GNSS (the CAM position comes
0), no real time source (detectionTime/referenceTime hardcoded 0, decodes as from the simulated drive above), no real time source (detectionTime/referenceTime hardcoded 0, decodes as
2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast 2004-01-01), fixed (non-rotating) pseudonym MAC, SHB instead of GeoBroadcast
(no multi-hop forwarding), unsecured (no IEEE 1609.2 signing). (no multi-hop forwarding), unsecured (no IEEE 1609.2 signing).
## Running it as a bench beacon
This firmware needs no phone: it beacons a CAM every second by itself
(`TX_INTERVAL_MS`) from station `0x0BADC0DE` (195936478), stationType 5
(passengerCar), at the hardcoded bench position, under the fixed MAC
`02:00:00:00:00:01`, on 5900 MHz. That makes it the quickest way to put known,
repeatable traffic on air, and it is how the 4-bit `yawRateConfidence` encoding
was confirmed over the air on 2026-09-14.
A board with only one USB-C port is fine. This firmware's console is on UART0,
so such a board shows no log output, but nothing here needs the console.
Flash it from the toolchain terminal (ESP-IDF 5.5.4, see the table above):
```powershell
cd C:\Users\Ashin\AndroidStudioProjects\MicrOBU\obu-cam-transmistter
idf.py -p COM10 -b 921600 flash
```
Or flash the existing build without any toolchain terminal:
```powershell
cd obu-cam-transmistter\build
C:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe -m esptool --chip esp32c5 -p COM10 -b 921600 write_flash --flash_mode dio --flash_freq 80m --flash_size 2MB 0x2000 bootloader/bootloader.bin 0x8000 partition_table/partition-table.bin 0x10000 obu_firmware.bin
```
It starts beaconing as soon as it boots, so there is nothing to start by hand,
and unplugging it is how you stop it.
**It transmits under the same MAC as the phone's CAM pinger**, so on air the two
are told apart by station ID (195936478 here, 999999 for the pinger), never by
source address.
To see what it is sending, capture on the sniffer board and decode:
```powershell
cd capture
py -3.11 live_capture.py COM8
py -3.11 ..\obu-firmware\test\pcap_gn_tally.py recordings\capture_<timestamp>.pcap
```
The tally lists it as SHB / port 2001 / lifetime `0x05`. For the message itself,
decode the payload with `asn1tools` against `asn1/cam_1_4_1.asn` +
`asn1/cdd_1_3_1_1.asn`; re-encoding must return the identical bytes. On
2026-09-14, 72 of 72 frames did.
+2 -2
View File
@@ -1,8 +1,8 @@
# wifi_patches.c is intentionally NOT in this list anymore - superseded by # wifi_patches.c is intentionally NOT in this list anymore - superseded by
# tx_custom.c (see that file for why). Left on disk, unused, for history. # tx_custom.c (see that file for why). Left on disk, unused, for history.
idf_component_register( idf_component_register(
SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c" SRCS "main.c" "denm.c" "cam.c" "geonet.c" "dot11p.c" "tx_custom.c" "route.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES esp_event esp_netif nvs_flash driver esp_phy REQUIRES esp_event esp_timer esp_netif nvs_flash driver esp_phy
PRIV_REQUIRES esp_wifi PRIV_REQUIRES esp_wifi
) )
+7 -6
View File
@@ -4,6 +4,7 @@
int geonet_wrap_shb(const uint8_t *its_payload, int its_len, int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
const uint8_t mac[6], uint8_t station_type, const uint8_t mac[6], uint8_t station_type,
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg, int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
uint16_t speed_cm_s, uint16_t heading_ddeg,
uint16_t btp_dest_port, uint16_t btp_dest_port,
uint8_t *out, size_t out_len) uint8_t *out, size_t out_len)
{ {
@@ -68,12 +69,12 @@ int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
uint32_t lon_u = (uint32_t)longitude_tenmicrodeg; uint32_t lon_u = (uint32_t)longitude_tenmicrodeg;
*p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16); *p++ = (uint8_t)(lon_u >> 24); *p++ = (uint8_t)(lon_u >> 16);
*p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u); *p++ = (uint8_t)(lon_u >> 8); *p++ = (uint8_t)(lon_u);
// PAI(1 bit) + Speed(15 bits), packed into 2 bytes: 0 = PAI false, // PAI(1 bit) + Speed(15 bits, signed, 0.01 m/s), packed into 2 bytes. PAI stays 0: the
// speed 0 - which is actually correct semantics for a STATIONARY // position has no accuracy estimate behind it.
// vehicle beacon, not just a placeholder. uint16_t spd = speed_cm_s > 0x7FFF ? 0x7FFF : speed_cm_s;
*p++ = 0x00; *p++ = 0x00; *p++ = (uint8_t)(spd >> 8); *p++ = (uint8_t)(spd & 0xFF);
// Heading (16 bits, 0.1 degree units): 0 = due north / unavailable // Heading (16 bits, 0.1 degree units, clockwise from north)
*p++ = 0x00; *p++ = 0x00; *p++ = (uint8_t)(heading_ddeg >> 8); *p++ = (uint8_t)(heading_ddeg & 0xFF);
// Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position // Reserved (4 bytes) - clause 9.8.4: the SHB extended header is the 24-byte Source Position
// Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These // Vector FOLLOWED BY a 4-byte reserved field (media-dependent data), 28 bytes in total. These
// four bytes were missing, which is why a standards-compliant receiver read our CAM payload's // four bytes were missing, which is why a standards-compliant receiver read our CAM payload's
+5
View File
@@ -31,6 +31,10 @@
// working (same extended header shape as CAM). Fine for a single-vehicle // working (same extended header shape as CAM). Fine for a single-vehicle
// beacon; revisit if you need real multi-hop forwarding later. // beacon; revisit if you need real multi-hop forwarding later.
// //
// `speed_cm_s` (0.01 m/s) and `heading_ddeg` (0.1 deg) also go into the Source Long Position
// Vector; pass the same values as the CAM's high-frequency container. Speed is a 15-bit field, so
// values above 32767 are clamped.
//
// `btp_dest_port` is the BTP-B destination port for the service being carried // `btp_dest_port` is the BTP-B destination port for the service being carried
// (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ... // (ETSI TS 103 248): 2001 = CAM, 2002 = DENM, 2003 = MAPEM, 2004 = SPATEM, ...
// //
@@ -38,6 +42,7 @@
int geonet_wrap_shb(const uint8_t *its_payload, int its_len, int geonet_wrap_shb(const uint8_t *its_payload, int its_len,
const uint8_t mac[6], uint8_t station_type, const uint8_t mac[6], uint8_t station_type,
int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg, int32_t latitude_tenmicrodeg, int32_t longitude_tenmicrodeg,
uint16_t speed_cm_s, uint16_t heading_ddeg,
uint16_t btp_dest_port, uint16_t btp_dest_port,
uint8_t *out, size_t out_len); uint8_t *out, size_t out_len);
+82 -30
View File
@@ -1,7 +1,11 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <math.h>
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "esp_timer.h"
#include "driver/gpio.h" #include "driver/gpio.h"
#include "esp_wifi.h" #include "esp_wifi.h"
#include "esp_event.h" #include "esp_event.h"
@@ -14,13 +18,17 @@
#include "geonet.h" #include "geonet.h"
#include "dot11p.h" #include "dot11p.h"
#include "tx_custom.h" #include "tx_custom.h"
#include "route.h"
#include "route_points.h"
static const char *TAG = "obu-tx"; static const char *TAG = "obu-tx";
// CAM beacon: transmit a Cooperative Awareness Message every TX_INTERVAL_MS, // CAM beacon for a simulated car driving round a block in Hamburg (see route.c). The CAM
// unconditionally (no hazard-light gating - CAM is a continuous beacon, unlike // generation rules follow ETSI EN 302 637-2 clause 6.1.3: every CHECK_INTERVAL_MS the car's state
// the event-triggered DENM). Matches the working Rust reference // is compared with the last CAM sent, and a new CAM goes out when the heading changed by more than
// (esp32-c_its-companion, feat/tx-cam), which beacons CAM on 5900 MHz. // 4 degrees, the position by more than 4 m, the speed by more than 0.5 m/s, or 1 s has passed.
// There is no hazard-light gating - CAM is a continuous beacon, unlike the event-triggered DENM.
// Transmits on 5900 MHz like the working Rust reference (esp32-c_its-companion, feat/tx-cam).
// ISOLATION TEST for whether tx_custom.c is the blocker. // ISOLATION TEST for whether tx_custom.c is the blocker.
// 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a // 1 = transmit via the STANDARD, well-tested esp_wifi_80211_tx() using a
@@ -55,15 +63,19 @@ static const char *TAG = "obu-tx";
#define VEHICLE_LENGTH_DM 40 // VehicleLengthValue, 10cm steps (4.0 m) #define VEHICLE_LENGTH_DM 40 // VehicleLengthValue, 10cm steps (4.0 m)
#define VEHICLE_WIDTH_DM 18 // VehicleWidth, 10cm steps (1.8 m) #define VEHICLE_WIDTH_DM 18 // VehicleWidth, 10cm steps (1.8 m)
#define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248) #define BTP_PORT_CAM 2001 // BTP-B destination port for CAM (ETSI TS 103 248)
#define TX_INTERVAL_MS 1000 // CAM beacon period (1 Hz; ITS allows 1-10 Hz) #define CHECK_INTERVAL_MS 100 // T_CheckCamGen: how often the generation rules are evaluated
#define CAM_MAX_INTERVAL_MS 1000 // T_GenCamMax: a CAM goes out at least this often
#define CAM_HEADING_DDEG 40 // > 4 degrees heading change triggers a CAM
#define CAM_POSITION_M 4.0 // > 4 m position change triggers a CAM
#define CAM_SPEED_CM_S 50 // > 0.5 m/s speed change triggers a CAM
// Bench location, hardcoded since there's no GNSS module wired in yet and // ---- Simulated drive ----
// the unit is genuinely stationary here: 53°33'16.8"N 10°01'20.6"E, in // route_points (main/route_points.h) is the street geometry of a driving loop through six waypoints
// 1/10-microdegree units (decimal_degrees * 10,000,000). Replace with real // in St. Georg, generated by tools/make_route.py from OpenStreetMap via OSRM. To change the route,
// GNSS output once you have a fix source; until then this beats 0/0 // edit WAYPOINTS in that script and rerun it. No GNSS is wired in; replace with real fixes once
// ("Null Island"), which is an obvious placeholder-tell on any map. // there is one.
#define BENCH_LATITUDE_TENMICRODEG 535546667 #define CRUISE_MPS (50.0 / 3.6) // 50 km/h, the urban limit
#define BENCH_LONGITUDE_TENMICRODEG 100223889 #define MIN_CORNER_MPS (10.0 / 3.6) // slowest the car goes, for hairpins and U-turns
// Single source of truth for the pseudonym/link-layer address: used both as // Single source of truth for the pseudonym/link-layer address: used both as
// the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN // the 802.11 source MAC (Addr2) and as GN_ADDR's MID field, since the GN
@@ -78,33 +90,28 @@ static const uint8_t pseudonym_mac[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
extern void phy_11p_set(int enable, int unused); extern void phy_11p_set(int enable, int unused);
extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, int unused); extern void phy_change_channel(int freq_mhz, int bw_mode, int sec_chan_offset, int unused);
static void send_cam(void) static void send_cam(const route_state_t *car, uint16_t gen_delta)
{ {
// GenerationDeltaTime is TimestampIts mod 65536 (ms). No RTC/GNSS time here,
// so use a free-running ms counter that advances one beacon-interval per
// send. It wraps at 65536, which is exactly the field's defined behaviour.
static uint16_t gen_delta = 0;
uint8_t frame[300]; uint8_t frame[300];
cam_fields_t fields = { cam_fields_t fields = {
.station_id = STATION_ID, .station_id = STATION_ID,
.station_type = STATION_TYPE, .station_type = STATION_TYPE,
.generation_delta_time = gen_delta, .generation_delta_time = gen_delta,
.latitude_tenmicrodeg = BENCH_LATITUDE_TENMICRODEG, .latitude_tenmicrodeg = car->latitude_tenmicrodeg,
.longitude_tenmicrodeg = BENCH_LONGITUDE_TENMICRODEG, .longitude_tenmicrodeg = car->longitude_tenmicrodeg,
.speed_cm_s = 0, // stationary .speed_cm_s = car->speed_cm_s,
.heading_ddeg = 3601, // HeadingValue unavailable (no heading source) .heading_ddeg = car->heading_ddeg,
.vehicle_length_dm = VEHICLE_LENGTH_DM, .vehicle_length_dm = VEHICLE_LENGTH_DM,
.vehicle_width_dm = VEHICLE_WIDTH_DM, .vehicle_width_dm = VEHICLE_WIDTH_DM,
}; };
gen_delta += TX_INTERVAL_MS;
uint8_t cam_payload[96]; uint8_t cam_payload[96];
int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload)); int cam_len = cam_encode(&fields, cam_payload, sizeof(cam_payload));
uint8_t gn_payload[160]; uint8_t gn_payload[160];
int gn_len = geonet_wrap_shb(cam_payload, cam_len, pseudonym_mac, STATION_TYPE, int gn_len = geonet_wrap_shb(cam_payload, cam_len, pseudonym_mac, STATION_TYPE,
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG, car->latitude_tenmicrodeg, car->longitude_tenmicrodeg,
car->speed_cm_s, car->heading_ddeg,
BTP_PORT_CAM, gn_payload, sizeof(gn_payload)); BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
// qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS // qos=false for the standard-TX path (esp_wifi_80211_tx accepts only non-QoS
@@ -127,7 +134,10 @@ static void send_cam(void)
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err); ESP_LOGW(TAG, "esp_wifi_80211_tx (standard) failed: %d", err);
} else { } else {
ESP_LOGI(TAG, "CAM sent via STANDARD tx (%d bytes) @ %d MHz genDeltaT=%u", frame_len, TX_FREQ_MHZ, gen_delta); ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz genDeltaT=%u pos=%.7f,%.7f %.1f km/h heading %.1f pt%d",
frame_len, TX_FREQ_MHZ, gen_delta,
car->latitude_tenmicrodeg / 1e7, car->longitude_tenmicrodeg / 1e7,
car->speed_cm_s * 0.036, car->heading_ddeg / 10.0, car->segment + 1);
} }
#else #else
// tx_custom path: submits to the driver's internal HMAC TX path, // tx_custom path: submits to the driver's internal HMAC TX path,
@@ -151,12 +161,49 @@ static void send_cam(void)
} }
} }
static bool cam_due(const route_state_t *car, const route_state_t *last, int64_t since_last_ms)
{
if (since_last_ms >= CAM_MAX_INTERVAL_MS) {
return true;
}
int dh = abs((int)car->heading_ddeg - (int)last->heading_ddeg);
if (dh > 1800) {
dh = 3600 - dh;
}
if (dh > CAM_HEADING_DDEG) {
return true;
}
if (abs((int)car->speed_cm_s - (int)last->speed_cm_s) > CAM_SPEED_CM_S) {
return true;
}
// Flat-earth distance is plenty for a 4 m threshold.
double north_m = (car->latitude_tenmicrodeg - last->latitude_tenmicrodeg) * 0.0111194930;
double east_m = (car->longitude_tenmicrodeg - last->longitude_tenmicrodeg) * 0.0111194930
* cos(car->latitude_tenmicrodeg / 1e7 * M_PI / 180.0);
return north_m * north_m + east_m * east_m > CAM_POSITION_M * CAM_POSITION_M;
}
static void tx_task(void *arg) static void tx_task(void *arg)
{ {
route_state_t car;
route_state_t last_sent;
int64_t last_sent_ms = 0;
bool sent_any = false;
TickType_t wake = xTaskGetTickCount();
route_step(0.0, &car);
while (1) { while (1) {
// CAM is a continuous beacon - send every interval, unconditionally. int64_t now_ms = esp_timer_get_time() / 1000;
send_cam(); if (!sent_any || cam_due(&car, &last_sent, now_ms - last_sent_ms)) {
vTaskDelay(pdMS_TO_TICKS(TX_INTERVAL_MS)); // GenerationDeltaTime is TimestampIts mod 65536 (ms). No real clock here, so use
// milliseconds since boot, which advances at the right rate.
send_cam(&car, (uint16_t)now_ms);
last_sent = car;
last_sent_ms = now_ms;
sent_any = true;
}
vTaskDelayUntil(&wake, pdMS_TO_TICKS(CHECK_INTERVAL_MS));
route_step(CHECK_INTERVAL_MS / 1000.0, &car);
} }
} }
@@ -255,8 +302,13 @@ void app_main(void)
phy_change_channel(TX_FREQ_MHZ, 1, 0, 0); phy_change_channel(TX_FREQ_MHZ, 1, 0, 0);
ESP_LOGI(TAG, "phy_change_channel returned"); ESP_LOGI(TAG, "phy_change_channel returned");
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, transmitting every %d ms", if (route_init(route_points, sizeof(route_points) / sizeof(route_points[0]),
TX_FREQ_MHZ, TX_INTERVAL_MS); CRUISE_MPS, MIN_CORNER_MPS) != 0) {
ESP_LOGE(TAG, "route_init failed - check route_points");
return;
}
ESP_LOGW(TAG, "OCB @ %d MHz - CAM beacon armed, driving a %d-point street loop",
TX_FREQ_MHZ, (int)(sizeof(route_points) / sizeof(route_points[0])));
xTaskCreate(tx_task, "tx_task", 4096, NULL, 5, NULL); xTaskCreate(tx_task, "tx_task", 4096, NULL, 5, NULL);
} }
+125
View File
@@ -0,0 +1,125 @@
#include "route.h"
#include <math.h>
#define DEG_TO_RAD (M_PI / 180.0)
#define METRES_PER_DEG 111194.93 // mean Earth radius 6371 km; a block is small enough for a flat projection
#define ACCEL_MPS2 1.5 // pulling away from a corner
#define DECEL_MPS2 2.0 // braking ahead of a corner
#define LATERAL_MPS2 2.0 // sideways acceleration a normal driver takes a bend at
#define STRAIGHT_DEG 3.0 // kinks gentler than this are digitising noise, not bends
#define BEND_SPAN_M 15.0 // longest segment counted towards a bend's radius (see route_init)
static const route_point_t *s_pts;
static int s_n;
static double s_len[ROUTE_MAX_POINTS]; // segment i runs from point i to point (i+1) % n
static double s_bearing_deg[ROUTE_MAX_POINTS];
static double s_corner_mps[ROUTE_MAX_POINTS]; // speed limit at point i, where segment i starts
static double s_cruise_mps;
static int s_seg;
static double s_pos_m; // distance along the current segment
static double segment_speed(int seg, double pos_m)
{
double v = s_cruise_mps;
double pull_away = sqrt(s_corner_mps[seg] * s_corner_mps[seg] + 2.0 * ACCEL_MPS2 * pos_m);
int next = (seg + 1) % s_n;
double braking = sqrt(s_corner_mps[next] * s_corner_mps[next]
+ 2.0 * DECEL_MPS2 * (s_len[seg] - pos_m));
if (pull_away < v) v = pull_away;
if (braking < v) v = braking;
return v;
}
int route_init(const route_point_t *points, int n, double cruise_mps, double min_corner_mps)
{
if (n < 2 || n > ROUTE_MAX_POINTS) {
return -1;
}
s_pts = points;
s_n = n;
s_cruise_mps = cruise_mps;
for (int i = 0; i < n; i++) {
const route_point_t *a = &points[i];
const route_point_t *b = &points[(i + 1) % n];
double mid_lat = (a->latitude_tenmicrodeg + (double)b->latitude_tenmicrodeg) / 2e7;
double north_m = (b->latitude_tenmicrodeg - a->latitude_tenmicrodeg) / 1e7 * METRES_PER_DEG;
double east_m = (b->longitude_tenmicrodeg - a->longitude_tenmicrodeg) / 1e7 * METRES_PER_DEG
* cos(mid_lat * DEG_TO_RAD);
s_len[i] = sqrt(north_m * north_m + east_m * east_m);
if (s_len[i] < 0.01) {
return -1;
}
double bearing = atan2(east_m, north_m) / DEG_TO_RAD;
s_bearing_deg[i] = bearing < 0 ? bearing + 360.0 : bearing;
}
// Speed limit at each point from how tight the bend there is. A polyline bend of angle theta
// between segments of length L approximates an arc of radius L / theta, and a car takes a
// radius R at sqrt(a_lat * R). L is capped at BEND_SPAN_M: at a junction the two streets can be
// hundreds of metres long, but the car still turns within the width of the crossing.
for (int i = 0; i < n; i++) {
double turn = fabs(s_bearing_deg[i] - s_bearing_deg[(i + n - 1) % n]);
if (turn > 180.0) {
turn = 360.0 - turn;
}
double v = cruise_mps;
if (turn > STRAIGHT_DEG) {
double span = s_len[(i + n - 1) % n] < s_len[i] ? s_len[(i + n - 1) % n] : s_len[i];
if (span > BEND_SPAN_M) {
span = BEND_SPAN_M;
}
v = sqrt(LATERAL_MPS2 * span / (turn * DEG_TO_RAD));
}
if (v > cruise_mps) v = cruise_mps;
if (v < min_corner_mps) v = min_corner_mps;
s_corner_mps[i] = v;
}
// A point's limit also has to respect the bends after it (the car must be able to brake for
// them within the segments in between) and before it (it can only have sped up so much since).
// segment_speed only looks at the two ends of a segment, so settle this here. Limits only ever
// go down, so repeating the two passes until nothing changes terminates.
for (int changed = 1; changed;) {
changed = 0;
for (int k = 0; k < 2 * n; k++) {
int i = (2 * n - 1 - k) % n; // backwards: braking
int next = (i + 1) % n;
double v = sqrt(s_corner_mps[next] * s_corner_mps[next] + 2.0 * DECEL_MPS2 * s_len[i]);
if (v < s_corner_mps[i] - 1e-9) { s_corner_mps[i] = v; changed = 1; }
}
for (int k = 0; k < 2 * n; k++) {
int i = k % n; // forwards: accelerating
int next = (i + 1) % n;
double v = sqrt(s_corner_mps[i] * s_corner_mps[i] + 2.0 * ACCEL_MPS2 * s_len[i]);
if (v < s_corner_mps[next] - 1e-9) { s_corner_mps[next] = v; changed = 1; }
}
}
s_seg = 0;
s_pos_m = 0.0;
return 0;
}
void route_step(double dt_s, route_state_t *out)
{
// Advance with the speed at the start of the step; at 100 ms steps the error is well under a metre.
double d = segment_speed(s_seg, s_pos_m) * dt_s;
while (s_pos_m + d >= s_len[s_seg]) {
d -= s_len[s_seg] - s_pos_m;
s_seg = (s_seg + 1) % s_n;
s_pos_m = 0.0;
}
s_pos_m += d;
const route_point_t *a = &s_pts[s_seg];
const route_point_t *b = &s_pts[(s_seg + 1) % s_n];
double f = s_pos_m / s_len[s_seg];
out->latitude_tenmicrodeg = (int32_t)lround(a->latitude_tenmicrodeg
+ f * (b->latitude_tenmicrodeg - a->latitude_tenmicrodeg));
out->longitude_tenmicrodeg = (int32_t)lround(a->longitude_tenmicrodeg
+ f * (b->longitude_tenmicrodeg - a->longitude_tenmicrodeg));
out->speed_cm_s = (uint16_t)lround(segment_speed(s_seg, s_pos_m) * 100.0);
out->heading_ddeg = (uint16_t)(lround(s_bearing_deg[s_seg] * 10.0) % 3600);
out->segment = s_seg;
}
+40
View File
@@ -0,0 +1,40 @@
#ifndef ROUTE_H
#define ROUTE_H
#include <stdint.h>
// Simulated drive around a closed loop of waypoints, so the beacon looks like a
// car going round the block instead of a parked one.
//
// The car follows straight lines between the points and goes from the last one
// back to the first, forever. For street-following, the points are the street
// geometry from tools/make_route.py (main/route_points.h). Speed is a function
// of where the car is on a segment: it cruises, brakes ahead of each bend down to
// the speed that bend allows, and accelerates away after it. Heading is the
// bearing of the current segment.
//
// Uses only standard headers, so it also compiles on the host for testing.
typedef struct {
int32_t latitude_tenmicrodeg; // 1/10 microdegree, same units as the CAM
int32_t longitude_tenmicrodeg;
} route_point_t;
typedef struct {
int32_t latitude_tenmicrodeg;
int32_t longitude_tenmicrodeg;
uint16_t speed_cm_s; // SpeedValue units (0.01 m/s)
uint16_t heading_ddeg; // HeadingValue units (0.1 deg, 0..3599, 0 = north, clockwise)
int segment; // index of the route point the car last passed
} route_state_t;
#define ROUTE_MAX_POINTS 512 // keep in step with MAX_POINTS in tools/make_route.py
// `points` must stay valid for as long as the route is used. Returns 0, or -1
// if n is out of range (2..ROUTE_MAX_POINTS) or a segment has zero length.
// min_corner_mps is the slowest the car ever goes (hairpins, U-turns).
int route_init(const route_point_t *points, int n, double cruise_mps, double min_corner_mps);
// Moves the car on by dt_s seconds and writes its new position into `out`.
void route_step(double dt_s, route_state_t *out);
#endif
+116
View File
@@ -0,0 +1,116 @@
// GENERATED by tools/make_route.py - do not edit by hand; change WAYPOINTS there and rerun.
// Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.
//
// Driving loop through 6 waypoints: 5179 m (legs 147 m, 1837 m, 381 m, 819 m, 1234 m, 761 m), 103 points after
// simplifying to 1.5 m. Streets: Beim Strohhause, Berlinertordamm, Berliner Tor, Bei der Hauptfeuerwache, Westphalensweg, Berliner Tor, Berlinertordamm, Borgfelder Straße, Anckelmannstraße, Anckelmannsplatz, Bürgerweide, Wallstraße, Lübeckertordamm, Steindamm, Kreuzweg, Adenauerallee, Nagelsweg, Norderstraße, Repsoldstraße, Kurt-Schumacher-Allee, Kreuzweg, Adenauerallee, Kurt-Schumacher-Allee, Beim Strohhause.
#ifndef ROUTE_POINTS_H
#define ROUTE_POINTS_H
#include "route.h"
static const route_point_t route_points[] = {
{ 535531770, 100220980 },
{ 535534470, 100240600 },
{ 535535790, 100244160 },
{ 535536400, 100244520 },
{ 535537130, 100244160 },
{ 535538480, 100242840 },
{ 535538720, 100242140 },
{ 535540390, 100240200 },
{ 535548880, 100233930 },
{ 535549470, 100235130 },
{ 535554140, 100253280 },
{ 535553570, 100254580 },
{ 535546070, 100251700 },
{ 535543130, 100250020 },
{ 535542570, 100249130 },
{ 535542760, 100247800 },
{ 535539980, 100244600 },
{ 535538720, 100242140 },
{ 535538140, 100241960 },
{ 535535720, 100243380 },
{ 535535300, 100244470 },
{ 535535090, 100246580 },
{ 535536830, 100264460 },
{ 535537600, 100270920 },
{ 535538520, 100275220 },
{ 535541110, 100290830 },
{ 535540170, 100291550 },
{ 535539890, 100294430 },
{ 535539580, 100295330 },
{ 535532290, 100299300 },
{ 535527980, 100302450 },
{ 535525130, 100291880 },
{ 535524220, 100289760 },
{ 535522900, 100287820 },
{ 535522890, 100285300 },
{ 535522610, 100282810 },
{ 535520620, 100276050 },
{ 535519820, 100271130 },
{ 535519670, 100269030 },
{ 535520220, 100267120 },
{ 535520930, 100262840 },
{ 535521850, 100260020 },
{ 535522840, 100258210 },
{ 535527790, 100257880 },
{ 535538650, 100261370 },
{ 535551660, 100266470 },
{ 535555790, 100268750 },
{ 535559620, 100271540 },
{ 535561120, 100272050 },
{ 535562430, 100271250 },
{ 535563830, 100269390 },
{ 535569610, 100260190 },
{ 535579530, 100242810 },
{ 535584090, 100237660 },
{ 535585850, 100234670 },
{ 535584970, 100230060 },
{ 535584000, 100227260 },
{ 535580730, 100222030 },
{ 535579100, 100218590 },
{ 535574500, 100208430 },
{ 535570560, 100199010 },
{ 535568130, 100194820 },
{ 535564910, 100187620 },
{ 535563990, 100184630 },
{ 535562540, 100181160 },
{ 535559780, 100176310 },
{ 535542180, 100136840 },
{ 535539720, 100133430 },
{ 535537350, 100132010 },
{ 535534760, 100131390 },
{ 535523840, 100132690 },
{ 535524980, 100155090 },
{ 535524890, 100156360 },
{ 535524440, 100157650 },
{ 535523030, 100158530 },
{ 535517610, 100158580 },
{ 535504440, 100168810 },
{ 535501360, 100156400 },
{ 535499250, 100144880 },
{ 535498470, 100133880 },
{ 535498630, 100126150 },
{ 535499110, 100121980 },
{ 535504260, 100117230 },
{ 535505720, 100115500 },
{ 535507630, 100112310 },
{ 535508400, 100111560 },
{ 535510000, 100110940 },
{ 535511390, 100119560 },
{ 535512440, 100122280 },
{ 535514510, 100132770 },
{ 535515020, 100134120 },
{ 535516630, 100135630 },
{ 535518260, 100136100 },
{ 535523950, 100134870 },
{ 535524980, 100155090 },
{ 535524890, 100156360 },
{ 535524440, 100157650 },
{ 535523470, 100158460 },
{ 535518560, 100158480 },
{ 535519070, 100162430 },
{ 535522260, 100178080 },
{ 535527880, 100197650 },
{ 535530060, 100209020 },
};
#endif
+170
View File
@@ -0,0 +1,170 @@
"""Turn the beacon's waypoints into a street-following route for main/route_points.h.
Asks the OSRM demo server (router.project-osrm.org, OpenStreetMap data) for a driving route that
visits WAYPOINTS in order and returns to the first, thins the street geometry out, and writes:
main/route_points.h the C array the firmware drives (commit this)
tools/route_osrm.json the raw OSRM answer, so the header can be regenerated offline (--offline)
tools/route_map.html the route over an OpenStreetMap map, to check it before flashing
Each waypoint also gets a bearing (the direction towards the next waypoint), so OSRM snaps it onto
the carriageway going that way. Without it, points on divided roads land on the wrong side and
every leg grows a U-turn detour.
Usage (Python 3.8+, standard library only):
py tools/make_route.py fetch from OSRM, then write all three files
py -3 tools/make_route.py --offline rebuild from the saved route_osrm.json
Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.
"""
import argparse
import json
import math
import pathlib
import urllib.request
# (latitude, longitude) in decimal degrees, in driving order. The route closes back to the first.
WAYPOINTS = [
(53.553309, 10.022043),
(53.553611, 10.024146),
(53.556164, 10.027121),
(53.558310, 10.023362),
(53.554062, 10.013515),
(53.551540, 10.013398),
]
BEARING_TOLERANCE_DEG = 60 # how far the road's direction may differ from the waypoint bearing
SIMPLIFY_M = 1.5 # drop points that move the line by less than this
MAX_POINTS = 512 # must match ROUTE_MAX_POINTS in main/route.h
HERE = pathlib.Path(__file__).resolve().parent
PROJECT = HERE.parent
OSRM_JSON = HERE / "route_osrm.json"
HEADER = PROJECT / "main" / "route_points.h"
MAP_HTML = HERE / "route_map.html"
METRES_PER_DEG = 111194.93
def to_xy(lat, lon, lat0):
return (lon * METRES_PER_DEG * math.cos(math.radians(lat0)), lat * METRES_PER_DEG)
def bearing(a, b):
north = b[0] - a[0]
east = (b[1] - a[1]) * math.cos(math.radians(a[0]))
return math.degrees(math.atan2(east, north)) % 360
def fetch():
n = len(WAYPOINTS)
pts = WAYPOINTS + [WAYPOINTS[0]]
bearings = [round(bearing(WAYPOINTS[i], WAYPOINTS[(i + 1) % n])) for i in range(n)]
bearings.append(bearings[0])
url = ("https://router.project-osrm.org/route/v1/driving/"
+ ";".join(f"{lon},{lat}" for lat, lon in pts)
+ "?overview=full&geometries=geojson&steps=true&bearings="
+ ";".join(f"{b},{BEARING_TOLERANCE_DEG}" for b in bearings))
req = urllib.request.Request(url, headers={"User-Agent": "MicrOBU-route-tool"})
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.load(resp)
if data.get("code") != "Ok":
raise SystemExit(f"OSRM error: {data.get('code')} {data.get('message')}")
OSRM_JSON.write_text(json.dumps(data, indent=1), encoding="utf-8")
return data
def simplify(xy, tol):
"""Douglas-Peucker, iterative. Keeps the first and last point."""
keep = [False] * len(xy)
keep[0] = keep[-1] = True
stack = [(0, len(xy) - 1)]
while stack:
a, b = stack.pop()
(ax, ay), (bx, by) = xy[a], xy[b]
dx, dy = bx - ax, by - ay
seg2 = dx * dx + dy * dy
worst, worst_d = -1, tol
for i in range(a + 1, b):
px, py = xy[i]
if seg2 == 0:
d = math.hypot(px - ax, py - ay)
else:
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg2))
d = math.hypot(px - ax - t * dx, py - ay - t * dy)
if d > worst_d:
worst, worst_d = i, d
if worst >= 0:
keep[worst] = True
stack += [(a, worst), (worst, b)]
return [i for i, k in enumerate(keep) if k]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--offline", action="store_true", help="use the saved route_osrm.json")
args = ap.parse_args()
data = json.loads(OSRM_JSON.read_text(encoding="utf-8")) if args.offline else fetch()
route = data["routes"][0]
# GeoJSON is [lon, lat]; round to the CAM's 1/10-microdegree grid and drop repeats.
raw = []
for lon, lat in route["geometry"]["coordinates"]:
p = (round(lat * 1e7), round(lon * 1e7))
if not raw or p != raw[-1]:
raw.append(p)
if raw[0] == raw[-1]:
raw.pop()
closed = raw + [raw[0]]
lat0 = closed[0][0] / 1e7
xy = [to_xy(p[0] / 1e7, p[1] / 1e7, lat0) for p in closed]
pts = [closed[i] for i in simplify(xy, SIMPLIFY_M)][:-1]
if len(pts) > MAX_POINTS:
raise SystemExit(f"{len(pts)} points is more than MAX_POINTS={MAX_POINTS}; raise SIMPLIFY_M")
streets = []
for leg in route["legs"]:
for step in leg["steps"]:
if step["name"] and (not streets or streets[-1] != step["name"]):
streets.append(step["name"])
legs = ", ".join(f"{round(l['distance'])} m" for l in route["legs"])
lines = [
"// GENERATED by tools/make_route.py - do not edit by hand; change WAYPOINTS there and rerun.",
"// Route data (c) OpenStreetMap contributors, ODbL. Routing by OSRM.",
"//",
f"// Driving loop through {len(WAYPOINTS)} waypoints: {round(route['distance'])} m "
f"(legs {legs}), {len(pts)} points after",
f"// simplifying to {SIMPLIFY_M} m. Streets: {', '.join(streets)}.",
"#ifndef ROUTE_POINTS_H",
"#define ROUTE_POINTS_H",
'#include "route.h"',
"",
"static const route_point_t route_points[] = {",
]
lines += [f" {{ {lat}, {lon} }}," for lat, lon in pts]
lines += ["};", "", "#endif", ""]
HEADER.write_text("\n".join(lines), encoding="utf-8", newline="\n")
MAP_HTML.write_text(f"""<!doctype html><meta charset="utf-8"><title>Beacon route</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>html,body,#m{{height:100%;margin:0}}</style><div id="m"></div><script>
const pts={json.dumps([[p[0] / 1e7, p[1] / 1e7] for p in pts])};
const wps={json.dumps(WAYPOINTS)};
const m=L.map('m');
L.tileLayer('https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{maxZoom:19,
attribution:'&copy; OpenStreetMap contributors'}}).addTo(m);
const line=L.polyline(pts.concat([pts[0]]),{{color:'#d33',weight:4}}).addTo(m);
wps.forEach((w,i)=>L.marker(w,{{title:'wp'+(i+1)}}).bindTooltip('wp'+(i+1),{{permanent:true}}).addTo(m));
L.circleMarker(pts[0],{{radius:7,color:'#060'}}).bindTooltip('start').addTo(m);
m.fitBounds(line.getBounds(),{{padding:[20,20]}});
</script>
""", encoding="utf-8")
print(f"{round(route['distance'])} m, legs {legs}")
print(f"{len(route['geometry']['coordinates'])} OSRM points -> {len(pts)} after simplifying")
print(f"wrote {HEADER.relative_to(PROJECT)}, {OSRM_JSON.relative_to(PROJECT)}, "
f"{MAP_HTML.relative_to(PROJECT)}")
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
<!doctype html><meta charset="utf-8"><title>Beacon route</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>html,body,#m{height:100%;margin:0}</style><div id="m"></div><script>
const pts=[[53.553177, 10.022098], [53.553447, 10.02406], [53.553579, 10.024416], [53.55364, 10.024452], [53.553713, 10.024416], [53.553848, 10.024284], [53.553872, 10.024214], [53.554039, 10.02402], [53.554888, 10.023393], [53.554947, 10.023513], [53.555414, 10.025328], [53.555357, 10.025458], [53.554607, 10.02517], [53.554313, 10.025002], [53.554257, 10.024913], [53.554276, 10.02478], [53.553998, 10.02446], [53.553872, 10.024214], [53.553814, 10.024196], [53.553572, 10.024338], [53.55353, 10.024447], [53.553509, 10.024658], [53.553683, 10.026446], [53.55376, 10.027092], [53.553852, 10.027522], [53.554111, 10.029083], [53.554017, 10.029155], [53.553989, 10.029443], [53.553958, 10.029533], [53.553229, 10.02993], [53.552798, 10.030245], [53.552513, 10.029188], [53.552422, 10.028976], [53.55229, 10.028782], [53.552289, 10.02853], [53.552261, 10.028281], [53.552062, 10.027605], [53.551982, 10.027113], [53.551967, 10.026903], [53.552022, 10.026712], [53.552093, 10.026284], [53.552185, 10.026002], [53.552284, 10.025821], [53.552779, 10.025788], [53.553865, 10.026137], [53.555166, 10.026647], [53.555579, 10.026875], [53.555962, 10.027154], [53.556112, 10.027205], [53.556243, 10.027125], [53.556383, 10.026939], [53.556961, 10.026019], [53.557953, 10.024281], [53.558409, 10.023766], [53.558585, 10.023467], [53.558497, 10.023006], [53.5584, 10.022726], [53.558073, 10.022203], [53.55791, 10.021859], [53.55745, 10.020843], [53.557056, 10.019901], [53.556813, 10.019482], [53.556491, 10.018762], [53.556399, 10.018463], [53.556254, 10.018116], [53.555978, 10.017631], [53.554218, 10.013684], [53.553972, 10.013343], [53.553735, 10.013201], [53.553476, 10.013139], [53.552384, 10.013269], [53.552498, 10.015509], [53.552489, 10.015636], [53.552444, 10.015765], [53.552303, 10.015853], [53.551761, 10.015858], [53.550444, 10.016881], [53.550136, 10.01564], [53.549925, 10.014488], [53.549847, 10.013388], [53.549863, 10.012615], [53.549911, 10.012198], [53.550426, 10.011723], [53.550572, 10.01155], [53.550763, 10.011231], [53.55084, 10.011156], [53.551, 10.011094], [53.551139, 10.011956], [53.551244, 10.012228], [53.551451, 10.013277], [53.551502, 10.013412], [53.551663, 10.013563], [53.551826, 10.01361], [53.552395, 10.013487], [53.552498, 10.015509], [53.552489, 10.015636], [53.552444, 10.015765], [53.552347, 10.015846], [53.551856, 10.015848], [53.551907, 10.016243], [53.552226, 10.017808], [53.552788, 10.019765], [53.553006, 10.020902]];
const wps=[[53.553309, 10.022043], [53.553611, 10.024146], [53.556164, 10.027121], [53.55831, 10.023362], [53.554062, 10.013515], [53.55154, 10.013398]];
const m=L.map('m');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:19,
attribution:'&copy; OpenStreetMap contributors'}).addTo(m);
const line=L.polyline(pts.concat([pts[0]]),{color:'#d33',weight:4}).addTo(m);
wps.forEach((w,i)=>L.marker(w,{title:'wp'+(i+1)}).bindTooltip('wp'+(i+1),{permanent:true}).addTo(m));
L.circleMarker(pts[0],{radius:7,color:'#060'}).bindTooltip('start').addTo(m);
m.fitBounds(line.getBounds(),{padding:[20,20]});
</script>
File diff suppressed because it is too large Load Diff
+60 -7
View File
@@ -1,9 +1,62 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.22)
# obu-firmware: the ESP32-C5 half of the MicrOBU station, on the vanetza-idf C-ITS stack.
#
# Since 2026-09-23 this is a port of the colleague's standalone VRU station
# (microbu-esp32c5/firmware, 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()
+37 -11
View File
@@ -2,24 +2,38 @@
## Two toolchains - use a dedicated terminal for each ## Two toolchains - use a dedicated terminal for each
This project builds against the receiver-firmware's pinned ESP-IDF **6.1**. Since 2026-09-23 this project builds against ESP-IDF **6.0.2** exactly
The separate `obu-cam-transmistter` project builds against the global ESP-IDF (`C:\Espressif\frameworks\esp-idf-v6.0.2`): it is the vanetza-idf port (see
**5.5.4**. Exporting both in one PowerShell window fails: the second export NOTES.md), and vanetza-idf's `radio_c5.cmake` refuses any other version because
the raw TX path pokes private Wi-Fi driver structures only validated there. The
previous C firmware used the receiver firmware's IDF **6.1**; the separate
`obu-cam-transmistter` project builds against the global ESP-IDF **5.5.4**.
Exporting two of them in one PowerShell window fails: the second export
inherits the first's `IDF_PYTHON_ENV_PATH` and reports every Python dependency 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`.
## Every new PowerShell session 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/`.
Activate the toolchain (obu-firmware has no esp-idf of its own — reuse the ## Every new PowerShell session
receiver firmware's already-installed checkout):
```powershell ```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
C:\Users\Ashin\Documents\micrOBU_workspace\its-g5-receiver-firmware\esp-idf\export.ps1 $env:IDF_TOOLS_PATH = "C:\Espressif"
idf.py --version C:\Espressif\frameworks\esp-idf-v6.0.2\export.ps1
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
@@ -97,10 +111,22 @@ 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 — always export from - Don't use the global "ESP-IDF 5.5 PowerShell" shortcut or the receiver
the receiver-firmware's pinned checkout, since this firmware's undocumented firmware's 6.1 checkout — export from `esp-idf-v6.0.2`, the version the
PHY/driver internals were verified against that specific build. vanetza-idf radio's undocumented driver internals were verified against.
- The console (ESP_LOG, boot messages, panics) stays on the UART-bridge port.
Opening it resets the board; do that only while nothing else depends on the
session.
+54 -1
View File
@@ -1,4 +1,57 @@
# OBU transmit firmware - Phase 2 (in progress: HLN-SV DENM beacon) # obu-firmware
## Since 2026-09-23: signed ITS on vanetza-idf
This firmware is a port of the colleague's standalone ESP32-C5 VRU station
(`microbu-esp32c5/firmware`, 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.
+21
View File
@@ -0,0 +1,21 @@
dependencies:
espressif/esp-boost:
component_hash: 45cfa63ade2ad7c489203ab2ddf3f33be50ec9cab752b6eb17a79f06aee8f8f0
dependencies:
- name: idf
require: private
version: '>=5.3'
source:
registry_url: https://components.espressif.com/
type: service
version: 0.4.1
idf:
source:
type: idf
version: 6.0.2
direct_dependencies:
- espressif/esp-boost
- idf
manifest_hash: 3fca1283d556ade5b08c63857e23cb3b08582f1d1c24bb7c1d5e374f438415d7
target: esp32c5
version: 3.0.0
+20 -16
View File
@@ -1,18 +1,22 @@
# wifi_patches.c is intentionally NOT in this list anymore - superseded by # C++ station (from microbu-esp32c5/firmware/main) plus the C files of the previous firmware that
# tx_custom.c (see that file for why). Left on disk, unused, for history. # still do a job here:
# 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).
# #
# cam.c is ALSO intentionally not in this list anymore (Phase 03): CAM is now built on the # Left on disk and NOT built, like cam.c before them:
# phone and sent down over serial_link, so this firmware never encodes CAM itself. cam.c/.h are # main.c, serial_link.c/.h - the previous firmware's entry point and phone link (frame types
# left on disk as the byte-exact reference the Kotlin encoder was ported from - do not delete. # 0x01-0x05). Superseded by app_main.cpp and the station-link protocol.
# # dot11p.c/.h - previous 802.11 framing; c5_radio.cpp frames through vanetza-idf now.
# serial_link.c/.h - binary phone<->ESP32 framing over the native USB Serial/JTAG port # Still compiled by the host tests in test/host.
# (Phase 03; changed from a GPIO UART1 wire to native USB because neither # tx_custom.c/.h - previous copy of the OpenTrafficMap raw TX; otm_tx_custom.c replaces it.
# USB-C port on the ESP32-C5-WIFI6-KIT was actually routed to that UART). # denm.c/.h, cam.c/.h, wifi_patches.c - reference only, as before.
# gn_unwrap.c/.h - strips 802.11/LLC-SNAP/GeoNetworking/BTP-B off received frames down to CAM idf_component_register(SRCS "app_main.cpp" "board_controls.cpp" "simple_ble.cpp" "c5_radio.cpp" "otm_tx_custom.c"
# UPER bytes, for forwarding to the phone over serial_link. "link_protocol.cpp" "serial_link.cpp" "station.cpp" "station_test.cpp"
idf_component_register( "link_service.cpp" "test_channel.cpp"
SRCS "main.c" "denm.c" "geonet.c" "dot11p.c" "tx_custom.c" "serial_link.c" "gn_unwrap.c" "gn_unwrap.c" "geonet.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES esp_event esp_netif nvs_flash driver esp_phy esp_driver_gpio esp_driver_usb_serial_jtag LDFRAGMENTS "linker.lf"
PRIV_REQUIRES esp_wifi REQUIRES vanetza-idf esp_wifi esp_phy esp_event bt esp_driver_gpio esp_driver_usb_serial_jtag nvs_flash esp_timer)
) target_compile_features(${COMPONENT_LIB} PRIVATE cxx_std_17)
+39
View File
@@ -0,0 +1,39 @@
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
+142
View File
@@ -0,0 +1,142 @@
// 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, &copy, 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, &copy, 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);
}
+65
View File
@@ -0,0 +1,65 @@
#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
+31
View File
@@ -0,0 +1,31 @@
#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
+469
View File
@@ -0,0 +1,469 @@
#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
+117
View File
@@ -0,0 +1,117 @@
#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
+186
View File
@@ -0,0 +1,186 @@
#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
+363
View File
@@ -0,0 +1,363 @@
#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
+159
View File
@@ -0,0 +1,159 @@
#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
+51
View File
@@ -0,0 +1,51 @@
#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
+4
View File
@@ -0,0 +1,4 @@
[mapping:newlib_memcmp]
archive: libnewlib.a
entries:
memcmp (noflash)
+7 -2
View File
@@ -259,8 +259,13 @@ static void wifi_promisc_rx_cb(void *recv_buf, wifi_promiscuous_pkt_type_t type)
s_cb_item.rssi = packet->rx_ctrl.rssi; s_cb_item.rssi = packet->rx_ctrl.rssi;
// 0 timeout: never block the WiFi driver's own task waiting for queue space. xQueueSend copies // 0 timeout: never block the WiFi driver's own task waiting for queue space. xQueueSend copies
// the struct out before returning, so reusing s_cb_item on the next callback is fine. // the struct out before returning, so reusing s_cb_item on the next callback is fine. The
xQueueSend(s_rx_queue, &s_cb_item, 0); // return value used to go unchecked, so a full queue (rx_forward_task still draining a
// previous burst) silently ate frames with no counter anywhere - see
// serial_link_note_rx_queue_drop()'s KDoc.
if (xQueueSend(s_rx_queue, &s_cb_item, 0) != pdTRUE) {
serial_link_note_rx_queue_drop();
}
} }
static void rx_forward_task(void *arg) static void rx_forward_task(void *arg)
+175
View File
@@ -0,0 +1,175 @@
// 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;
}
+23
View File
@@ -0,0 +1,23 @@
#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
+13 -3
View File
@@ -21,6 +21,7 @@ static serial_link_cam_tx_pv_cb_t s_on_cam_tx_pv;
static uint16_t s_oversize_drops; static uint16_t s_oversize_drops;
static uint16_t s_tx_failures; static uint16_t s_tx_failures;
static uint16_t s_rx_crc_errors; static uint16_t s_rx_crc_errors;
static uint16_t s_rx_queue_drops;
// Serializes send_frame(): it writes a frame as four separate usb_serial_jtag_write_bytes() calls // Serializes send_frame(): it writes a frame as four separate usb_serial_jtag_write_bytes() calls
// and shares one static CRC scratch buffer, and it's now called from three tasks (rx_forward for // and shares one static CRC scratch buffer, and it's now called from three tasks (rx_forward for
@@ -45,6 +46,12 @@ void serial_link_note_oversize_drop(uint16_t btp_dest_port)
btp_dest_port, s_oversize_drops); btp_dest_port, s_oversize_drops);
} }
void serial_link_note_rx_queue_drop(void)
{
bump(&s_rx_queue_drops);
ESP_LOGW(TAG, "rx queue full, dropped a captured frame, total rx queue drops %u", s_rx_queue_drops);
}
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ---- // ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table // Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly // lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
@@ -165,9 +172,10 @@ bool serial_link_send_v2x_rx(uint16_t btp_dest_port, int8_t rssi,
bool serial_link_send_status(uint8_t status) bool serial_link_send_status(uint8_t status)
{ {
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1] - // [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
// keep in lockstep with EspLinkStatus.parse() in the app's SerialFrame.kt. // [rx_queue_drops:2 LE] - keep in lockstep with EspLinkStatus.parse() in the app's
uint8_t payload[8]; // SerialFrame.kt.
uint8_t payload[10];
payload[0] = status; payload[0] = status;
payload[1] = (uint8_t)(s_oversize_drops & 0xFF); payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF); payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
@@ -178,6 +186,8 @@ bool serial_link_send_status(uint8_t status)
// What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which // What this firmware accepts. The app reads it to decide whether it may send CAM_TX_PV, which
// is what lets a new app keep working against firmware that predates that message. // is what lets a new app keep working against firmware that predates that message.
payload[7] = SERIAL_CAP_CAM_TX_PV; payload[7] = SERIAL_CAP_CAM_TX_PV;
payload[8] = (uint8_t)(s_rx_queue_drops & 0xFF);
payload[9] = (uint8_t)((s_rx_queue_drops >> 8) & 0xFF);
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload)); return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
} }
+160
View File
@@ -0,0 +1,160 @@
#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
+14 -2
View File
@@ -52,11 +52,14 @@
// message's own ItsPduHeader.stationID is the meaningful identifier. // message's own ItsPduHeader.stationID is the meaningful identifier.
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can // SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
// distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in // distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 8 bytes: // UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 10 bytes:
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1] // [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE][capabilities:1]
// [rx_queue_drops:2 LE]
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF. // status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
// capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather // capabilities is a bitmask of the SERIAL_CAP_* flags below. It was appended as byte 7 rather
// than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected. // than inserted, so an app that predates it, and reads only the first 7 bytes, is unaffected;
// rx_queue_drops (bytes 8-9) follows the same rule for an app that predates it. Either side
// reading a payload shorter than the field it wants should treat that field as 0, not error.
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the // They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus // phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
// in the app's SerialFrame.kt. // in the app's SerialFrame.kt.
@@ -165,4 +168,13 @@ void serial_link_note_tx_failure(void);
// whose capture buffer is smaller than the largest frames on air. // whose capture buffer is smaller than the largest frames on air.
void serial_link_note_oversize_drop(uint16_t btp_dest_port); void serial_link_note_oversize_drop(uint16_t btp_dest_port);
// Counts a promiscuously-captured frame that main.c's wifi_promisc_rx_cb() could not hand to
// rx_forward_task because s_rx_queue was full - i.e. frames arrived faster than the forward task
// (gn_unwrap + a blocking USB write, up to SERIAL_LINK_WRITE_TIMEOUT_MS x 4 per frame under
// contention) could drain them. Unlike oversize_drop this is not about one frame's size; it is
// about a burst of otherwise-forwardable frames. Previously silent - xQueueSend's return value
// was not even checked - so a run of these had no visible symptom beyond "that station's CAM
// count looked a little low."
void serial_link_note_rx_queue_drop(void);
#endif #endif
+75
View File
@@ -0,0 +1,75 @@
#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
+493
View File
@@ -0,0 +1,493 @@
#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, &params);
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,
&params, 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
+58
View File
@@ -0,0 +1,58 @@
#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
+653
View File
@@ -0,0 +1,653 @@
#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
+266
View File
@@ -0,0 +1,266 @@
#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
+130
View File
@@ -0,0 +1,130 @@
// 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
+79
View File
@@ -0,0 +1,79 @@
#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
+15
View File
@@ -0,0 +1,15 @@
#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
+8
View File
@@ -0,0 +1,8 @@
# 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,
1 # Name, Type, SubType, Offset, Size, Flags
2 # MicrOBU: NVS 80 KB instead of the colleague's 24 KB (their board has 4 MB of flash, this one 16 MB).
3 # NVS holds the BLE bond, the credential bundle and PHY calibration; at 24 KB, with Wi-Fi settings the
4 # previous firmware had left in it, there was no room for the bond, so the board forgot the phone
5 # after every connection. The app therefore moves from 0x10000 to 0x20000 (64 KB aligned).
6 nvs, data, nvs, 0x9000, 0x14000,
7 phy_init, data, phy, 0x1D000, 0x1000,
8 factory, app, factory, 0x20000, 0x300000,
+817 -986
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -1 +1,54 @@
# 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
+1 -3
View File
@@ -14,9 +14,7 @@ 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,)
# Captures live in capture/recordings/ since 2026-09-14; older ones are still in the RECORDINGS = $(wildcard ../../../its-g5-receiver-firmware/recordings/*.pcap)
# 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
+241
View File
@@ -0,0 +1,241 @@
#!/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())