Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f60623e18 | ||
|
|
a08494b56a | ||
|
|
d2fd222a62 |
+13
@@ -46,3 +46,16 @@ sdkconfig.old
|
|||||||
# Office lock files. Word/Excel create these beside a document while it is open
|
# Office lock files. Word/Excel create these beside a document while it is open
|
||||||
# and remove them on close, so they are transient and machine-local.
|
# and remove them on close, so they are transient and machine-local.
|
||||||
~$*
|
~$*
|
||||||
|
|
||||||
|
# Colleague's standalone ESP32-C5 VRU station (signed ITS via Vanetza). It is its
|
||||||
|
# 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/
|
||||||
|
|||||||
@@ -5,6 +5,107 @@ 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)
|
### 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
|
Investigated the user's report of "OBU mode keeps dropping a few frames" and "v2x screen comes
|
||||||
|
|||||||
@@ -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.
@@ -30,7 +30,8 @@ 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
|
||||||
@@ -88,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.
|
||||||
@@ -128,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)
|
||||||
@@ -146,7 +156,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -163,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,
|
||||||
@@ -202,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,
|
||||||
@@ -304,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,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
|
||||||
@@ -362,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.
|
||||||
*/
|
*/
|
||||||
@@ -377,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -461,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,24 +119,66 @@ 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) {
|
||||||
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
when (outgoing) {
|
||||||
// rotation fall cleanly between two frames instead of inside one.
|
OutgoingMessage.CAM -> sendCam(gnss)
|
||||||
val pseudonym = pseudonymManager.current()
|
OutgoingMessage.VAM -> sendVamIfDue(gnss)
|
||||||
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
}
|
||||||
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
|
||||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
|
||||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
|
||||||
val bytes = codec.encodeCam(cam)
|
|
||||||
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
|
|
||||||
}
|
}
|
||||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
delay(if (outgoing == OutgoingMessage.VAM) VAM_TICK_MS else (1000.0 / currentRateHz(gnss)).toLong())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun sendCam(gnss: GnssReading) {
|
||||||
|
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||||
|
// rotation fall cleanly between two frames instead of inside one.
|
||||||
|
val pseudonym = pseudonymManager.current()
|
||||||
|
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
|
||||||
|
// outgoing CAM is: acceleration below still differences wall-clock samples.
|
||||||
|
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||||
|
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||||
|
val bytes = codec.encodeCam(cam)
|
||||||
|
esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes,
|
||||||
|
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendVamIfDue(gnss: GnssReading) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val kinematics = VamGenerationRules.Kinematics(gnss.latitude, gnss.longitude,
|
||||||
|
gnss.speedMs.toDouble(), gnss.bearingDeg.toDouble())
|
||||||
|
if (!vamRules.due(now, kinematics)) return
|
||||||
|
val pseudonym = pseudonymManager.current()
|
||||||
|
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||||
|
// The CAM view of this fix is built only for its position vector, so the GN header of a VAM
|
||||||
|
// follows exactly the rules a CAM's does. It is not transmitted.
|
||||||
|
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||||
|
val withLowFrequency = vamRules.includeLowFrequency(now)
|
||||||
|
val bytes = VamUperCodec.encode(VamContent(
|
||||||
|
stationId = pseudonym.stationId,
|
||||||
|
timestamp = cam.timestamp,
|
||||||
|
latitude = cam.latitude,
|
||||||
|
longitude = cam.longitude,
|
||||||
|
accuracyM = gnss.accuracyM,
|
||||||
|
speedMps = cam.speedMps,
|
||||||
|
headingDeg = cam.headingDeg,
|
||||||
|
accelerationMps2 = cam.accelerationMps2,
|
||||||
|
includeLowFrequency = withLowFrequency,
|
||||||
|
))
|
||||||
|
val handedOver = esp32Link.send(OutgoingIts(OutgoingMessage.VAM, bytes,
|
||||||
|
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||||
|
if (handedOver) vamRules.onSent(now, kinematics, withLowFrequency)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Along-track acceleration in m/s², from the change in GNSS speed since the previous fix.
|
* Along-track acceleration in m/s², from the change in GNSS speed since the previous fix.
|
||||||
*
|
*
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,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
|
||||||
@@ -136,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()
|
||||||
@@ -161,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
|
||||||
|
|
||||||
@@ -216,8 +216,8 @@ fun MqttTopicViewerScreen(
|
|||||||
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()
|
||||||
}
|
}
|
||||||
@@ -253,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,
|
||||||
@@ -294,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,
|
||||||
@@ -338,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,
|
||||||
@@ -1473,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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -189,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>
|
||||||
@@ -318,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>
|
||||||
|
|||||||
@@ -190,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>
|
||||||
@@ -325,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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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, ©, 0) != pdTRUE) delete copy; // the phone retries on a missing RESULT
|
||||||
|
});
|
||||||
|
if (!microbu::ble::start([](microbu::link::Bytes message) {
|
||||||
|
microbu::serial::Frame frame {microbu::serial::FrameType::LINK, std::move(message)};
|
||||||
|
auto* copy = try_new_incoming(microbu::LinkTransport::ble, std::move(frame));
|
||||||
|
if (!copy) return;
|
||||||
|
if (xQueueSend(frames, ©, 0) != pdTRUE) delete copy; // same hand-off as the serial callback above
|
||||||
|
})) {
|
||||||
|
ESP_LOGE(TAG, "BLE station link failed to start. USB remains available");
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "MicrOBU obu-firmware on vanetza-idf, station-link v1 + V2X_RX, console on UART0%s",
|
||||||
|
#if CONFIG_MICROBU_TEST_CHANNEL
|
||||||
|
", test channel enabled");
|
||||||
|
#else
|
||||||
|
"");
|
||||||
|
#endif
|
||||||
|
xTaskCreate(station_task, "station", 12288, nullptr, 10, nullptr);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[mapping:newlib_memcmp]
|
||||||
|
archive: libnewlib.a
|
||||||
|
entries:
|
||||||
|
memcmp (noflash)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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, ¶ms);
|
||||||
|
ble_gap_security_initiate(event->connect.conn_handle);
|
||||||
|
} else {
|
||||||
|
start_advertising();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_DISCONNECT:
|
||||||
|
ESP_LOGI(TAG, "disconnect reason=%d", event->disconnect.reason);
|
||||||
|
s_conn_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||||
|
s_subscribed = false;
|
||||||
|
s_notify_count = 0;
|
||||||
|
start_advertising();
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_REPEAT_PAIRING:
|
||||||
|
// MicrOBU: logged, since a phone that pairs again while we hold its bond means one side
|
||||||
|
// lost the keys; the phone's side then usually needs "forget device" as well.
|
||||||
|
ESP_LOGW(TAG, "phone started pairing again although bonded: dropping the old bond");
|
||||||
|
if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) == 0) {
|
||||||
|
ble_store_util_delete_peer(&desc.peer_id_addr);
|
||||||
|
}
|
||||||
|
return BLE_GAP_REPEAT_PAIRING_RETRY;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_ENC_CHANGE:
|
||||||
|
// MicrOBU: the one place the board side says whether the link got secure. 0 = encrypted.
|
||||||
|
if (ble_gap_conn_find(event->enc_change.conn_handle, &desc) == 0) {
|
||||||
|
ESP_LOGI(TAG, "encryption change status=%d encrypted=%d authenticated=%d bonded=%d",
|
||||||
|
event->enc_change.status, desc.sec_state.encrypted, desc.sec_state.authenticated,
|
||||||
|
desc.sec_state.bonded);
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "encryption change status=%d", event->enc_change.status);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_MTU:
|
||||||
|
ESP_LOGI(TAG, "ATT MTU %u", unsigned(event->mtu.value));
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_PASSKEY_ACTION:
|
||||||
|
if (event->passkey.params.action == BLE_SM_IOACT_DISP) {
|
||||||
|
struct ble_sm_io pkey = {};
|
||||||
|
pkey.action = BLE_SM_IOACT_DISP;
|
||||||
|
pkey.passkey = FIXED_PASSKEY;
|
||||||
|
ESP_LOGW(TAG, "BLE PAIRING FIXED PASSKEY: %06lu", static_cast<unsigned long>(FIXED_PASSKEY));
|
||||||
|
int rc = ble_sm_inject_io(event->passkey.conn_handle, &pkey);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGW(TAG, "ble_sm_inject_io rc=%d", rc);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "unhandled passkey action %d", event->passkey.params.action);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_SUBSCRIBE:
|
||||||
|
if (event->subscribe.attr_handle == s_btp_ind_val_handle ||
|
||||||
|
event->subscribe.attr_handle == s_status_val_handle ||
|
||||||
|
event->subscribe.attr_handle == s_id_val_handle ||
|
||||||
|
event->subscribe.attr_handle == s_result_val_handle) {
|
||||||
|
if (event->subscribe.cur_notify && !event->subscribe.prev_notify)
|
||||||
|
++s_notify_count;
|
||||||
|
else if (!event->subscribe.cur_notify && event->subscribe.prev_notify)
|
||||||
|
--s_notify_count;
|
||||||
|
if (s_notify_count < 0) s_notify_count = 0;
|
||||||
|
s_subscribed = (s_notify_count > 0);
|
||||||
|
ESP_LOGI(TAG, "subscription handle %u changed: notify=%d count=%d",
|
||||||
|
event->subscribe.attr_handle, (int)event->subscribe.cur_notify, s_notify_count);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case BLE_GAP_EVENT_ADV_COMPLETE:
|
||||||
|
start_advertising();
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Re)starts undirected advertising with the device name and service UUID, unless paused.
|
||||||
|
int start_advertising() {
|
||||||
|
if (!s_adv_allowed || !s_synced || s_conn_handle != BLE_HS_CONN_HANDLE_NONE || ble_gap_adv_active()) return 0;
|
||||||
|
const char* name = s_dev_name;
|
||||||
|
size_t name_len = strlen(name);
|
||||||
|
|
||||||
|
struct ble_hs_adv_fields adv = {};
|
||||||
|
adv.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
|
||||||
|
adv.tx_pwr_lvl_is_present = 1;
|
||||||
|
adv.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
|
||||||
|
adv.name = reinterpret_cast<const uint8_t*>(name);
|
||||||
|
adv.name_len = static_cast<uint8_t>(name_len);
|
||||||
|
adv.name_is_complete = 1;
|
||||||
|
|
||||||
|
int rc = ble_gap_adv_set_fields(&adv);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "adv_set_fields rc=%d", rc);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ble_hs_adv_fields rsp = {};
|
||||||
|
rsp.uuids128 = &s_svc_uuid;
|
||||||
|
rsp.num_uuids128 = 1;
|
||||||
|
rsp.uuids128_is_complete = 1;
|
||||||
|
|
||||||
|
rc = ble_gap_adv_rsp_set_fields(&rsp);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "adv_rsp_set_fields rc=%d", rc);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ble_gap_adv_params params = {};
|
||||||
|
params.conn_mode = BLE_GAP_CONN_MODE_UND;
|
||||||
|
params.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
||||||
|
|
||||||
|
rc = ble_gap_adv_start(s_own_addr_type, nullptr, BLE_HS_FOREVER,
|
||||||
|
¶ms, gap_event, nullptr);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "adv_start rc=%d", rc);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "BLE advertising started as '%s'", name);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NimBLE host reset callback.
|
||||||
|
void on_reset(int reason) {
|
||||||
|
ESP_LOGE(TAG, "NimBLE reset, reason=%d", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NimBLE host sync callback: resolves our address and starts advertising.
|
||||||
|
void on_sync() {
|
||||||
|
int rc = ble_hs_util_ensure_addr(0);
|
||||||
|
assert(rc == 0);
|
||||||
|
rc = ble_hs_id_infer_auto(0, &s_own_addr_type);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "ble_hs_id_infer_auto rc=%d", rc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s_synced = true;
|
||||||
|
// MicrOBU: a bond that did not persist (full NVS) looks like "the phone has to pair every time".
|
||||||
|
int bonds = 0;
|
||||||
|
if (ble_store_util_count(BLE_STORE_OBJ_TYPE_OUR_SEC, &bonds) == 0)
|
||||||
|
ESP_LOGI(TAG, "%d bonded phone(s) in NVS", bonds);
|
||||||
|
start_advertising();
|
||||||
|
}
|
||||||
|
|
||||||
|
void nimble_host_task(void* param) {
|
||||||
|
ESP_LOGI(TAG, "NimBLE host task running");
|
||||||
|
nimble_port_run();
|
||||||
|
nimble_port_freertos_deinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool start(Receiver receiver) {
|
||||||
|
s_receiver = std::move(receiver);
|
||||||
|
|
||||||
|
uint8_t mac[6] = {};
|
||||||
|
if (esp_read_mac(mac, ESP_MAC_BT) == ESP_OK) {
|
||||||
|
std::snprintf(s_dev_name, sizeof(s_dev_name), "micrOBU-%02X%02X", mac[4], mac[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t err = nimble_port_init();
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "nimble_port_init failed: %d", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ble_hs_cfg.reset_cb = on_reset;
|
||||||
|
ble_hs_cfg.sync_cb = on_sync;
|
||||||
|
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
|
||||||
|
|
||||||
|
// Fixed passkey LE Secure Connections + Bonding
|
||||||
|
ble_hs_cfg.sm_io_cap = BLE_HS_IO_DISPLAY_ONLY;
|
||||||
|
ble_hs_cfg.sm_sc = 1;
|
||||||
|
ble_hs_cfg.sm_bonding = 1;
|
||||||
|
ble_hs_cfg.sm_mitm = 1;
|
||||||
|
ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
|
||||||
|
ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
|
||||||
|
|
||||||
|
ble_svc_gap_init();
|
||||||
|
ble_svc_gatt_init();
|
||||||
|
ble_svc_gap_device_name_set(s_dev_name);
|
||||||
|
|
||||||
|
int rc = ble_gatts_count_cfg(s_svc_defs);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "ble_gatts_count_cfg failed: %d", rc);
|
||||||
|
nimble_port_deinit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rc = ble_gatts_add_svcs(s_svc_defs);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "ble_gatts_add_svcs failed: %d", rc);
|
||||||
|
nimble_port_deinit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ble_store_config_init();
|
||||||
|
nimble_port_freertos_init(nimble_host_task);
|
||||||
|
ESP_LOGI(TAG, "simple_ble initialized (fixed passkey %06lu)", static_cast<unsigned long>(FIXED_PASSKEY));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool write(const link::Bytes& message) {
|
||||||
|
uint16_t conn_handle = s_conn_handle;
|
||||||
|
if (conn_handle == BLE_HS_CONN_HANDLE_NONE) {
|
||||||
|
++statistics.tx_failed;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message.empty() || message.size() > link::maximum_message) {
|
||||||
|
++statistics.tx_failed;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint16_t val_handle = s_result_val_handle;
|
||||||
|
if (message.size() >= link::header_size) {
|
||||||
|
link::Opcode op = static_cast<link::Opcode>(message[0]);
|
||||||
|
switch (op) {
|
||||||
|
case link::Opcode::V2X_RX:
|
||||||
|
case link::Opcode::BTP_DATA_INDICATION:
|
||||||
|
val_handle = s_btp_ind_val_handle;
|
||||||
|
break;
|
||||||
|
case link::Opcode::SF_IDCHANGE_EVENT:
|
||||||
|
val_handle = s_id_val_handle;
|
||||||
|
break;
|
||||||
|
case link::Opcode::STATUS:
|
||||||
|
// Route unsolicited or requested STATUS over the subscribed result/reply characteristic
|
||||||
|
val_handle = s_result_val_handle;
|
||||||
|
break;
|
||||||
|
case link::Opcode::RESULT:
|
||||||
|
default:
|
||||||
|
val_handle = s_result_val_handle;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MicrOBU: one notification is one whole message (no fragmentation on this GATT layout), and
|
||||||
|
// NimBLE cuts a value longer than ATT_MTU - 3 short without an error. The phone asks for an
|
||||||
|
// MTU of 517; until it has, drop rather than deliver a truncated message.
|
||||||
|
if (message.size() + 3 > ble_att_mtu(conn_handle)) {
|
||||||
|
++statistics.tx_failed;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
struct os_mbuf *om = ble_hs_mbuf_from_flat(message.data(), message.size());
|
||||||
|
if (!om) {
|
||||||
|
++statistics.tx_failed;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int rc = ble_gatts_notify_custom(conn_handle, val_handle, om);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGW(TAG, "notify failed handle=%u op=0x%02x rc=%d", (unsigned)val_handle, (unsigned)(message.empty() ? 0 : message[0]), rc);
|
||||||
|
if (rc == BLE_HS_ENOTSUP) os_mbuf_free_chain(om);
|
||||||
|
++statistics.tx_failed;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
++statistics.tx_messages;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool connected() {
|
||||||
|
return s_conn_handle != BLE_HS_CONN_HANDLE_NONE && s_subscribed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_advertising_allowed(bool allowed) {
|
||||||
|
if (s_adv_allowed == allowed) return;
|
||||||
|
s_adv_allowed = allowed;
|
||||||
|
if (!s_synced) return;
|
||||||
|
if (allowed) {
|
||||||
|
start_advertising();
|
||||||
|
ESP_LOGI(TAG, "USB link idle: BLE advertising resumed");
|
||||||
|
} else if (ble_gap_adv_active()) {
|
||||||
|
ble_gap_adv_stop();
|
||||||
|
ESP_LOGI(TAG, "USB link in use: BLE advertising paused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool advertising_allowed() {
|
||||||
|
return s_adv_allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_subscribed() {
|
||||||
|
return s_subscribed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Counters counters() {
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace microbu::ble
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
+817
-986
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user