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:
Ashin Walpola
2026-09-23 17:28:05 +02:00
parent d2fd222a62
commit a08494b56a
27 changed files with 2092 additions and 134 deletions
@@ -30,7 +30,8 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.ui.components.StatusTopBar
import com.hawhamburg.micr0bu.ui.navigation.BottomNavBar
@@ -88,7 +89,10 @@ class MainActivity : AppCompatActivity() {
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
val obuHardware by mqttViewModel.obuHardware.collectAsState()
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
val esp32LinkState by mqttViewModel.esp32LinkState.collectAsState()
val esp32Transport by mqttViewModel.esp32Transport.collectAsState()
val outgoingMessage by mqttViewModel.outgoingMessage.collectAsState()
val signOutgoing by mqttViewModel.signOutgoing.collectAsState()
// Received hazards and live signal state, for the Dashboard's V2X summary cards.
// Both flows already expire their own entries on a clock, so nothing here has to
// decide when a hazard or a traffic light has gone stale.
@@ -128,11 +132,17 @@ class MainActivity : AppCompatActivity() {
LaunchedEffect(Unit) {
viewModel.startImuStreams()
// Bluetooth scan/connect ride along with location (Android 12+): the ESP32-C5
// can be reached over BLE, and a denial only matters if that is selected, where
// BleLinkTransport then says so instead of silently finding nothing.
val bluetooth = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else emptyArray()
locationLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
) + bluetooth
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
@@ -146,7 +156,8 @@ class MainActivity : AppCompatActivity() {
state = state,
mqttConnectionState = mqttConnectionState,
isEsp32 = obuHardware == ObuHardware.ESP32_C5,
usbSerialState = usbSerialState,
esp32LinkState = esp32LinkState,
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
)
}
},
@@ -163,7 +174,8 @@ class MainActivity : AppCompatActivity() {
mqttConnectionState = mqttConnectionState,
activeTransport = activeTransport,
obuHardware = obuHardware,
usbSerialState = usbSerialState,
esp32LinkState = esp32LinkState,
esp32Bluetooth = esp32Transport == Esp32Transport.BLE,
usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType,
@@ -202,7 +214,7 @@ class MainActivity : AppCompatActivity() {
state = state,
mqttConnectionState = mqttConnectionState,
obuConnected = if (obuHardware == ObuHardware.ESP32_C5)
usbSerialState == UsbSerialState.CONNECTED
esp32LinkState == Esp32LinkState.CONNECTED
else
mqttConnectionState == MqttConnectionState.CONNECTED,
tripServiceState = tripServiceState,
@@ -304,6 +316,12 @@ class MainActivity : AppCompatActivity() {
onMqttPrefsChange = mqttViewModel::updatePrefs,
obuHardware = obuHardware,
onObuHardwareChange = mqttViewModel::setObuHardware,
esp32Transport = esp32Transport,
onEsp32TransportChange = mqttViewModel::setEsp32Transport,
outgoingMessage = outgoingMessage,
onOutgoingMessageChange = mqttViewModel::setOutgoingMessage,
signOutgoing = signOutgoing,
onSignOutgoingChange = mqttViewModel::setSignOutgoing,
onBack = { navController.popBackStack() },
)
}
@@ -21,7 +21,13 @@ import java.time.DateTimeException
* [SystemClock.currentGnssTimeClock] (API 29, this app's minSdk) is a UTC clock the platform keeps
* synchronised from GNSS fixes. One reading of it taken alongside the wall clock gives the wall
* clock's error, which is then applied to the fix's own timestamp. When GNSS time is unavailable,
* 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
* 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. */
@Volatile private var lastUsedGnss: Boolean? = null
/** GNSS time minus wall clock at the last reading of both; null until GNSS time was first seen. */
@Volatile private var lastErrorMs: Long? = null
/** [systemMs], a wall-clock reading, moved onto GNSS time where GNSS time is available. */
fun correct(systemMs: Long): Long {
val systemNow = System.currentTimeMillis()
@@ -44,8 +53,9 @@ object GnssTimeSource {
} catch (e: DateTimeException) {
null
}
if (gnssNow != null) lastErrorMs = 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) {
@@ -55,6 +65,9 @@ object GnssTimeSource {
if (gnssNow != null) {
Log.i(TAG, "transmit timestamps now on GNSS time; phone clock is " +
"${"%+.1f".format((systemNow - gnssNow) / 1000.0)} s off")
} else if (lastErrorMs != null) {
Log.i(TAG, "GNSS time unavailable, keeping the last measured phone clock error of " +
"${"%+.1f".format(-lastErrorMs!! / 1000.0)} s")
} else {
Log.w(TAG, "GNSS time unavailable, transmit timestamps fall back to the phone clock, " +
"which has no automatic time source without a SIM or internet")
@@ -16,8 +16,8 @@ import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.BtpPort
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
import com.hawhamburg.micr0bu.data.transport.V2xRxFrame
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.domain.asn1.DenmUperCodec
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.asn1.SpatemUperCodec
@@ -102,7 +102,7 @@ private const val RAW_PREFERRED_WINDOW_MS = 5_000L
class CamUseCaseRepository @Inject constructor(
private val mqttRepository: MqttRepository,
private val prefs: UseCaseAlertPreferences,
private val usbSerialTransport: UsbSerialTransport,
private val esp32Link: Esp32Link,
private val camCodec: RealAsn1UperCodec,
private val obuHardwarePrefs: ObuHardwarePreferences,
private val pseudonymManager: PseudonymManager,
@@ -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
// just never emits CAM_RX frames if nothing's plugged in over serial).
scope.launch {
usbSerialTransport.incomingFrames.collect { frame ->
esp32Link.incomingFrames.collect { frame ->
if (frame.type != SerialFrameType.V2X_RX) return@collect
if (usbSerialTransport.state.value != UsbSerialState.CONNECTED) return@collect
if (esp32Link.state.value != Esp32LinkState.CONNECTED) return@collect
val v2x = V2xRxFrame.parse(frame.payload) ?: return@collect
when (v2x.btpPort) {
BtpPort.CAM -> handleCamFromSerial(v2x)
@@ -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
// an unplug, which reads as live traffic - the worst kind of stale on a safety display.
scope.launch {
usbSerialTransport.state.collect { state ->
esp32Link.state.collect { state ->
// ESP32-only: on the CiT One path this transport is permanently DISCONNECTED and
// resetting here would wipe perfectly good MQTT-derived state.
if (currentHardware == ObuHardware.ESP32_C5 && state != UsbSerialState.CONNECTED) {
if (currentHardware == ObuHardware.ESP32_C5 && state != Esp32LinkState.CONNECTED) {
engine.reset()
_rsuStations.value = emptyMap()
}
@@ -1,11 +1,14 @@
package com.hawhamburg.micr0bu.data.mqtt
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
@@ -32,6 +35,10 @@ class ObuHardwarePreferences @Inject constructor(
val OWN_STATION_ID = longPreferencesKey("own_station_id")
val OWN_MAC = stringPreferencesKey("own_mac")
val OWN_PSEUDONYM_CREATED_MS = longPreferencesKey("own_pseudonym_created_ms")
// ESP32-C5 only.
val ESP32_TRANSPORT = stringPreferencesKey("esp32_transport")
val OUTGOING_MESSAGE = stringPreferencesKey("outgoing_message")
val SIGN_OUTGOING = booleanPreferencesKey("sign_outgoing")
}
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
@@ -42,6 +49,37 @@ class ObuHardwarePreferences @Inject constructor(
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.
*
@@ -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.Singleton
/** Connection lifecycle for the ESP32-C5 USB-serial link. */
enum class UsbSerialState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
private const val ACTION_USB_PERMISSION = "com.hawhamburg.micr0bu.USB_SERIAL_PERMISSION"
private const val TAG = "UsbSerialTransport"
@@ -63,6 +60,11 @@ private const val TAG = "UsbSerialTransport"
* Baud rate is not applicable here — USB Serial/JTAG has no baud concept; `setParameters` below
* is a no-op the library requires anyway for API-shape reasons but the value is otherwise unused.
*
* Since 2026-09-23 the rest of the app does not use this class directly but [Esp32Link], which
* picks this or [BleLinkTransport] and speaks either the previous firmware's frames (0x01-0x05,
* [sendCamTx]) or the current firmware's station-link messages (frame type [SERIAL_FRAME_LINK],
* [sendFrame]) over it.
*
* ## Ownership
* This is a `@Singleton` shared by the UI ([com.hawhamburg.micr0bu.viewmodel.MqttViewModel]), the
* foreground [com.hawhamburg.micr0bu.service.TripRecordingService]'s
@@ -111,8 +113,8 @@ class UsbSerialTransport @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED)
val state: StateFlow<UsbSerialState> = _state.asStateFlow()
private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED)
val state: StateFlow<Esp32LinkState> = _state.asStateFlow()
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
@@ -150,7 +152,7 @@ class UsbSerialTransport @Inject constructor(
} else {
Log.w(TAG, "usbReceiver: permission denied or device null " +
"(granted=$granted, device=$device)")
_state.value = UsbSerialState.ERROR
_state.value = Esp32LinkState.ERROR
}
}
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
@@ -174,7 +176,7 @@ class UsbSerialTransport @Inject constructor(
* repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
*/
fun connect() {
if (_state.value == UsbSerialState.CONNECTED) {
if (_state.value == Esp32LinkState.CONNECTED) {
Log.i(TAG, "connect(): already connected, no-op")
return
}
@@ -197,7 +199,7 @@ class UsbSerialTransport @Inject constructor(
"(see device list logged above) - either nothing is attached at the Android " +
"USB level, or it's attached but its VID/PID doesn't match any entry in " +
"customProber's table")
_state.value = UsbSerialState.DISCONNECTED
_state.value = Esp32LinkState.DISCONNECTED
return
}
if (espDriver == null) {
@@ -211,14 +213,14 @@ class UsbSerialTransport @Inject constructor(
Log.i(TAG, "connect(): matched device vid=0x${device.vendorId.toString(16)} " +
"pid=0x${device.productId.toString(16)} name=${device.deviceName} " +
"ports=${driver.ports.size}")
_state.value = UsbSerialState.DEVICE_ATTACHED
_state.value = Esp32LinkState.DEVICE_ATTACHED
if (usbManager.hasPermission(device)) {
Log.i(TAG, "connect(): permission already granted, opening directly")
openDevice(device)
} else {
Log.i(TAG, "connect(): requesting USB permission from user")
_state.value = UsbSerialState.PERMISSION_REQUESTED
_state.value = Esp32LinkState.PERMISSION_REQUESTED
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
val permissionIntent = PendingIntent.getBroadcast(
context, 0, Intent(ACTION_USB_PERMISSION).setPackage(context.packageName), flags,
@@ -269,14 +271,14 @@ class UsbSerialTransport @Inject constructor(
if (driver == null || driver.ports.isEmpty()) {
Log.w(TAG, "openDevice(): probeDevice returned null or no ports for " +
"vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
_state.value = UsbSerialState.ERROR
_state.value = Esp32LinkState.ERROR
return
}
val connection = usbManager.openDevice(device)
if (connection == null) {
Log.w(TAG, "openDevice(): usbManager.openDevice() returned null - permission not " +
"actually granted, or Android couldn't claim the device")
_state.value = UsbSerialState.ERROR
_state.value = Esp32LinkState.ERROR
return
}
@@ -287,7 +289,7 @@ class UsbSerialTransport @Inject constructor(
} catch (e: Exception) {
Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e)
runCatching { newPort.close() }
_state.value = UsbSerialState.ERROR
_state.value = Esp32LinkState.ERROR
return
}
@@ -349,7 +351,7 @@ class UsbSerialTransport @Inject constructor(
override fun onRunError(e: Exception) {
Log.e(TAG, "SerialInputOutputManager.onRunError()", e)
_state.value = UsbSerialState.ERROR
_state.value = Esp32LinkState.ERROR
}
})
ioManager = manager
@@ -362,13 +364,13 @@ class UsbSerialTransport @Inject constructor(
_consecutiveWriteFailures.value = 0
_linkStatus.value = null
lastFrameAtMs = SystemClock.elapsedRealtime()
_state.value = UsbSerialState.CONNECTED
_state.value = Esp32LinkState.CONNECTED
startWatchdog()
}
}
/**
* Flips the link to [UsbSerialState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
* Flips the link to [Esp32LinkState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
* missing for [LINK_TIMEOUT_MS]. Without this, "connected" only ever means "the port opened
* at some point in the past" — which on a bench is exactly the wrong thing to believe.
*/
@@ -377,14 +379,14 @@ class UsbSerialTransport @Inject constructor(
watchdogJob = scope.launch {
while (isActive) {
delay(WATCHDOG_POLL_MS)
if (_state.value != UsbSerialState.CONNECTED) continue
if (_state.value != Esp32LinkState.CONNECTED) continue
val silentFor = SystemClock.elapsedRealtime() - lastFrameAtMs
if (silentFor > LINK_TIMEOUT_MS) {
Log.w(TAG, "watchdog: no frame from ESP32 for ${silentFor}ms (heartbeat " +
"expected at 1 Hz) - marking link ERROR. Either the firmware is wedged/" +
"not running, or the host->device direction opened but device->host " +
"never did (see the DTR note in openDevice()).")
_state.value = UsbSerialState.ERROR
_state.value = Esp32LinkState.ERROR
}
}
}
@@ -445,7 +447,29 @@ class UsbSerialTransport @Inject constructor(
} catch (e: Exception) {
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e)
_state.value = UsbSerialState.ERROR
_state.value = Esp32LinkState.ERROR
false
}
}
/**
* Writes one frame of any [type] (the station-link messages go as [SERIAL_FRAME_LINK]). Same
* failure accounting as [sendCamTx]. Blocking, 200 ms timeout: call from a background dispatcher.
*/
fun sendFrame(type: Int, payload: ByteArray): Boolean {
val p = port
if (p == null) {
_consecutiveWriteFailures.update { it + 1 }
return false
}
return try {
p.write(SerialFrameEncoder.encode(type, payload), /* timeout ms */ 200)
_consecutiveWriteFailures.value = 0
true
} catch (e: Exception) {
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
Log.w(TAG, "sendFrame(0x${type.toString(16)}): write failed (consecutive failures: $failures)", e)
_state.value = Esp32LinkState.ERROR
false
}
}
@@ -461,7 +485,7 @@ class UsbSerialTransport @Inject constructor(
openDeviceName = null
_linkStatus.value = null
_consecutiveWriteFailures.value = 0
_state.value = UsbSerialState.DISCONNECTED
_state.value = Esp32LinkState.DISCONNECTED
}
}
@@ -8,7 +8,7 @@ import javax.inject.Singleton
* UPER (Unaligned Packed Encoding Rules) codec for CAM, used on the ESP32-C5 hardware path
* (Phase 03, Section 13): the phone builds outgoing CAM itself
* ([com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]) and UPER-encodes it before handing bytes
* to [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport], and UPER-decodes whatever the
* to [com.hawhamburg.micr0bu.data.transport.Esp32Link], and UPER-decodes whatever the
* ESP32-C5 forwards back on receive (already stripped of 802.11/GeoNetworking/BTP framing by
* the firmware's `gn_unwrap.c` — this only ever sees CAM UPER bytes, never raw radio frames).
*
@@ -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
* to the ESP32-C5. Live flow, driven by [com.hawhamburg.micr0bu.service.CamTransmitLoop]:
*
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (write).
* `PhoneCamBuilder.build(...)` → `RealAsn1UperCodec.encodeCam(...)` → `Esp32Link` (write).
*
* Position/speed/heading come straight from GNSS. Yaw rate is derived from the gyroscope's
* z-axis reading (rotation about the vertical axis while the phone is roughly flat/mounted
@@ -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.SensorRepository
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.cam.OwnStationIds
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.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@@ -49,7 +53,8 @@ import javax.inject.Singleton
@Singleton
class CamPinger @Inject constructor(
@ApplicationContext private val context: Context,
private val usbSerialTransport: UsbSerialTransport,
private val esp32Link: Esp32Link,
private val prefs: ObuHardwarePreferences,
private val codec: RealAsn1UperCodec,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -114,7 +119,8 @@ class CamPinger @Inject constructor(
// Fixed bench identity on every layer, the link-layer address included, so a ping
// stays recognisable in a capture and never rotates.
val pv = GnPositionVector.fromCam(cam, gnss.accuracyM, OwnStationIds.BENCH_PING_MAC)
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 }
}
}
@@ -6,13 +6,18 @@ import com.hawhamburg.micr0bu.data.GnssTimeSource
import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.cam.PseudonymManager
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.data.transport.GnPositionVector
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.data.transport.OutgoingIts
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.asn1.VamContent
import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec
import com.hawhamburg.micr0bu.domain.cam.CamTransmitConfig
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -45,12 +50,19 @@ import javax.inject.Singleton
* [com.hawhamburg.micr0bu.domain.detection.EventDetector] stream that already drives trip event
* logging). Both rate figures are placeholders pending real-world tuning, per
* [CamTransmitConfig]'s own disclaimer.
*
* ## CAM or VAM
* Settings chooses what goes out ([OutgoingMessage]). CAM follows the rate policy above. VAM is
* checked every [VAM_TICK_MS] against the generation rules of TS 103 300-3 clause 6.4
* ([VamGenerationRules]) and sent when one fires, from the same GNSS fix, pseudonym and position
* vector a CAM would use. Whether either is signed is the "Sign outgoing messages" setting; the
* micrOBU does the signing ([Esp32Link]).
*/
@Singleton
class CamTransmitLoop @Inject constructor(
@ApplicationContext private val context: Context,
private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport,
private val esp32Link: Esp32Link,
private val codec: RealAsn1UperCodec,
private val pseudonymManager: PseudonymManager,
) {
@@ -67,6 +79,10 @@ class CamTransmitLoop @Inject constructor(
/** Previous GNSS fix, kept only to derive along-track acceleration — see [longitudinalAccel]. */
@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
* 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
elevatedUntilMs = 0L
previousGnss = null
vamRules.reset()
job = scope.launch {
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
@@ -102,24 +119,66 @@ class CamTransmitLoop @Inject constructor(
private suspend fun runTransmitLoop() = coroutineScope {
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
launch { obuHardwarePrefs.signOutgoingFlow.collect { signOutgoing = it } }
launch {
obuHardwarePrefs.outgoingMessageFlow.collect {
if (it != outgoing) vamRules.reset()
outgoing = it
}
}
while (true) {
val gnss = latestGnss
if (gnss != null) {
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
// rotation fall cleanly between two frames instead of inside one.
val pseudonym = pseudonymManager.current()
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
// outgoing CAM is: acceleration below still differences wall-clock samples.
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
val bytes = codec.encodeCam(cam)
usbSerialTransport.sendCamTx(bytes, GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac))
when (outgoing) {
OutgoingMessage.CAM -> sendCam(gnss)
OutgoingMessage.VAM -> sendVamIfDue(gnss)
}
}
delay((1000.0 / currentRateHz(gnss)).toLong())
delay(if (outgoing == OutgoingMessage.VAM) VAM_TICK_MS else (1000.0 / currentRateHz(gnss)).toLong())
}
}
private suspend fun sendCam(gnss: GnssReading) {
// Asked for per CAM rather than once per trip: that is what lets a pseudonym
// rotation fall cleanly between two frames instead of inside one.
val pseudonym = pseudonymManager.current()
// Stamped on GNSS time rather than the phone clock; see GnssTimeSource. Only the
// outgoing CAM is: acceleration below still differences wall-clock samples.
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
val bytes = codec.encodeCam(cam)
esp32Link.send(OutgoingIts(OutgoingMessage.CAM, bytes,
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
}
private suspend fun sendVamIfDue(gnss: GnssReading) {
val now = System.currentTimeMillis()
val kinematics = VamGenerationRules.Kinematics(gnss.latitude, gnss.longitude,
gnss.speedMs.toDouble(), gnss.bearingDeg.toDouble())
if (!vamRules.due(now, kinematics)) return
val pseudonym = pseudonymManager.current()
val fix = gnss.copy(timestamp = GnssTimeSource.correct(gnss.timestamp))
// The CAM view of this fix is built only for its position vector, so the GN header of a VAM
// follows exactly the rules a CAM's does. It is not transmitted.
val cam = PhoneCamBuilder.build(fix, latestGyroZ, pseudonym.stationId, longitudinalAccel(gnss))
val withLowFrequency = vamRules.includeLowFrequency(now)
val bytes = VamUperCodec.encode(VamContent(
stationId = pseudonym.stationId,
timestamp = cam.timestamp,
latitude = cam.latitude,
longitude = cam.longitude,
accuracyM = gnss.accuracyM,
speedMps = cam.speedMps,
headingDeg = cam.headingDeg,
accelerationMps2 = cam.accelerationMps2,
includeLowFrequency = withLowFrequency,
))
val handedOver = esp32Link.send(OutgoingIts(OutgoingMessage.VAM, bytes,
GnPositionVector.fromCam(cam, gnss.accuracyM, pseudonym.mac), gnss.accuracyM, signOutgoing))
if (handedOver) vamRules.onSent(now, kinematics, withLowFrequency)
}
/**
* Along-track acceleration in m/s², from the change in GNSS speed since the previous fix.
*
@@ -158,6 +217,9 @@ class CamTransmitLoop @Inject constructor(
companion object {
private const val ELEVATED_HOLD_MS = 5_000L
/** How often VAM generation rules are checked: T_GenVamMin, TS 103 300-3 Table 16. */
private const val VAM_TICK_MS = VamGenerationRules.T_GEN_VAM_MIN_MS
/** Below this gap, GNSS speed noise divided by a tiny dt produces absurd accelerations. */
private const val MIN_ACCEL_DT_SEC = 0.2
@@ -20,6 +20,8 @@ import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.SensorsOff
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.BluetoothDisabled
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.UsbOff
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.WifiOff
@@ -39,7 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
private val GreenActive = Color(0xFF4CAF50)
@@ -53,7 +55,9 @@ fun StatusTopBar(
state: SensorUiState,
mqttConnectionState: MqttConnectionState,
isEsp32: Boolean = false,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
esp32Bluetooth: Boolean = false,
) {
TopAppBar(
title = {
@@ -93,7 +97,7 @@ fun StatusTopBar(
)
Spacer(Modifier.width(8.dp))
ObuStatusIcon(mqttConnectionState, isEsp32, usbSerialState)
ObuStatusIcon(mqttConnectionState, isEsp32, esp32LinkState, esp32Bluetooth = esp32Bluetooth)
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -106,20 +110,21 @@ fun StatusTopBar(
* OBU link indicator. Which transport it reflects depends on the selected hardware: the CiT One
* reaches the phone over MQTT (Wi-Fi / USB-C tethering), the ESP32-C5 over a USB-serial link with
* no broker at all - so on that path [mqttConnectionState] is permanently DISCONNECTED and would
* report the OBU as offline while CAMs were streaming in. Uses a USB glyph there rather than the
* Wi-Fi one, since that is literally what the connection is.
* report the OBU as offline while CAMs were streaming in. Uses a USB or Bluetooth glyph there
* rather than the Wi-Fi one, since that is literally what the connection is.
*/
@Composable
private fun ObuStatusIcon(
mqttState: MqttConnectionState,
isEsp32: Boolean,
usbSerialState: UsbSerialState,
esp32LinkState: Esp32LinkState,
esp32Bluetooth: Boolean,
) {
val state = if (isEsp32) usbSerialState.asConnectionState() else mqttState
val state = if (isEsp32) esp32LinkState.asConnectionState() else mqttState
val linkUp = state == MqttConnectionState.CONNECTED || state == MqttConnectionState.CONNECTING
val icon = when {
isEsp32 && state == MqttConnectionState.CONNECTED -> Icons.Default.Usb
isEsp32 && state == MqttConnectionState.CONNECTING -> Icons.Default.Usb
isEsp32 -> Icons.Default.UsbOff
isEsp32 && esp32Bluetooth -> if (linkUp) Icons.Default.Bluetooth else Icons.Default.BluetoothDisabled
isEsp32 -> if (linkUp) Icons.Default.Usb else Icons.Default.UsbOff
state == MqttConnectionState.CONNECTED ||
state == MqttConnectionState.CONNECTING -> Icons.Default.Wifi
else -> Icons.Default.WifiOff
@@ -194,10 +199,10 @@ private fun RecordingPulse() {
* Maps the ESP32-C5 serial link's lifecycle onto the MQTT connection vocabulary this bar's colour
* and pulse logic already speaks, so one indicator serves both transports.
*/
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
UsbSerialState.DEVICE_ATTACHED,
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
UsbSerialState.ERROR -> MqttConnectionState.ERROR
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
Esp32LinkState.DEVICE_ATTACHED,
Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
Esp32LinkState.ERROR -> MqttConnectionState.ERROR
Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
}
@@ -44,7 +44,9 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import androidx.compose.material.icons.filled.Bluetooth
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
private val UsbGreen = Color(0xFF4CAF50)
@@ -65,7 +67,11 @@ fun ConnectionSetupScreen(
val activeTransport by viewModel.activeTransport.collectAsState()
val mqttPrefs by viewModel.mqttPrefs.collectAsState()
val obuHardware by viewModel.obuHardware.collectAsState()
val usbSerialState by viewModel.usbSerialState.collectAsState()
val esp32LinkState by viewModel.esp32LinkState.collectAsState()
val esp32Transport by viewModel.esp32Transport.collectAsState()
val esp32Detail by viewModel.esp32Detail.collectAsState()
val stationStatus by viewModel.stationStatus.collectAsState()
val signOutgoing by viewModel.signOutgoing.collectAsState()
val isConnected = connectionState == MqttConnectionState.CONNECTED
val isConnecting = connectionState == MqttConnectionState.CONNECTING
@@ -229,22 +235,22 @@ fun ConnectionSetupScreen(
}
} else {
// ── ESP32-C5 real connection card (Phase 03) ────────────────────────
// Backed by UsbSerialTransport (native USB Serial/JTAG CDC-ACM link) - see that
// class's KDoc for the VID/PID (0x303A/0x1001) and native-vs-UART-bridge port note.
val isEspConnected = usbSerialState == UsbSerialState.CONNECTED
val isEspBusy = usbSerialState == UsbSerialState.DEVICE_ATTACHED ||
usbSerialState == UsbSerialState.PERMISSION_REQUESTED
// Backed by Esp32Link: USB (UsbSerialTransport, native USB Serial/JTAG CDC-ACM - see
// its KDoc for the VID/PID and native-vs-UART-bridge port note) or BLE (BleLinkTransport).
val isEspConnected = esp32LinkState == Esp32LinkState.CONNECTED
val isEspBusy = esp32LinkState == Esp32LinkState.DEVICE_ATTACHED ||
esp32LinkState == Esp32LinkState.PERMISSION_REQUESTED
val espContainerColor = when {
isEspConnected -> UsbGreenBg
isEspBusy -> UsbAmberBg
else -> UsbGrayBg
}
val (espColor, espStateLabel) = when (usbSerialState) {
UsbSerialState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
UsbSerialState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
UsbSerialState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
UsbSerialState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
UsbSerialState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
val (espColor, espStateLabel) = when (esp32LinkState) {
Esp32LinkState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
Esp32LinkState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
Esp32LinkState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
Esp32LinkState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
Esp32LinkState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
}
Card(
@@ -257,7 +263,7 @@ fun ConnectionSetupScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Usb,
if (esp32Transport == Esp32Transport.BLE) Icons.Default.Bluetooth else Icons.Default.Usb,
contentDescription = null,
tint = espColor,
modifier = Modifier.size(20.dp),
@@ -285,11 +291,36 @@ fun ConnectionSetupScreen(
Text(espStateLabel, style = MaterialTheme.typography.bodySmall, color = espColor)
}
Text(
stringResource(
if (esp32Transport == Esp32Transport.BLE) R.string.conn_esp32_via_ble else R.string.conn_esp32_via_usb
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Pairing passkey, provisioning progress, or why the micrOBU refused something.
esp32Detail?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = UsbAmber)
}
if (isEspConnected) {
stationStatus?.let { s ->
Text(
stringResource(
R.string.conn_esp32_signing,
if (signOutgoing) stringResource(R.string.conn_esp32_signing_on) else stringResource(R.string.conn_esp32_signing_off),
s.tickets, s.signedMessages, s.signRefused, s.radioSubmitted,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(12.dp))
if (isEspConnected) {
if (isEspConnected || isEspBusy) {
OutlinedButton(
onClick = { viewModel.disconnectUsbSerial() },
onClick = { viewModel.disconnectEsp32() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFFFF5252),
@@ -297,13 +328,13 @@ fun ConnectionSetupScreen(
) {
Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.conn_disconnect))
// While connecting (BLE retries until it succeeds) this cancels the attempt.
Text(stringResource(if (isEspConnected) R.string.conn_disconnect else R.string.conn_esp32_cancel))
}
} else {
Button(
onClick = { viewModel.connectUsbSerial() },
onClick = { viewModel.connectEsp32() },
modifier = Modifier.fillMaxWidth(),
enabled = !isEspBusy,
) {
Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
@@ -60,7 +60,7 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.denm.DenmParser
@@ -78,7 +78,9 @@ fun DashboardScreen(
mqttConnectionState: MqttConnectionState,
activeTransport: TransportType = TransportType.USB_C,
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
/** The ESP32-C5 is reached over BLE rather than its USB port (Settings). */
esp32Bluetooth: Boolean = false,
usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null,
@@ -281,14 +283,14 @@ fun DashboardScreen(
// even once the serial link is actually up.
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
val obuConnected = if (isEsp32) usbSerialState == UsbSerialState.CONNECTED else mqttConnected
val transportIcon = when (activeTransport) {
val obuConnected = if (isEsp32) esp32LinkState == Esp32LinkState.CONNECTED else mqttConnected
val transportIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.Bluetooth else when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb
TransportType.USB_SERIAL -> Icons.Default.Usb
TransportType.WIFI -> Icons.Default.Wifi
TransportType.BLUETOOTH -> Icons.Default.Bluetooth
}
val transportInactiveIcon = when (activeTransport) {
val transportInactiveIcon = if (isEsp32 && esp32Bluetooth) Icons.Default.BluetoothDisabled else when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb
TransportType.USB_SERIAL -> Icons.Default.Usb
TransportType.WIFI -> Icons.Default.WifiOff
@@ -324,12 +326,12 @@ fun DashboardScreen(
)
Text(
text = if (isEsp32) {
when (usbSerialState) {
UsbSerialState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
UsbSerialState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
UsbSerialState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
UsbSerialState.ERROR -> stringResource(R.string.conn_esp32_state_error)
UsbSerialState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
when (esp32LinkState) {
Esp32LinkState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
Esp32LinkState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
Esp32LinkState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
Esp32LinkState.ERROR -> stringResource(R.string.conn_esp32_state_error)
Esp32LinkState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
}
} else when (mqttConnectionState) {
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
@@ -362,15 +364,16 @@ fun DashboardScreen(
TransportChip(
label = stringResource(R.string.dash_transport_usb_serial),
icon = Icons.Default.Usb,
active = activeTransport == TransportType.USB_SERIAL,
active = !esp32Bluetooth,
hasCable = usbCableConnected,
)
}
TransportChip(
label = stringResource(R.string.dash_transport_bt),
icon = Icons.Default.Bluetooth,
active = activeTransport == TransportType.BLUETOOTH,
dimmed = true, // Phase 03 — production BT transport still under discussion
active = isEsp32 && esp32Bluetooth,
// Only the ESP32-C5 has a BLE link; the CiT One has none.
dimmed = !isEsp32,
)
}
}
@@ -70,7 +70,7 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.cam.StationType
import com.hawhamburg.micr0bu.domain.denm.DenmParser
@@ -136,7 +136,7 @@ fun MqttTopicViewerScreen(
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
// Engine road users PLUS roadside units - the engine deliberately does not track RSUs.
val remoteCamPositions by viewModel.stationsInRange.collectAsState()
val usbSerialState by viewModel.usbSerialState.collectAsState()
val esp32LinkState by viewModel.esp32LinkState.collectAsState()
val camPingerActive by viewModel.camPingerActive.collectAsState()
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
val camPingerHasFix by viewModel.camPingerHasFix.collectAsState()
@@ -161,7 +161,7 @@ fun MqttTopicViewerScreen(
// DISCONNECTED and using it here made the screen report "offline" while CAMs streamed in over
// serial. Everything on this screen that means "is the OBU link up?" follows the serial link
// instead when that hardware is selected.
val effectiveState = if (isEsp32) usbSerialState.asConnectionState() else connectionState
val effectiveState = if (isEsp32) esp32LinkState.asConnectionState() else connectionState
val isConnected = effectiveState == MqttConnectionState.CONNECTED
val isConnecting = effectiveState == MqttConnectionState.CONNECTING
@@ -216,8 +216,8 @@ fun MqttTopicViewerScreen(
onClick = {
// Route to whichever transport this hardware actually uses.
if (isEsp32) {
if (isConnected || isConnecting) viewModel.disconnectUsbSerial()
else viewModel.connectUsbSerial()
if (isConnected || isConnecting) viewModel.disconnectEsp32()
else viewModel.connectEsp32()
} else {
if (isConnected || isConnecting) viewModel.disconnect() else viewModel.connect()
}
@@ -253,7 +253,7 @@ fun MqttTopicViewerScreen(
isEsp32 = isEsp32,
denmEvents = denmEvents,
spatIntersections = spatIntersections,
usbSerialState = usbSerialState,
esp32LinkState = esp32LinkState,
camPingerActive = camPingerActive,
camPingerSentCount = camPingerSentCount,
camPingerHasFix = camPingerHasFix,
@@ -294,7 +294,7 @@ private fun TopicListPane(
isEsp32: Boolean = false,
denmEvents: List<com.hawhamburg.micr0bu.domain.denm.DenmEvent> = emptyList(),
spatIntersections: List<com.hawhamburg.micr0bu.domain.spat.SpatIntersection> = emptyList(),
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
esp32LinkState: Esp32LinkState = Esp32LinkState.DISCONNECTED,
camPingerActive: Boolean = false,
camPingerSentCount: Int = 0,
camPingerHasFix: Boolean = false,
@@ -338,7 +338,7 @@ private fun TopicListPane(
// ── CAM Pinger card — ESP32-C5-only manual bench test, mirrors the DENM card above ──
if (showCamPinger) {
CamPingerCard(
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
usbConnected = esp32LinkState == Esp32LinkState.CONNECTED,
pingerActive = camPingerActive,
sentCount = camPingerSentCount,
hasFix = camPingerHasFix,
@@ -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
* than duplicating the chip and its colours per hardware type.
*/
private fun UsbSerialState.asConnectionState(): MqttConnectionState = when (this) {
UsbSerialState.CONNECTED -> MqttConnectionState.CONNECTED
UsbSerialState.DEVICE_ATTACHED,
UsbSerialState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
UsbSerialState.ERROR -> MqttConnectionState.ERROR
UsbSerialState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
private fun Esp32LinkState.asConnectionState(): MqttConnectionState = when (this) {
Esp32LinkState.CONNECTED -> MqttConnectionState.CONNECTED
Esp32LinkState.DEVICE_ATTACHED,
Esp32LinkState.PERMISSION_REQUESTED -> MqttConnectionState.CONNECTING
Esp32LinkState.ERROR -> MqttConnectionState.ERROR
Esp32LinkState.DISCONNECTED -> MqttConnectionState.DISCONNECTED
}
@@ -47,7 +47,9 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
@@ -165,13 +167,19 @@ fun ConnectionSettingsScreen(
onMqttPrefsChange: (MqttPrefs) -> Unit,
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
onObuHardwareChange: (ObuHardware) -> Unit = {},
esp32Transport: Esp32Transport = Esp32Transport.USB,
onEsp32TransportChange: (Esp32Transport) -> Unit = {},
outgoingMessage: OutgoingMessage = OutgoingMessage.CAM,
onOutgoingMessageChange: (OutgoingMessage) -> Unit = {},
signOutgoing: Boolean = true,
onSignOutgoingChange: (Boolean) -> Unit = {},
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_connection), onBack) {
SectionCard {
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). Everything
// below (transport, USB-C options) only really applies to CiT One; ESP32-C5 uses
// USB Serial exclusively and has no transport choice to make here.
// OBU Hardware selector — CiT One / ESP32-C5 (Phase 03, Section 13). The transport
// cards below apply to the CiT One; the ESP32-C5 has its own card (USB-C or BLE,
// CAM or VAM, signing).
Text(
stringResource(R.string.settings_obu_hardware),
style = MaterialTheme.typography.labelSmall,
@@ -212,6 +220,48 @@ fun ConnectionSettingsScreen(
}
}
if (obuHardware == ObuHardware.ESP32_C5) {
SectionCard {
TwoWayChoice(
label = stringResource(R.string.settings_esp32_link),
first = stringResource(R.string.settings_transport_usbc),
second = stringResource(R.string.settings_esp32_link_ble),
firstSelected = esp32Transport == Esp32Transport.USB,
onFirst = { onEsp32TransportChange(Esp32Transport.USB) },
onSecond = { onEsp32TransportChange(Esp32Transport.BLE) },
)
if (esp32Transport == Esp32Transport.BLE) {
Text(
stringResource(R.string.settings_esp32_link_ble_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Divider()
TwoWayChoice(
label = stringResource(R.string.settings_esp32_message),
first = stringResource(R.string.settings_esp32_message_cam),
second = stringResource(R.string.settings_esp32_message_vam),
firstSelected = outgoingMessage == OutgoingMessage.CAM,
onFirst = { onOutgoingMessageChange(OutgoingMessage.CAM) },
onSecond = { onOutgoingMessageChange(OutgoingMessage.VAM) },
)
Divider()
SettingToggleRow(
label = stringResource(R.string.settings_esp32_sign),
checked = signOutgoing,
onCheckedChange = onSignOutgoingChange,
)
Text(
stringResource(R.string.settings_esp32_sign_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
if (obuHardware == ObuHardware.CIT_ONE) {
SectionCard {
// Active transport selector
@@ -611,6 +661,40 @@ private fun LanguageSection() {
}
}
/** A labelled pair of outlined buttons, the selected one filled — the style of the OBU hardware picker. */
@Composable
private fun TwoWayChoice(
label: String,
first: String,
second: String,
firstSelected: Boolean,
onFirst: () -> Unit,
onSecond: () -> Unit,
) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for ((text, selected, onClick) in listOf(Triple(first, firstSelected, onFirst), Triple(second, !firstSelected, onSecond))) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(text, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) }
}
}
}
@Composable
private fun RowDivider() = HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
@@ -13,8 +13,11 @@ import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.data.transport.StationStatus
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.denm.DenmParser
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
@@ -45,7 +48,7 @@ class MqttViewModel @Inject constructor(
private val usbDetector: UsbNetworkDetector,
private val camUseCaseRepository: CamUseCaseRepository,
private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport,
private val esp32Link: Esp32Link,
private val camPinger: CamPinger,
) : ViewModel() {
@@ -80,14 +83,42 @@ class MqttViewModel @Inject constructor(
/** Auto-detected OBU gateway IP on the USB interface. */
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
/** ESP32-C5 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */
val usbSerialState: StateFlow<UsbSerialState> = usbSerialTransport.state
/** ESP32-C5 link state, over USB or BLE per [esp32Transport] — see [Esp32Link]. */
val esp32LinkState: StateFlow<Esp32LinkState> = esp32Link.state
/** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */
val espLinkStatus: StateFlow<EspLinkStatus?> = usbSerialTransport.linkStatus
val espLinkStatus: StateFlow<EspLinkStatus?> = esp32Link.linkStatus
/** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */
val camSendFailures: StateFlow<Int> = usbSerialTransport.consecutiveWriteFailures
/** Non-zero means CAMs are being built and dropped — see [Esp32Link.send]. */
val camSendFailures: StateFlow<Int> = esp32Link.consecutiveWriteFailures
/** Signing and radio counters of the current obu-firmware; null with the previous firmware. */
val stationStatus: StateFlow<StationStatus?> = esp32Link.stationStatus
/** One line about the link session (pairing passkey, provisioning, refusals); null when quiet. */
val esp32Detail: StateFlow<String?> = esp32Link.detail
// ── ESP32-C5 settings ─────────────────────────────────────────────────────
val esp32Transport: StateFlow<Esp32Transport> = esp32Link.transport
fun setEsp32Transport(transport: Esp32Transport) {
viewModelScope.launch { obuHardwarePrefs.setEsp32Transport(transport) }
}
val outgoingMessage: StateFlow<OutgoingMessage> = obuHardwarePrefs.outgoingMessageFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, OutgoingMessage.CAM)
fun setOutgoingMessage(message: OutgoingMessage) {
viewModelScope.launch { obuHardwarePrefs.setOutgoingMessage(message) }
}
val signOutgoing: StateFlow<Boolean> = obuHardwarePrefs.signOutgoingFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setSignOutgoing(sign: Boolean) {
viewModelScope.launch { obuHardwarePrefs.setSignOutgoing(sign) }
}
// ── ESP32-C5 CAM pinger (manual bench test, Phase 03) ─────────────────────
// The ESP32-C5-path equivalent of the CiT One's manual DENM trigger below — a fixed-
@@ -352,10 +383,10 @@ class MqttViewModel @Inject constructor(
fun connect() = repo.connect()
fun disconnect() = repo.disconnect()
/** Connect/disconnect the ESP32-C5 USB-serial link — separate from [connect]/[disconnect],
* which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. See [ConnectionSetupScreen]. */
fun connectUsbSerial() = usbSerialTransport.connect()
fun disconnectUsbSerial() = usbSerialTransport.disconnect()
/** Connect/disconnect the ESP32-C5 link (USB or BLE per [esp32Transport]) — separate from
* [connect]/[disconnect], which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. */
fun connectEsp32() = esp32Link.connect()
fun disconnectEsp32() = esp32Link.disconnect()
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
@@ -389,7 +420,7 @@ class MqttViewModel @Inject constructor(
super.onCleared()
repo.disconnect()
camPinger.stop()
// Deliberately NOT usbSerialTransport.disconnect(): the transport is an app-scoped
// Deliberately NOT esp32Link.disconnect(): the link is an app-scoped
// @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop).
// Closing it here would tear the port down when the Activity goes away — e.g. swiping
// the app from Recents mid-recording — leaving the still-running service beaconing into