From a08494b56a7e2c50e561b5b3a6db757f08e714d9 Mon Sep 17 00:00:00 2001 From: Ashin Walpola Date: Wed, 23 Sep 2026 17:28:05 +0200 Subject: [PATCH] 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. --- app/src/main/AndroidManifest.xml | 12 +- app/src/main/assets/demo-chain.vcr | Bin 0 -> 695 bytes .../com/hawhamburg/micr0bu/MainActivity.kt | 30 +- .../hawhamburg/micr0bu/data/GnssTimeSource.kt | 17 +- .../micr0bu/data/cam/CamUseCaseRepository.kt | 14 +- .../data/mqtt/ObuHardwarePreferences.kt | 38 ++ .../data/transport/BleLinkTransport.kt | 466 ++++++++++++++++++ .../micr0bu/data/transport/Esp32Link.kt | 422 ++++++++++++++++ .../micr0bu/data/transport/StationLink.kt | 274 ++++++++++ .../data/transport/UsbSerialTransport.kt | 64 ++- .../micr0bu/domain/asn1/Asn1UperCodec.kt | 2 +- .../micr0bu/domain/asn1/VamUperCodec.kt | 145 ++++++ .../micr0bu/domain/cam/PhoneCamBuilder.kt | 2 +- .../micr0bu/domain/vam/VamGenerationRules.kt | 60 +++ .../hawhamburg/micr0bu/service/CamPinger.kt | 12 +- .../micr0bu/service/CamTransmitLoop.kt | 86 +++- .../micr0bu/ui/components/StatusTopBar.kt | 37 +- .../ui/screens/ConnectionSetupScreen.kt | 69 ++- .../micr0bu/ui/screens/DashboardScreen.kt | 31 +- .../ui/screens/MqttTopicViewerScreen.kt | 28 +- .../micr0bu/ui/screens/SettingsScreen.kt | 90 +++- .../micr0bu/viewmodel/MqttViewModel.kt | 57 ++- app/src/main/res/values-de/strings.xml | 16 +- app/src/main/res/values/strings.xml | 16 +- .../com/hawhamburg/micr0bu/StationLinkTest.kt | 119 +++++ .../micr0bu/VamGenerationRulesTest.kt | 60 +++ .../hawhamburg/micr0bu/VamUperCodecTest.kt | 59 +++ 27 files changed, 2092 insertions(+), 134 deletions(-) create mode 100644 app/src/main/assets/demo-chain.vcr create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/transport/BleLinkTransport.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/transport/Esp32Link.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/transport/StationLink.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/VamUperCodec.kt create mode 100644 app/src/main/java/com/hawhamburg/micr0bu/domain/vam/VamGenerationRules.kt create mode 100644 app/src/test/java/com/hawhamburg/micr0bu/StationLinkTest.kt create mode 100644 app/src/test/java/com/hawhamburg/micr0bu/VamGenerationRulesTest.kt create mode 100644 app/src/test/java/com/hawhamburg/micr0bu/VamUperCodecTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b363613..3a52451 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -10,6 +11,15 @@ + + + + + + diff --git a/app/src/main/assets/demo-chain.vcr b/app/src/main/assets/demo-chain.vcr new file mode 100644 index 0000000000000000000000000000000000000000..279ff897dad55fe7004dbab70859c6b64abdcd91 GIT binary patch literal 695 zcmWG@4l-n9_|w45(8wUssN|cOT;%T*s^F5Eo39X*pI@S&;pZQs5ajCa864so4HXPCPoWJrUea*tPPARO&rXO|Nk)mXJBq%RBhs7 zVf_F9Kg)lhUZB#tCKg6U#{UeAy+BTW6B`rb0jB@Q=R`{&8HK{0=rLLhN?VEDshMP}I^cr>7DOG$h^$%cl zo%s04D!-hXd)u83KGpM@>nd$8p}U2vewxFbH6o2Km^uCS>#sfY{YB-fqd(1=7=8e~ z-;h_!T=JVql2NjeA0-qV9g)1u3UMFEs|JjWK4 zv}Bc=yzUluo^hZ2fI;%N3DE1$h3)5l`|+qx`HPkFir}f-kE_3};9seGo-Kgcy2meN zdPBpiomnf^x$K=KDpEh$=HaII-GS*|eIJ-ZY&TrBk=-D*;qSvI#pxYWR&SZUG1J7` zrgXk?vbA{p`cEuO)tpkz0q>a^CV{-Z*-&4z#cAdPfo8Z@TU-i&vCF8^$imDBi8F9C zH#9drU%!jj^HZ7Zk?M)T7q{ydh$WT@c+L9O{kYt?z1iNWp`oWU&Tf6xE%{wn!s6H2 z=LIB8(NNS1ZTn<%qSj32d literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt index 77f491b..8613d67 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -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() }, ) } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/GnssTimeSource.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/GnssTimeSource.kt index 6a04804..f11028c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/GnssTimeSource.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/GnssTimeSource.kt @@ -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") diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt index b7c8585..ae0108e 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/cam/CamUseCaseRepository.kt @@ -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() } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt index 2373e78..c54131a 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/ObuHardwarePreferences.kt @@ -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 = 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 = 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 = 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 = 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. * diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/BleLinkTransport.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/BleLinkTransport.kt new file mode 100644 index 0000000..179a893 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/BleLinkTransport.kt @@ -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 = _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(null) + val detail: StateFlow = _detail.asStateFlow() + + private val _incoming = MutableSharedFlow(extraBufferCapacity = 256) + /** Every link message the micrOBU notifies, one GATT value each. */ + val incoming: SharedFlow = _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? = null + @Volatile private var connected: CompletableDeferred? = null + @Volatile private var mtuDone: CompletableDeferred? = null + @Volatile private var servicesDone: CompletableDeferred? = 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() + 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() + 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() + 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()) } + } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/Esp32Link.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/Esp32Link.kt new file mode 100644 index 0000000..0e03d28 --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/Esp32Link.kt @@ -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 = + prefs.esp32TransportFlow.stateIn(scope, SharingStarted.Eagerly, Esp32Transport.USB) + + val state: StateFlow = 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 = _protocol.asStateFlow() + + private val _incomingFrames = MutableSharedFlow(extraBufferCapacity = 256) + /** Received ITS messages ([SerialFrameType.V2X_RX]) and, from legacy firmware, its heartbeats. */ + val incomingFrames: SharedFlow = _incomingFrames.asSharedFlow() + + private val _linkStatus = MutableStateFlow(null) + /** Heartbeat counters in the old shape, from either firmware; null before the first one. */ + val linkStatus: StateFlow = _linkStatus.asStateFlow() + + private val _stationStatus = MutableStateFlow(null) + /** Full station-link STATUS (signing counters, tickets); null with legacy firmware. */ + val stationStatus: StateFlow = _stationStatus.asStateFlow() + + private val _detail = MutableStateFlow(null) + /** One line for the UI about the session: pairing, provisioning, or why it is stuck. */ + val detail: StateFlow = _detail.asStateFlow() + + private val _consecutiveWriteFailures = MutableStateFlow(0) + val consecutiveWriteFailures: StateFlow = _consecutiveWriteFailures.asStateFlow() + + private val _refusedRequests = MutableStateFlow(0) + /** BTP_DATA_REQUESTs the micrOBU answered with anything but accepted (e.g. no ticket). */ + val refusedRequests: StateFlow = _refusedRequests.asStateFlow() + + private val sequence = AtomicInteger(0) + private val pending = ConcurrentHashMap>() + 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() + 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) + } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/StationLink.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/StationLink.kt new file mode 100644 index 0000000..21899bd --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/StationLink.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt index 0af3bc0..c71fc32 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/transport/UsbSerialTransport.kt @@ -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 = _state.asStateFlow() + private val _state = MutableStateFlow(Esp32LinkState.DISCONNECTED) + val state: StateFlow = _state.asStateFlow() private val _incomingFrames = MutableSharedFlow(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 } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/Asn1UperCodec.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/Asn1UperCodec.kt index 0d3bf3c..82f6826 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/Asn1UperCodec.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/Asn1UperCodec.kt @@ -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). * diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/VamUperCodec.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/VamUperCodec.kt new file mode 100644 index 0000000..9282d8d --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/asn1/VamUperCodec.kt @@ -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) +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt index 6f70c23..4886c53 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/cam/PhoneCamBuilder.kt @@ -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 diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/vam/VamGenerationRules.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/vam/VamGenerationRules.kt new file mode 100644 index 0000000..8a77f8f --- /dev/null +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/vam/VamGenerationRules.kt @@ -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 + } +} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt index 58d6a1d..3b1beaf 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/CamPinger.kt @@ -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 } } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt index 15b5d49..c566a6d 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/CamTransmitLoop.kt @@ -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 diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt index ebda3e0..75e3df1 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/components/StatusTopBar.kt @@ -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 } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt index ad4ab71..797b504 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/ConnectionSetupScreen.kt @@ -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)) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt index adbba49..eed69e4 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt @@ -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, ) } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt index e292011..e33ed6d 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -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 = emptyList(), spatIntersections: List = 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 } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt index a5402ea..b79d8b3 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt @@ -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)) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt index 673a548..332892a 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt @@ -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 = usbDetector.detectedGatewayIp - /** ESP32-C5 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */ - val usbSerialState: StateFlow = usbSerialTransport.state + /** ESP32-C5 link state, over USB or BLE per [esp32Transport] — see [Esp32Link]. */ + val esp32LinkState: StateFlow = esp32Link.state /** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */ - val espLinkStatus: StateFlow = usbSerialTransport.linkStatus + val espLinkStatus: StateFlow = esp32Link.linkStatus - /** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */ - val camSendFailures: StateFlow = usbSerialTransport.consecutiveWriteFailures + /** Non-zero means CAMs are being built and dropped — see [Esp32Link.send]. */ + val camSendFailures: StateFlow = esp32Link.consecutiveWriteFailures + + /** Signing and radio counters of the current obu-firmware; null with the previous firmware. */ + val stationStatus: StateFlow = esp32Link.stationStatus + + /** One line about the link session (pairing passkey, provisioning, refusals); null when quiet. */ + val esp32Detail: StateFlow = esp32Link.detail + + // ── ESP32-C5 settings ───────────────────────────────────────────────────── + + val esp32Transport: StateFlow = esp32Link.transport + + fun setEsp32Transport(transport: Esp32Transport) { + viewModelScope.launch { obuHardwarePrefs.setEsp32Transport(transport) } + } + + val outgoingMessage: StateFlow = obuHardwarePrefs.outgoingMessageFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, OutgoingMessage.CAM) + + fun setOutgoingMessage(message: OutgoingMessage) { + viewModelScope.launch { obuHardwarePrefs.setOutgoingMessage(message) } + } + + val signOutgoing: StateFlow = 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4833bca..68e719a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -189,7 +189,7 @@ OBU-Hardware CiT One ESP32-C5 - Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug. + Das Smartphone erstellt CAM oder VAM und übergibt sie per USB-C oder Bluetooth an den ESP32-C5; der ESP32 ergänzt GeoNetworking, signiert (optional) und sendet über ITS-G5. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine - siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug. Aktiver Transport USB-C WLAN @@ -318,4 +318,18 @@ Kreuzung %1$s · Station %2$d SG%1$d %1$.0f s + ESP32-C5-Verbindung + Bluetooth + Beim ersten Verbinden wird das Koppeln mit micrOBU-XXXX angefragt: Passkey 123456 eingeben. Die Platine wirbt nur, solange ihr USB-C-Port nicht benutzt wird. BLE teilt sich das Funk-Frontend mit ITS-G5; der Einfluss auf den 5,9-GHz-Empfang ist noch nicht gemessen. + Senden während der Aufzeichnung + CAM + VAM + Ausgehende Nachrichten signieren + Signiert mit einer Demo-PKI, nicht der EU-Vertrauensliste: Empfänger, die dagegen prüfen, verwerfen diese Nachrichten. Aus sendet sie wie bisher unsigniert. + über USB-C (nativer Port) + über Bluetooth (Passkey 123456 beim ersten Koppeln) + Abbrechen + Signieren %1$s · Tickets %2$d · signiert %3$d · abgelehnt %4$d · gesendet %5$d + an + aus diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 832e515..24ae426 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,7 +190,7 @@ OBU Hardware CiT One ESP32-C5 - ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool. + The phone builds CAM or VAM and hands it to the ESP32-C5 over USB-C or Bluetooth; the ESP32 adds GeoNetworking, signs it (optional) and broadcasts it over ITS-G5. No MQTT broker or DENM use-case engine on this path - see the V2X Monitor screen\'s CAM Pinger for a manual test tool. Active transport USB-C Wi-Fi @@ -325,4 +325,18 @@ Trip Review + ESP32-C5 link + Bluetooth + The first connection asks to pair with micrOBU-XXXX: enter passkey 123456. The board only advertises while nothing uses its USB-C port. BLE shares the radio front end with ITS-G5; its effect on 5.9 GHz reception has not been measured yet. + Transmit while recording + CAM + VAM + Sign outgoing messages + Signed with a demo PKI, not the EU trust list: receivers that verify against it will drop these messages. Off sends them unsigned, as before. + via USB-C (native port) + via Bluetooth (passkey 123456 on first pairing) + Cancel + Signing %1$s · tickets %2$d · signed %3$d · refused %4$d · on air %5$d + on + off diff --git a/app/src/test/java/com/hawhamburg/micr0bu/StationLinkTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/StationLinkTest.kt new file mode 100644 index 0000000..db9ef23 --- /dev/null +++ b/app/src/test/java/com/hawhamburg/micr0bu/StationLinkTest.kt @@ -0,0 +1,119 @@ +package com.hawhamburg.micr0bu + +import com.hawhamburg.micr0bu.data.transport.BtpDataRequest +import com.hawhamburg.micr0bu.data.transport.LinkMessage +import com.hawhamburg.micr0bu.data.transport.LinkOpcode +import com.hawhamburg.micr0bu.data.transport.LinkResult +import com.hawhamburg.micr0bu.data.transport.LinkSecurityProfile +import com.hawhamburg.micr0bu.data.transport.PotiUpdate +import com.hawhamburg.micr0bu.data.transport.StationConfigure +import com.hawhamburg.micr0bu.data.transport.StationInfo +import com.hawhamburg.micr0bu.data.transport.StationStatus +import com.hawhamburg.micr0bu.data.transport.credentialsSegment +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the phone side of the station-link message layer. + * + * ## Where the expected bytes come from + * The colleague's Python implementation of the same protocol, microbu-esp32c5 + * station-link/python/microbu_link/messages.py (`encode_message` over each body's `encode`), + * run with the same field values. That module is what their phone emulator drives the firmware + * with, so an agreement here is agreement with a second, independent implementation. + */ +class StationLinkTest { + + private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } + + @Test + fun `STATION_CONFIGURE matches the Python encoder`() { + val body = StationConfigure(stationType = 2, mid = "021122334455".hexToBytes()).encode() + assertEquals( + "0100070002010002112233445500b40014020205", + LinkMessage(LinkOpcode.STATION_CONFIGURE, 7, body).encode().hex(), + ) + } + + @Test + fun `POTI_UPDATE matches the Python encoder`() { + val body = PotiUpdate( + timestampMs = 717_254_800_123L, latTenMicroDeg = 535_546_667, lonTenMicroDeg = 100_223_889, + semiMajorCm = 486, semiMinorCm = 486, speedCms = 543, headingDeciDeg = 1234, pai = true, + ).encode() + assertEquals( + "02000800fbb2b7ffa60000002bcbeb1f914bf905e601e60100000e000000001f02d204", + LinkMessage(LinkOpcode.POTI_UPDATE, 8, body).encode().hex(), + ) + } + + @Test + fun `BTP_DATA_REQUEST for a signed CAM matches the Python encoder`() { + val body = BtpDataRequest( + destinationPort = 2001, itsAid = 36, securityProfile = LinkSecurityProfile.SECURED, + permissions = "010000".hexToBytes(), flSdu = "0102030405".hexToBytes(), + ).encode() + assertEquals( + "0300090001d1070000010102ffff000000000024000000030100000005000102030405", + LinkMessage(LinkOpcode.BTP_DATA_REQUEST, 9, body).encode().hex(), + ) + } + + @Test + fun `CREDENTIALS_PROVISION segment matches the Python encoder`() { + val body = credentialsSegment(totalLength = 695, offset = 240, segment = ByteArray(3) { 0xAB.toByte() }) + assertEquals("04000a00b702f00003ababab", LinkMessage(LinkOpcode.CREDENTIALS_PROVISION, 10, body).encode().hex()) + } + + @Test + fun `STATUS from the Python encoder decodes field by field`() { + val message = LinkMessage.decode( + ("8400341240e20100010800021122334455b80b49387a4c12eb00010b0000000c0000000d0000000e000000" + + "0f000000100000001100000012000000130000001400000015000000160000001700000018000000" + + "190000001a0000001b0000001c000000fbb2b7ffa6000000").hexToBytes(), + ) + assertNotNull(message) + assertEquals(LinkOpcode.STATUS, message!!.opcode) + assertEquals(0x1234, message.sequence) + val status = StationStatus.decode(message.body)!! + assertEquals(123_456L, status.uptimeMs) + assertTrue(status.configured) + assertArrayEquals("b80b49387a4c12eb".hexToBytes(), status.identifier) + assertEquals(1, status.tickets) + assertEquals(11L, status.signedMessages) + assertEquals(12L, status.refusedNoTicket) + assertEquals(13L, status.refusedChangePending) + assertEquals(14L, status.refusedPermission) + assertEquals(15L, status.signFailed) + assertEquals(16L, status.verified) + assertEquals(17L, status.rejected) + assertEquals(18L, status.requestsAccepted) + assertEquals(19L, status.requestsRefused) + assertEquals(21L, status.radioSubmitted) + assertEquals(22L, status.radioFailed) + assertEquals(23L, status.radioReceived) + assertEquals(24L, status.radioDropped) + assertEquals(26L, status.linkCrcErrors) + assertEquals(27L, status.linkMalformed) + assertEquals(28L, status.potiUpdates) + assertEquals(717_254_800_123L, status.itsTimeMs) + } + + @Test + fun `a STATUS of the wrong length is refused, as the Python decoder does`() { + assertNull(StationStatus.decode(ByteArray(StationStatus.SIZE + 1))) + } + + @Test + fun `RESULT of STATION_CONFIGURE carries the credential state`() { + val message = LinkMessage.decode("8000070000120800021122334455b80b49387a4c12eb0101".hexToBytes())!! + val result = LinkResult.decode(message.body)!! + assertTrue(result.accepted) + assertEquals(StationInfo(credentialsLoaded = true, tickets = 1), StationInfo.decode(result.detail)) + } +} diff --git a/app/src/test/java/com/hawhamburg/micr0bu/VamGenerationRulesTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/VamGenerationRulesTest.kt new file mode 100644 index 0000000..8e0b5d6 --- /dev/null +++ b/app/src/test/java/com/hawhamburg/micr0bu/VamGenerationRulesTest.kt @@ -0,0 +1,60 @@ +package com.hawhamburg.micr0bu + +import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules +import com.hawhamburg.micr0bu.domain.vam.VamGenerationRules.Kinematics +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** TS 103 300-3 clause 6.4 items 1 to 4, with the Table 16/17 values. */ +class VamGenerationRulesTest { + + private val here = Kinematics(53.5546667, 10.0223889, speedMps = 3.0, headingDeg = 90.0) + + private fun sentAt(ms: Long, k: Kinematics = here) = VamGenerationRules().apply { onSent(ms, k, withLowFrequency = true) } + + @Test + fun `the first VAM is always due`() { + assertTrue(VamGenerationRules().due(0, here)) + } + + @Test + fun `nothing is due within T_GenVamMin even after a big jump`() { + assertFalse(sentAt(1_000).due(1_050, here.copy(latitude = here.latitude + 0.001))) + } + + @Test + fun `a stationary VRU gets one VAM every T_GenVamMax`() { + val rules = sentAt(1_000) + assertFalse(rules.due(5_900, here)) + assertTrue(rules.due(6_001, here)) + } + + @Test + fun `position, speed and heading changes trigger past their thresholds only`() { + val rules = sentAt(1_000) + // ~3.3 m north: under 4 m. ~5.6 m: over. + assertFalse(rules.due(2_000, here.copy(latitude = here.latitude + 0.00003))) + assertTrue(rules.due(2_000, here.copy(latitude = here.latitude + 0.00005))) + assertFalse(rules.due(2_000, here.copy(speedMps = 3.4))) + assertTrue(rules.due(2_000, here.copy(speedMps = 3.6))) + assertFalse(rules.due(2_000, here.copy(headingDeg = 93.0))) + assertTrue(rules.due(2_000, here.copy(headingDeg = 95.0))) + } + + @Test + fun `heading change is measured the short way round north`() { + val rules = sentAt(1_000, here.copy(headingDeg = 358.0)) + assertFalse(rules.due(2_000, here.copy(headingDeg = 1.0))) // 3 degrees across north + assertTrue(rules.due(2_000, here.copy(headingDeg = 3.0))) // 5 degrees + } + + @Test + fun `the low-frequency container goes with the first VAM, then every T_GenVamLFMin`() { + val rules = VamGenerationRules() + assertTrue(rules.includeLowFrequency(0)) + rules.onSent(0, here, withLowFrequency = true) + assertFalse(rules.includeLowFrequency(1_999)) + assertTrue(rules.includeLowFrequency(2_000)) + } +} diff --git a/app/src/test/java/com/hawhamburg/micr0bu/VamUperCodecTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/VamUperCodecTest.kt new file mode 100644 index 0000000..d8002d1 --- /dev/null +++ b/app/src/test/java/com/hawhamburg/micr0bu/VamUperCodecTest.kt @@ -0,0 +1,59 @@ +package com.hawhamburg.micr0bu + +import com.hawhamburg.micr0bu.domain.asn1.VamContent +import com.hawhamburg.micr0bu.domain.asn1.VamUperCodec +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pins the VAM encoder against bytes this code did not produce. + * + * ## Where the expected bytes come from + * asn1tools 0.167, compiling the ETSI modules vanetza-idf ships (asn1/release2: + * TS102894-2v241-CDD.asn, TS103300-3v231/VAM-PDU-Descriptions.asn and its motorcyclist container), + * encoding the same values as a Python dict: the same toolchain the colleague's reference VBS + * (microbu-esp32c5/station-link/python/microbu_link/vbs.py) builds its VAMs with. Station + * 0x12345678, fix at Unix ms 1790170000123 (generationDeltaTime 45819), 53.5546667 N + * 10.0223889 E, 5.43 m/s, heading 123.4 deg, -1.26 m/s^2; every confidence and the altitude + * unavailable, as the encoder sends them. + */ +class VamUperCodecTest { + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } + + private fun content(includeLowFrequency: Boolean, accuracyM: Float? = 3.0f) = VamContent( + stationId = 0x12345678, + timestamp = 1_790_170_000_123L, + latitude = 53.5546667, + longitude = 10.0223889, + accuracyM = accuracyM, + speedMps = 5.43, + headingDeg = 123.4, + accelerationMps2 = -1.26, + includeLowFrequency = includeLowFrequency, + ) + + @Test + fun `VAM with the low-frequency container matches asn1tools`() { + assertEquals( + "031012345678b2fb400aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f322220", + VamUperCodec.encode(content(includeLowFrequency = true)).hex(), + ) + } + + @Test + fun `VAM without the low-frequency container matches asn1tools`() { + assertEquals( + "031012345678b2fb000aac85a15b8a18ec88f30f3708eddd0f8002697e087ff24f30", + VamUperCodec.encode(content(includeLowFrequency = false)).hex(), + ) + } + + @Test + fun `unknown accuracy encodes the confidence ellipse as unavailable`() { + assertEquals( + "031012345678b2fb000aac85a15b8a18ec8fffffff08eddd0f8002697e087ff24f30", + VamUperCodec.encode(content(includeLowFrequency = false, accuracyM = null)).hex(), + ) + } +}