Drive the ESP32-C5 station over USB or BLE, send CAM or VAM, signed or not
The app now speaks the station-link protocol of the new obu-firmware. Esp32Link picks the transport from Settings (UsbSerialTransport or the new BleLinkTransport), tells the previous firmware from the new one by its heartbeat, and runs the session: STATION_CONFIGURE with the current pseudonym MAC (which also starts the board's radio), CREDENTIALS_PROVISION of the bundled demo chain when the board has no ticket, then per message a POTI_UPDATE and a BTP_DATA_REQUEST. Received messages still arrive as V2X_RX frames, so the receive side is unchanged. A board on the previous firmware keeps working for CAM over USB. Settings > Connection > ESP32-C5: link USB-C or Bluetooth, transmit CAM or VAM, "Sign outgoing messages" (on by default). The connection card, top bar and dashboard show the link in use, the pairing passkey and signing counters. - VAM: VamUperCodec (TS 103 300-3 V2.3.1, bytes checked against asn1tools) and VamGenerationRules (clause 6.4, Tables 16/17). - BLE: the firmware's GATT layout (service 0000C175-...), MTU 517, pairing and encryption settled before any other operation (short timeouts during pairing made it loop), backoff between attempts, reasons on the card. - Clock: a PoTi goes to the board once per new fix and never moves the board's clock backwards except for a real correction (>= 60 s); stale and wobbling fix times made the board answer time_regression and restart its stack every few seconds. GnssTimeSource keeps the last measured phone-clock error while GNSS time drops out indoors: the bench phone is 14 minutes fast, and falling back to it made every transmitted timestamp jump by that much. - assets/demo-chain.vcr: throwaway, not EU-registered demo chain generated 2026-09-23 (AT B80B49387A4C12EB, psid 36 and 638). Its private key ships with the app on purpose; receivers verifying against the EU trust list drop what it signs. - Bluetooth permissions requested at start-up on Android 12+. StationLinkTest pins the codec to bytes from the colleague's Python implementation (microbu_link/messages.py). 103 unit tests pass.
This commit is contained in:
@@ -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,10 +119,27 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
private suspend fun runTransmitLoop() = coroutineScope {
|
private suspend fun runTransmitLoop() = coroutineScope {
|
||||||
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
|
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
|
||||||
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
|
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
|
||||||
|
launch { obuHardwarePrefs.signOutgoingFlow.collect { signOutgoing = it } }
|
||||||
|
launch {
|
||||||
|
obuHardwarePrefs.outgoingMessageFlow.collect {
|
||||||
|
if (it != outgoing) vamRules.reset()
|
||||||
|
outgoing = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val gnss = latestGnss
|
val gnss = latestGnss
|
||||||
if (gnss != null) {
|
if (gnss != null) {
|
||||||
|
when (outgoing) {
|
||||||
|
OutgoingMessage.CAM -> sendCam(gnss)
|
||||||
|
OutgoingMessage.VAM -> sendVamIfDue(gnss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(if (outgoing == OutgoingMessage.VAM) VAM_TICK_MS else (1000.0 / currentRateHz(gnss)).toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendCam(gnss: GnssReading) {
|
||||||
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
|
||||||
// rotation fall cleanly between two frames instead of inside one.
|
// rotation fall cleanly between two frames instead of inside one.
|
||||||
val pseudonym = pseudonymManager.current()
|
val pseudonym = pseudonymManager.current()
|
||||||
@@ -114,10 +148,35 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||||
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||||
val bytes = codec.encodeCam(cam)
|
val bytes = codec.encodeCam(cam)
|
||||||
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
|
esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes,
|
||||||
}
|
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||||
delay((1000.0 / currentRateHz(gnss)).toLong())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun sendVamIfDue(gnss: GnssReading) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val kinematics = VamGenerationRules.Kinematics(gnss.latitude, gnss.longitude,
|
||||||
|
gnss.speedMs.toDouble(), gnss.bearingDeg.toDouble())
|
||||||
|
if (!vamRules.due(now, kinematics)) return
|
||||||
|
val pseudonym = pseudonymManager.current()
|
||||||
|
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
|
||||||
|
// The CAM view of this fix is built only for its position vector, so the GN header of a VAM
|
||||||
|
// follows exactly the rules a CAM's does. It is not transmitted.
|
||||||
|
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
|
||||||
|
val withLowFrequency = vamRules.includeLowFrequency(now)
|
||||||
|
val bytes = VamUperCodec.encode(VamContent(
|
||||||
|
stationId = pseudonym.stationId,
|
||||||
|
timestamp = cam.timestamp,
|
||||||
|
latitude = cam.latitude,
|
||||||
|
longitude = cam.longitude,
|
||||||
|
accuracyM = gnss.accuracyM,
|
||||||
|
speedMps = cam.speedMps,
|
||||||
|
headingDeg = cam.headingDeg,
|
||||||
|
accelerationMps2 = cam.accelerationMps2,
|
||||||
|
includeLowFrequency = withLowFrequency,
|
||||||
|
))
|
||||||
|
val handedOver = esp32Link.send(OutgoingIts(OutgoingMessage.VAM, bytes,
|
||||||
|
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
|
||||||
|
if (handedOver) vamRules.onSent(now, kinematics, withLowFrequency)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,6 +217,9 @@ class CamTransmitLoop @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val ELEVATED_HOLD_MS = 5_000L
|
private const val ELEVATED_HOLD_MS = 5_000L
|
||||||
|
|
||||||
|
/** How often VAM generation rules are checked: T_GenVamMin, TS 103 300-3 Table 16. */
|
||||||
|
private const val VAM_TICK_MS = VamGenerationRules.T_GEN_VAM_MIN_MS
|
||||||
|
|
||||||
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
|
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
|
||||||
private const val MIN_ACCEL_DT_SEC = 0.2
|
private const val MIN_ACCEL_DT_SEC = 0.2
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import androidx.compose.material.icons.filled.GpsOff
|
|||||||
import androidx.compose.material.icons.filled.Sensors
|
import androidx.compose.material.icons.filled.Sensors
|
||||||
import androidx.compose.material.icons.filled.SensorsOff
|
import androidx.compose.material.icons.filled.SensorsOff
|
||||||
import androidx.compose.material.icons.filled.Usb
|
import androidx.compose.material.icons.filled.Usb
|
||||||
|
import androidx.compose.material.icons.filled.BluetoothDisabled
|
||||||
|
import androidx.compose.material.icons.filled.Bluetooth
|
||||||
import androidx.compose.material.icons.filled.UsbOff
|
import androidx.compose.material.icons.filled.UsbOff
|
||||||
import androidx.compose.material.icons.filled.Wifi
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
import androidx.compose.material.icons.filled.WifiOff
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
@@ -39,7 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
|
||||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||||
|
|
||||||
private val GreenActive = Color(0xFF4CAF50)
|
private val GreenActive = Color(0xFF4CAF50)
|
||||||
@@ -53,7 +55,9 @@ fun StatusTopBar(
|
|||||||
state: SensorUiState,
|
state: SensorUiState,
|
||||||
mqttConnectionState: MqttConnectionState,
|
mqttConnectionState: MqttConnectionState,
|
||||||
isEsp32: Boolean = false,
|
isEsp32: Boolean = false,
|
||||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
|
||||||
|
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
|
||||||
|
esp32Bluetooth: Boolean = false,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -93,7 +97,7 @@ fun StatusTopBar(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState)
|
ObuStatusIcon(mqttConnectionState, isEsp32, esp32LinkState, esp32Bluetooth = esp32Bluetooth)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -106,20 +110,21 @@ fun StatusTopBar(
|
|||||||
* OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One
|
* OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One
|
||||||
* reaches the phone over MQTT (Wi-Fi / USB-C tethering), the ESP32-C5 over a USB-serial link with
|
* reaches the phone over MQTT (Wi-Fi / USB-C tethering), the ESP32-C5 over a USB-serial link with
|
||||||
* no broker at all - so on that path [mqttConnectionState] is permanently DISCONNECTED and would
|
* no broker at all - so on that path [mqttConnectionState] is permanently DISCONNECTED and would
|
||||||
* report the OBU as offline while CAMs were streaming in. Uses a USB glyph there rather than the
|
* report the OBU as offline while CAMs were streaming in. Uses a USB or Bluetooth glyph there
|
||||||
* Wi-Fi one, since that is literally what the connection is.
|
* rather than the Wi-Fi one, since that is literally what the connection is.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ObuStatusIcon(
|
private fun ObuStatusIcon(
|
||||||
mqttState: MqttConnectionState,
|
mqttState: MqttConnectionState,
|
||||||
isEsp32: Boolean,
|
isEsp32: Boolean,
|
||||||
usbSerialState: UsbSerialState,
|
esp32LinkState: Esp32LinkState,
|
||||||
|
esp32Bluetooth: Boolean,
|
||||||
) {
|
) {
|
||||||
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState
|
val state = if (isEsp32) esp32LinkState.asConnectionState() else mqttState
|
||||||
|
val linkUp = state == MqttConnectionState.CONNECTED || state == MqttConnectionState.CONNECTING
|
||||||
val icon = when {
|
val icon = when {
|
||||||
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb
|
isEsp32 && esp32Bluetooth -> if (linkUp) Icons.Default.Bluetooth else Icons.Default.BluetoothDisabled
|
||||||
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb
|
isEsp32 -> if (linkUp) Icons.Default.Usb else Icons.Default.UsbOff
|
||||||
isEsp32 -> Icons.Default.UsbOff
|
|
||||||
state == MqttConnectionState.CONNECTED ||
|
state == MqttConnectionState.CONNECTED ||
|
||||||
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
|
||||||
else -> Icons.Default.WifiOff
|
else -> Icons.Default.WifiOff
|
||||||
@@ -194,10 +199,10 @@ private fun RecordingPulse() {
|
|||||||
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour
|
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour
|
||||||
* and pulse logic already speaks, so one indicator serves both transports.
|
* and pulse logic already speaks, so one indicator serves both transports.
|
||||||
*/
|
*/
|
||||||
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
|
private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
|
||||||
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
|
Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
|
||||||
UsbSerialState.DEVICE_ATTACHED,
|
Esp32LinkState.DEVICE_ATTACHED,
|
||||||
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
|
||||||
UsbSerialState.ERROR -> MqttConnectionState.ERROR
|
Esp32LinkState.ERROR -> MqttConnectionState.ERROR
|
||||||
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user