Drive the ESP32-C5 station over USB or BLE, send CAM or VAM, signed or not

The app now speaks the station-link protocol of the new obu-firmware.
Esp32Link picks the transport from Settings (UsbSerialTransport or the new
BleLinkTransport), tells the previous firmware from the new one by its
heartbeat, and runs the session: STATION_CONFIGURE with the current pseudonym
MAC (which also starts the board's radio), CREDENTIALS_PROVISION of the
bundled demo chain when the board has no ticket, then per message a
POTI_UPDATE and a BTP_DATA_REQUEST. Received messages still arrive as
V2X_RX frames, so the receive side is unchanged. A board on the previous
firmware keeps working for CAM over USB.

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

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

StationLinkTest pins the codec to bytes from the colleague's Python
implementation (microbu_link/messages.py). 103 unit tests pass.
This commit is contained in:
Ashin Walpola
2026-09-23 17:28:05 +02:00
parent d2fd222a62
commit a08494b56a
27 changed files with 2092 additions and 134 deletions
@@ -13,8 +13,11 @@ import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.data.transport.Esp32LinkState
import com.hawhamburg.micr0bu.data.transport.Esp32Link
import com.hawhamburg.micr0bu.data.transport.Esp32Transport
import com.hawhamburg.micr0bu.data.transport.OutgoingMessage
import com.hawhamburg.micr0bu.data.transport.StationStatus
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.denm.DenmParser
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
@@ -45,7 +48,7 @@ class MqttViewModel @Inject constructor(
private val usbDetector: UsbNetworkDetector,
private val camUseCaseRepository: CamUseCaseRepository,
private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport,
private val esp32Link: Esp32Link,
private val camPinger: CamPinger,
) : ViewModel() {
@@ -80,14 +83,42 @@ class MqttViewModel @Inject constructor(
/** Auto-detected OBU gateway IP on the USB interface. */
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
/** ESP32-C5 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */
val usbSerialState: StateFlow<UsbSerialState> = usbSerialTransport.state
/** ESP32-C5 link state, over USB or BLE per [esp32Transport] — see [Esp32Link]. */
val esp32LinkState: StateFlow<Esp32LinkState> = esp32Link.state
/** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */
val espLinkStatus: StateFlow<EspLinkStatus?> = usbSerialTransport.linkStatus
val espLinkStatus: StateFlow<EspLinkStatus?> = esp32Link.linkStatus
/** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */
val camSendFailures: StateFlow<Int> = usbSerialTransport.consecutiveWriteFailures
/** Non-zero means CAMs are being built and dropped — see [Esp32Link.send]. */
val camSendFailures: StateFlow<Int> = esp32Link.consecutiveWriteFailures
/** Signing and radio counters of the current obu-firmware; null with the previous firmware. */
val stationStatus: StateFlow<StationStatus?> = esp32Link.stationStatus
/** One line about the link session (pairing passkey, provisioning, refusals); null when quiet. */
val esp32Detail: StateFlow<String?> = esp32Link.detail
// ── ESP32-C5 settings ─────────────────────────────────────────────────────
val esp32Transport: StateFlow<Esp32Transport> = esp32Link.transport
fun setEsp32Transport(transport: Esp32Transport) {
viewModelScope.launch { obuHardwarePrefs.setEsp32Transport(transport) }
}
val outgoingMessage: StateFlow<OutgoingMessage> = obuHardwarePrefs.outgoingMessageFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, OutgoingMessage.CAM)
fun setOutgoingMessage(message: OutgoingMessage) {
viewModelScope.launch { obuHardwarePrefs.setOutgoingMessage(message) }
}
val signOutgoing: StateFlow<Boolean> = obuHardwarePrefs.signOutgoingFlow
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
fun setSignOutgoing(sign: Boolean) {
viewModelScope.launch { obuHardwarePrefs.setSignOutgoing(sign) }
}
// ── ESP32-C5 CAM pinger (manual bench test, Phase 03) ─────────────────────
// The ESP32-C5-path equivalent of the CiT One's manual DENM trigger below — a fixed-
@@ -352,10 +383,10 @@ class MqttViewModel @Inject constructor(
fun connect() = repo.connect()
fun disconnect() = repo.disconnect()
/** Connect/disconnect the ESP32-C5 USB-serial link — separate from [connect]/[disconnect],
* which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. See [ConnectionSetupScreen]. */
fun connectUsbSerial() = usbSerialTransport.connect()
fun disconnectUsbSerial() = usbSerialTransport.disconnect()
/** Connect/disconnect the ESP32-C5 link (USB or BLE per [esp32Transport]) — separate from
* [connect]/[disconnect], which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. */
fun connectEsp32() = esp32Link.connect()
fun disconnectEsp32() = esp32Link.disconnect()
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
@@ -389,7 +420,7 @@ class MqttViewModel @Inject constructor(
super.onCleared()
repo.disconnect()
camPinger.stop()
// Deliberately NOT usbSerialTransport.disconnect(): the transport is an app-scoped
// Deliberately NOT esp32Link.disconnect(): the link is an app-scoped
// @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop).
// Closing it here would tear the port down when the Activity goes away — e.g. swiping
// the app from Recents mid-recording — leaving the still-running service beaconing into