Phase 03: real CAM UPER codec + ESP32-C5 TX/RX serial link

- Firmware: rewrite obu-firmware TX loop to be serial-driven (no on-chip timer), add promiscuous RX + GeoNetworking/BTP unwrap (gn_unwrap.c), add binary UART framing to the phone (serial_link.c/.h). Drop local cam_encode() - CAM is now built on the phone.
- Kotlin: byte-exact UPER CAM encoder/decoder ported from cam.c (BitWriter/BitReader/CamUperCodec), matching SerialFrame codec, real UsbSerialTransport (usb-serial-for-android), CamTransmitLoop (1Hz base rate, event/geofence boost, ESP32-C5-only), wired into CamUseCaseRepository for RX and TripRecordingService for TX.
- Add V2X message retention: persist all CAM (own+remote) to Room while recording, drop otherwise (DB v2 -> v3 migration).
- Add jitpack repo + usb-serial-for-android dependency.

Fixes: UsbSerialTransport now uses SerialInputOutputManager.start()/stop() (this lib version manages its own thread internally) instead of manual Runnable/Thread submission, which didn't compile.
This commit is contained in:
Ashin Walpola
2026-08-04 17:58:07 +02:00
parent 842d362c8f
commit 33c4ec5998
58 changed files with 8438 additions and 65 deletions
@@ -82,6 +82,7 @@ class MainActivity : AppCompatActivity() {
val tripServiceState by tripViewModel.serviceState.collectAsState()
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
val obuHardware by mqttViewModel.obuHardware.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current
@@ -133,6 +134,7 @@ class MainActivity : AppCompatActivity() {
state = state,
mqttConnectionState = mqttConnectionState,
activeTransport = activeTransport,
obuHardware = obuHardware,
usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType,
@@ -145,6 +147,13 @@ class MainActivity : AppCompatActivity() {
}
},
onNavigateToMap = { navController.navigate(Screen.Map.route) },
onNavigateToRecord = {
navController.navigate(Screen.Record.route) {
popUpTo(Screen.Dashboard.route) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
)
}
composable(Screen.Sensors.route) {
@@ -239,6 +248,8 @@ class MainActivity : AppCompatActivity() {
ConnectionSettingsScreen(
mqttPrefs = mqttPrefs,
onMqttPrefsChange = mqttViewModel::updatePrefs,
obuHardware = obuHardware,
onObuHardwareChange = mqttViewModel::setObuHardware,
onBack = { navController.popBackStack() },
)
}
@@ -3,6 +3,8 @@ package com.hawhamburg.micr0bu.data
import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
import com.hawhamburg.micr0bu.data.db.V2xMessageEntity
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
import kotlinx.coroutines.flow.Flow
@@ -90,4 +92,30 @@ class TripRepository(db: AppDatabase) {
/** Emits events for [tripId] ordered by timestamp, updating whenever the DB changes. */
fun getEventsForTrip(tripId: Long): Flow<List<DetectedEventEntity>> =
dao.getEventsForTrip(tripId)
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
// Retention policy: only ever called while a trip is actively recording — see
// V2xMessageEntity's KDoc and CamUseCaseRepository.processedCam's collector in
// TripRecordingService, which is the only caller.
/** Persists a domain [Cam] (own or remote) for the given [tripId]. */
suspend fun insertV2xMessage(tripId: Long, cam: Cam) =
dao.insertV2xMessage(
V2xMessageEntity(
tripId = tripId,
timestamp = cam.timestamp,
isOwn = cam.isOwn,
stationId = cam.stationId,
stationType = cam.stationType,
latitude = cam.latitude,
longitude = cam.longitude,
speedMps = cam.speedMps,
headingDeg = cam.headingDeg,
yawRateDps = cam.yawRateDps,
)
)
/** Emits V2X messages for [tripId] ordered by timestamp, updating whenever the DB changes. */
fun getV2xMessagesForTrip(tripId: Long): Flow<List<V2xMessageEntity>> =
dao.getV2xMessagesForTrip(tripId)
}
@@ -6,6 +6,9 @@ import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.cam.ObuGnssParser
@@ -18,9 +21,12 @@ 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
@@ -61,6 +67,8 @@ private const val OBU_GNSS_STALE_MS = 2_500L
class CamUseCaseRepository @Inject constructor(
private val mqttRepository: MqttRepository,
private val prefs: UseCaseAlertPreferences,
private val usbSerialTransport: UsbSerialTransport,
private val camCodec: RealAsn1UperCodec,
@ApplicationContext private val context: Context,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -87,6 +95,23 @@ class CamUseCaseRepository @Inject constructor(
alerts.filter { enabled[it.useCase] != false }
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
/** Ego bike's latest known position/state, for the V2X Monitor live map view (Section 13). */
val ownPosition: StateFlow<Cam?> = engine.ownPosition
/** Each tracked remote road user's latest known CAM, for the live map view (Section 13). */
val remotePositions: StateFlow<Map<Long, Cam>> = engine.remotePositions
private val _processedCam = MutableSharedFlow<Cam>(extraBufferCapacity = 256)
/**
* Every CAM (own or remote) this repository processes, own outgoing included — for
* [com.hawhamburg.micr0bu.service.TripRecordingService] to persist for the duration of a
* recording session (see `V2xMessageEntity`'s retention-policy KDoc). Deliberately separate
* from [ownPosition]/[remotePositions] (which only track the *latest* state per station,
* for the live map) — this is every message, unbounded, since a recording session needs the
* full history, not just current position.
*/
val processedCam: SharedFlow<Cam> = _processedCam.asSharedFlow()
init {
scope.launch {
mqttRepository.messages.collect { msg ->
@@ -130,6 +155,17 @@ class CamUseCaseRepository @Inject constructor(
engine.pruneStale(System.currentTimeMillis())
}
}
// ESP32-C5 path (Phase 03): remote CAMs arrive over the serial link instead of MQTT,
// already stripped of 802.11/GeoNetworking/BTP framing by the firmware's gn_unwrap.c -
// 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 ->
if (frame.type != SerialFrameType.CAM_RX) return@collect
handleCamFromSerial(frame.payload)
}
}
}
fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) {
@@ -151,6 +187,7 @@ class CamUseCaseRepository @Inject constructor(
if (ego.stationId != 0L) _ownStationId.value = ego.stationId
lastOwnStationType = ego.stationType
engine.onOwnCam(ego)
_processedCam.tryEmit(ego)
}
/**
@@ -176,6 +213,7 @@ class CamUseCaseRepository @Inject constructor(
isOwn = true,
)
engine.onOwnCam(ego)
_processedCam.tryEmit(ego)
}
private fun handleCam(payload: String, timestamp: Long) {
@@ -188,5 +226,26 @@ class CamUseCaseRepository @Inject constructor(
} else {
engine.onRemoteCam(cam)
}
_processedCam.tryEmit(cam)
}
/**
* ESP32-C5 path: [payload] is a [com.hawhamburg.micr0bu.data.transport.SerialFrameType.CAM_RX]
* frame's body — `[rssi: 1 signed byte][CAM UPER bytes...]` (see that type's KDoc). RSSI
* itself isn't consumed yet (no UI surface for it on this path currently); only the CAM
* bytes are decoded.
*
* Every CAM received over the air here is inherently remote — this project's own outgoing
* CAM never loops back through this path — except for the edge case of the radio hearing
* its own just-transmitted frame (promiscuous capture of a local TX). Guarded the same way
* the MQTT path guards against reprocessing "own" CAM: compare against [_ownStationId].
*/
private fun handleCamFromSerial(payload: ByteArray) {
if (payload.isEmpty()) return
val camBytes = payload.copyOfRange(1, payload.size) // payload[0] is RSSI, not part of the CAM
val cam = camCodec.decodeCam(camBytes, System.currentTimeMillis()) ?: return
if (_ownStationId.value != null && cam.stationId == _ownStationId.value) return // self-heard TX
engine.onRemoteCam(cam)
_processedCam.tryEmit(cam)
}
}
@@ -12,8 +12,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
SessionEntity::class,
RecordedTripEntity::class,
DetectedEventEntity::class,
V2xMessageEntity::class,
],
version = 2,
version = 3,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
@@ -33,7 +34,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"micr0bu.db",
)
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
.also { INSTANCE = it }
}
@@ -83,5 +84,37 @@ abstract class AppDatabase : RoomDatabase() {
)
}
}
/**
* Adds the `v2x_messages` table (Phase 03) — CAM retention for the duration of a
* recording session, see [V2xMessageEntity]'s KDoc for the retention policy.
*/
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `v2x_messages` (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`tripId` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
`isOwn` INTEGER NOT NULL,
`stationId` INTEGER NOT NULL,
`stationType` INTEGER NOT NULL,
`latitude` REAL NOT NULL,
`longitude` REAL NOT NULL,
`speedMps` REAL NOT NULL,
`headingDeg` REAL NOT NULL,
`yawRateDps` REAL,
FOREIGN KEY(`tripId`) REFERENCES `trips`(`id`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_v2x_messages_tripId` " +
"ON `v2x_messages` (`tripId`)"
)
}
}
}
}
@@ -37,4 +37,15 @@ interface TripDao {
@Query("SELECT COUNT(*) FROM detected_events WHERE tripId = :tripId")
suspend fun getEventCountForTrip(tripId: Long): Int
// ── V2X messages (Phase 03) ──────────────────────────────────────────────────
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertV2xMessage(message: V2xMessageEntity)
@Query("SELECT * FROM v2x_messages WHERE tripId = :tripId ORDER BY timestamp ASC")
fun getV2xMessagesForTrip(tripId: Long): Flow<List<V2xMessageEntity>>
@Query("SELECT COUNT(*) FROM v2x_messages WHERE tripId = :tripId")
suspend fun getV2xMessageCountForTrip(tripId: Long): Int
}
@@ -0,0 +1,51 @@
package com.hawhamburg.micr0bu.data.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
/**
* One CAM processed by [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository] (own or remote),
* persisted for the duration of an active recording session only.
*
* Retention policy (explicit user requirement, not derived from the requirements doc): while a
* trip is recording, every V2X message the detection engine sees is kept — the ride is the
* source of truth for later analysis, so nothing here should be silently dropped for storage
* reasons. Outside of a recording session, nothing is written to this table at all; the
* detection engine's own bounded in-memory history (see `UseCaseDetectionEngine.remoteHistory`)
* is the only thing tracking recent CAMs, and it can (and does) safely drop old samples once
* memory/relevance bounds are hit — there's no trip to correlate that data with anyway.
*/
@Entity(
tableName = "v2x_messages",
foreignKeys = [
ForeignKey(
entity = RecordedTripEntity::class,
parentColumns = ["id"],
childColumns = ["tripId"],
onDelete = ForeignKey.CASCADE,
)
],
)
data class V2xMessageEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(index = true)
val tripId: Long,
/** Wall-clock epoch ms this CAM was processed. */
val timestamp: Long,
/** True if this was the ego micrOBU's own outgoing CAM, false if a remote road user's. */
val isOwn: Boolean,
val stationId: Long,
val stationType: Int,
val latitude: Double,
val longitude: Double,
val speedMps: Double,
val headingDeg: Double,
val yawRateDps: Double?,
)
@@ -1,5 +1,6 @@
package com.hawhamburg.micr0bu.data.mqtt
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.domain.denm.DENM_CTRL_TOPIC
@@ -16,6 +17,7 @@ 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.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -52,6 +54,7 @@ private val SUBSCRIBED_TOPICS = listOf(
class MqttRepository @Inject constructor(
private val prefs: MqttPreferences,
private val usbDetector: UsbNetworkDetector,
private val obuHardwarePrefs: ObuHardwarePreferences,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val clientId = "micr0bu-android-${UUID.randomUUID()}"
@@ -72,13 +75,25 @@ class MqttRepository @Inject constructor(
private val _topicMessages = MutableStateFlow<Map<String, List<MqttMessage>>>(emptyMap())
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = _topicMessages.asStateFlow()
/** Active transport derived from persisted prefs. */
val activeTransport: StateFlow<TransportType> = prefs.prefsFlow
.map { p ->
if (p.activeTransport == MqttPrefs.TRANSPORT_USB_C) TransportType.USB_C
else TransportType.WIFI
/** Which physical OBU (Section 13) is currently selected. */
val obuHardware: StateFlow<ObuHardware> = obuHardwarePrefs.obuHardwareFlow
.stateIn(scope, SharingStarted.Eagerly, ObuHardware.CIT_ONE)
/**
* Active transport derived from persisted prefs, overridden by the selected OBU hardware:
* ESP32-C5 always resolves to [TransportType.USB_SERIAL] (a UART link, no MQTT-over-tethering
* broker exists on that hardware) regardless of the CiT-One-specific USB-C/Wi-Fi toggle.
*/
val activeTransport: StateFlow<TransportType> = combine(
prefs.prefsFlow,
obuHardwarePrefs.obuHardwareFlow,
) { p, hardware ->
when {
hardware == ObuHardware.ESP32_C5 -> TransportType.USB_SERIAL
p.activeTransport == MqttPrefs.TRANSPORT_USB_C -> TransportType.USB_C
else -> TransportType.WIFI
}
.stateIn(scope, SharingStarted.Eagerly, TransportType.USB_C)
}.stateIn(scope, SharingStarted.Eagerly, TransportType.USB_C)
@Volatile private var activeClient: MqttAsyncClient? = null
private var connectJob: Job? = null
@@ -0,0 +1,36 @@
package com.hawhamburg.micr0bu.data.mqtt
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.obuHardwareDataStore by preferencesDataStore(name = "obu_hardware_prefs")
/**
* Persists which physical OBU (Section 13 / [ObuHardware]) the app is paired with. Defaults to
* [ObuHardware.CIT_ONE] — the hardware every existing feature (MQTT, DENM test trigger, CAM
* consumption) was built against.
*/
@Singleton
class ObuHardwarePreferences @Inject constructor(
@ApplicationContext private val context: Context,
) {
private object Keys {
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
}
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
ObuHardware.entries.firstOrNull { it.id == prefs[Keys.OBU_HARDWARE] } ?: ObuHardware.CIT_ONE
}
suspend fun setObuHardware(hardware: ObuHardware) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
}
}
@@ -0,0 +1,32 @@
package com.hawhamburg.micr0bu.data.transport
/**
* Which physical OBU the phone is paired with (Phase 03, requirements doc Section 13).
*
* The two hardware options differ in almost everything downstream: transport, CAM origin,
* and whether DENM triggering is available at all. See [com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences]
* for persistence and the Settings > Connection screen for the picker.
*/
enum class ObuHardware(val id: String) {
/**
* consider it CiT One — the primary Phase 01/02 hardware. Full V2X stack onboard: generates
* its own CAM autonomously, exposes an MQTT broker over USB-C tethering, supports DENM
* triggering via the Use Case API.
*/
CIT_ONE("cit_one"),
/**
* ESP32-C5 — a "dumb" V2X transceiver (Phase 03 second OBU option). Sends/receives raw
* ITS-G5 frames only on the phone's instruction; no onboard CAM generation, no MQTT broker,
* no DENM use-case engine. The phone does the work: builds CAM from its own GNSS/IMU, UPER-
* encodes it, and pushes it down a USB serial (UART) link — see
* [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport] and
* [com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder].
*
* **Placeholder hardware option as of this writing** — the actual frame protocol between
* phone and ESP32-C5 firmware is not yet defined (pending translation of the existing
* ESP32 C firmware's logic to the Kotlin side). UI/settings exist so the option is visible
* and selectable, but connecting will not yet do anything real.
*/
ESP32_C5("esp32_c5"),
}
@@ -0,0 +1,138 @@
package com.hawhamburg.micr0bu.data.transport
/**
* Binary framing for the phone <-> ESP32-C5 link (Phase 03). Kotlin counterpart of the
* firmware's `obu-firmware/main/serial_link.c`/`.h` — frame shape and CRC algorithm MUST stay
* bit-for-bit identical between the two, since neither side validates the other's version.
*
* Frame format (both directions, symmetric):
* `[0xAA][0x55][type:1][length:2 LE][payload: length bytes][crc16:2 LE]`
* CRC16 is CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout), computed over
* type + length + payload only (not the two sync bytes) — see [Crc16CcittFalse].
*/
object SerialFrameType {
/** Phone -> ESP32: raw CAM UPER bytes to GeoNetworking-wrap and transmit immediately. */
const val CAM_TX: Int = 0x01
/** ESP32 -> phone: payload is `[rssi: 1 signed][CAM UPER bytes...]`, already stripped of
* 802.11/LLC-SNAP/GeoNetworking/BTP-B framing by the firmware's `gn_unwrap.c`. */
const val CAM_RX: Int = 0x02
/** ESP32 -> phone: 1-byte heartbeat (0 = ok), independent of CAM traffic. */
const val STATUS: Int = 0x03
}
/** Max payload this link carries — matches `SERIAL_LINK_MAX_PAYLOAD` in the firmware. */
const val SERIAL_LINK_MAX_PAYLOAD = 160
private const val SYNC0: Byte = 0xAA.toByte()
private const val SYNC1: Byte = 0x55.toByte()
object Crc16CcittFalse {
/** MUST match the firmware's `crc16_ccitt_false` in `serial_link.c` byte-for-byte. */
fun compute(data: ByteArray, offset: Int = 0, length: Int = data.size - offset): Int {
var crc = 0xFFFF
for (i in offset until offset + length) {
crc = crc xor ((data[i].toInt() and 0xFF) shl 8)
repeat(8) {
crc = if (crc and 0x8000 != 0) ((crc shl 1) xor 0x1021) else (crc shl 1)
crc = crc and 0xFFFF
}
}
return crc
}
}
data class DecodedFrame(val type: Int, val payload: ByteArray)
object SerialFrameEncoder {
/** Builds a complete frame ready to write to the serial port. */
fun encode(type: Int, payload: ByteArray): ByteArray {
require(payload.size <= SERIAL_LINK_MAX_PAYLOAD) {
"payload too large for serial link (${payload.size} > $SERIAL_LINK_MAX_PAYLOAD)"
}
val head = byteArrayOf(type.toByte(), (payload.size and 0xFF).toByte(), ((payload.size shr 8) and 0xFF).toByte())
val crcInput = head + payload
val crc = Crc16CcittFalse.compute(crcInput)
val out = ByteArray(2 + crcInput.size + 2)
out[0] = SYNC0
out[1] = SYNC1
crcInput.copyInto(out, destinationOffset = 2)
out[out.size - 2] = (crc and 0xFF).toByte()
out[out.size - 1] = ((crc shr 8) and 0xFF).toByte()
return out
}
}
/**
* Stateful streaming decoder — feed it bytes as they arrive from the serial port (which may
* split or coalesce frames arbitrarily), and it emits [DecodedFrame]s as complete, checksummed
* frames are found. Mirrors the firmware's byte-at-a-time state machine in `serial_link.c`'s
* `rx_task` exactly (same states, same resync-on-mismatch behavior), just processing a whole
* chunk of newly-arrived bytes per call instead of one byte per loop iteration.
*
* Not thread-safe — feed bytes from a single reader coroutine/thread.
*/
class SerialFrameDecoder {
private enum class State { WAIT_SYNC0, WAIT_SYNC1, WAIT_TYPE, WAIT_LEN_LO, WAIT_LEN_HI, WAIT_PAYLOAD, WAIT_CRC_LO, WAIT_CRC_HI }
private var state = State.WAIT_SYNC0
private var type = 0
private var len = 0
private var payloadIdx = 0
private val payload = ByteArray(SERIAL_LINK_MAX_PAYLOAD)
private var crcRecv = 0
/** Feeds [count] new bytes from [data] (starting at [offset]) and returns any complete, valid frames found. */
fun onBytes(data: ByteArray, offset: Int = 0, count: Int = data.size - offset): List<DecodedFrame> {
val out = mutableListOf<DecodedFrame>()
for (i in offset until offset + count) {
val b = data[i].toInt() and 0xFF
when (state) {
State.WAIT_SYNC0 -> state = if (b == (SYNC0.toInt() and 0xFF)) State.WAIT_SYNC1 else State.WAIT_SYNC0
State.WAIT_SYNC1 -> state = when (b) {
SYNC1.toInt() and 0xFF -> State.WAIT_TYPE
SYNC0.toInt() and 0xFF -> State.WAIT_SYNC1
else -> State.WAIT_SYNC0
}
State.WAIT_TYPE -> {
type = b
state = State.WAIT_LEN_LO
}
State.WAIT_LEN_LO -> {
len = b
state = State.WAIT_LEN_HI
}
State.WAIT_LEN_HI -> {
len = len or (b shl 8)
state = when {
len > SERIAL_LINK_MAX_PAYLOAD -> State.WAIT_SYNC0 // can't trust the frame boundary - resync
len == 0 -> { payloadIdx = 0; State.WAIT_CRC_LO }
else -> { payloadIdx = 0; State.WAIT_PAYLOAD }
}
}
State.WAIT_PAYLOAD -> {
payload[payloadIdx++] = b.toByte()
if (payloadIdx >= len) state = State.WAIT_CRC_LO
}
State.WAIT_CRC_LO -> {
crcRecv = b
state = State.WAIT_CRC_HI
}
State.WAIT_CRC_HI -> {
crcRecv = crcRecv or (b shl 8)
val head = byteArrayOf(type.toByte(), (len and 0xFF).toByte(), ((len shr 8) and 0xFF).toByte())
val crcInput = head + payload.copyOf(len)
val crcCalc = Crc16CcittFalse.compute(crcInput)
if (crcCalc == crcRecv) {
out.add(DecodedFrame(type, payload.copyOf(len)))
}
// CRC mismatch: silently drop, matching firmware behavior - a corrupt frame
// on a link this fast recovers on its own within one beacon interval.
state = State.WAIT_SYNC0
}
}
}
return out
}
}
@@ -1,3 +1,10 @@
package com.hawhamburg.micr0bu.data.transport
enum class TransportType { USB_C, WIFI, BLUETOOTH }
/**
* [USB_C] — Android USB tethering (virtual Ethernet) to the CiT One's onboard MQTT broker.
* [USB_SERIAL] — direct UART link over USB-C to an ESP32-C5's serial port (Phase 03, no
* network/MQTT layer involved — see [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport]).
* [WIFI] — developer/legacy transport to a Wi-Fi-reachable MQTT broker.
* [BLUETOOTH] — planned production transport, not yet implemented (either OBU).
*/
enum class TransportType { USB_C, USB_SERIAL, WIFI, BLUETOOTH }
@@ -0,0 +1,206 @@
package com.hawhamburg.micr0bu.data.transport
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Build
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import com.hoho.android.usbserial.util.SerialInputOutputManager
import dagger.hilt.android.qualifiers.ApplicationContext
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 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"
/**
* UART connection handler for the ESP32-C5 (Phase 03 second OBU option, requirements doc
* Section 13). Unlike [UsbNetworkDetector] (CiT One, USB-C tethering → virtual Ethernet → MQTT
* broker), the ESP32-C5 has no MQTT broker or IP network at all — it's reachable only as a USB
* serial (UART) device, framed per [SerialFrameType] (see that file's KDoc — must stay
* bit-for-bit compatible with the firmware's `obu-firmware/main/serial_link.c`).
*
* Built on `com.github.mik3y:usb-serial-for-android`, which auto-detects CDC-ACM (what the
* ESP32-C5's native USB is expected to enumerate as) plus common USB-UART bridge chips as a
* fallback, so this doesn't need to hardcode a specific driver class.
*
* Baud rate is fixed at 115200 to match `SERIAL_LINK_BAUD` in the firmware — if that ever
* changes on the firmware side, [BAUD_RATE] here must change with it.
*/
@Singleton
class UsbSerialTransport @Inject constructor(
@ApplicationContext private val context: Context,
) {
companion object {
private const val BAUD_RATE = 115_200
}
private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED)
val state: StateFlow<UsbSerialState> = _state.asStateFlow()
private val _incomingFrames = MutableSharedFlow<DecodedFrame>(extraBufferCapacity = 256)
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
val incomingFrames: SharedFlow<DecodedFrame> = _incomingFrames.asSharedFlow()
private val decoder = SerialFrameDecoder()
@Volatile private var port: UsbSerialPort? = null
@Volatile private var ioManager: SerialInputOutputManager? = null
@Volatile private var pendingDevice: UsbDevice? = null
private val permissionReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action != ACTION_USB_PERMISSION) return
synchronized(this) {
val device: UsbDevice? = intent.getUsbDeviceExtra()
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (granted && device != null) {
openDevice(device)
} else {
_state.value = UsbSerialState.ERROR
}
}
}
}
private var receiverRegistered = false
/**
* Finds the first attached USB-serial-capable device, requests permission if needed, and
* opens it. Safe to call repeatedly (e.g. from a "retry" UI action) — no-ops if already
* connected.
*/
fun connect() {
if (_state.value == UsbSerialState.CONNECTED) return
ensureReceiverRegistered()
val availableDrivers: List<UsbSerialDriver> =
UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
val driver = availableDrivers.firstOrNull()
if (driver == null) {
_state.value = UsbSerialState.DISCONNECTED
return
}
val device = driver.device
_state.value = UsbSerialState.DEVICE_ATTACHED
if (usbManager.hasPermission(device)) {
openDevice(device)
} else {
pendingDevice = device
_state.value = UsbSerialState.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,
)
usbManager.requestPermission(device, permissionIntent)
}
}
private fun openDevice(device: UsbDevice) {
val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
if (driver == null || driver.ports.isEmpty()) {
_state.value = UsbSerialState.ERROR
return
}
val connection = usbManager.openDevice(device)
if (connection == null) {
_state.value = UsbSerialState.ERROR
return
}
val newPort = driver.ports[0]
try {
newPort.open(connection)
newPort.setParameters(BAUD_RATE, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
} catch (e: Exception) {
runCatching { newPort.close() }
_state.value = UsbSerialState.ERROR
return
}
port = newPort
val manager = SerialInputOutputManager(newPort, object : SerialInputOutputManager.Listener {
override fun onNewData(data: ByteArray) {
val frames = decoder.onBytes(data)
frames.forEach { _incomingFrames.tryEmit(it) }
}
override fun onRunError(e: Exception) {
_state.value = UsbSerialState.ERROR
}
})
ioManager = manager
// This version of the library manages its own background thread internally -
// SerialInputOutputManager.start()/stop() rather than the older pattern of the caller
// submitting it to an Executor/Thread as a Runnable (which this version's class no
// longer exposes for external use - see the two earlier compile errors this replaced).
manager.start()
_state.value = UsbSerialState.CONNECTED
}
/**
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
* treat that as "this beacon interval's CAM didn't go out," not a fatal error; the next one
* is only ~1s away and will retry naturally.
*/
fun sendCamTx(camUperBytes: ByteArray): Boolean {
val p = port ?: return false
return try {
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
p.write(frame, /* timeout ms */ 200)
true
} catch (e: Exception) {
_state.value = UsbSerialState.ERROR
false
}
}
fun disconnect() {
ioManager?.stop() // stops the manager's own internal background thread
ioManager = null
runCatching { port?.close() }
port = null
_state.value = UsbSerialState.DISCONNECTED
}
private fun ensureReceiverRegistered() {
if (receiverRegistered) return
val filter = IntentFilter(ACTION_USB_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(permissionReceiver, filter)
}
receiverRegistered = true
}
private fun Intent.getUsbDeviceExtra(): UsbDevice? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(UsbManager.EXTRA_DEVICE)
}
}
@@ -0,0 +1,42 @@
package com.hawhamburg.micr0bu.domain.asn1
import com.hawhamburg.micr0bu.domain.cam.Cam
import javax.inject.Inject
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
* 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).
*
* On the CiT One path this doesn't exist at all — that OBU encodes/decodes its own CAM/DENM
* onboard and only ever gives the phone already-parsed JSON over MQTT.
*
* The earlier open question ("does Kotlin have a UPER ASN.1 library for V2X") resolved to: no
* library needed. The ESP32-C5's own transmit firmware (`obu-firmware/main/cam.c`) already
* hand-builds CAM's UPER bitstream field-by-field rather than using a schema compiler, and that
* turned out to be the right reference to port directly — see [CamUperCodec], a bit-for-bit
* Kotlin port of that C function (plus a new decode direction the firmware never needed, since
* it was transmit-only). A schema-driven library (OSS Nokalva, Obj-Sys, `alexvoronov/
* geonetworking`) would only be worth revisiting if this project ever needs to encode/decode
* message types beyond CAM.
*/
interface Asn1UperCodec {
/** Encode a domain CAM into a UPER-encoded ETSI EN 302637-2 CAM byte frame. */
fun encodeCam(cam: Cam): ByteArray
/** Decode a UPER-encoded ETSI EN 302637-2 CAM byte frame into a domain CAM, or null if unparseable. */
fun decodeCam(frame: ByteArray, receivedAtEpochMs: Long): Cam?
}
/** [Asn1UperCodec] backed by [CamUperCodec]. Stateless — safe as a Hilt singleton. */
@Singleton
class RealAsn1UperCodec @Inject constructor() : Asn1UperCodec {
override fun encodeCam(cam: Cam): ByteArray = CamUperCodec.encode(cam)
override fun decodeCam(frame: ByteArray, receivedAtEpochMs: Long): Cam? =
CamUperCodec.decode(frame, receivedAtEpochMs)
}
@@ -0,0 +1,37 @@
package com.hawhamburg.micr0bu.domain.asn1
/**
* MSB-first bit reader — the decode-side inverse of [BitWriter]. No equivalent existed in the
* firmware (which only ever transmitted, never decoded CAM) — this is new code, but follows the
* exact same bit-order convention [BitWriter]/`cam.c`'s `bw_put_bits` uses, since it has to
* unpack what that packer (or the real firmware using the same layout) produced.
*/
class BitReader(private val data: ByteArray) {
private var bitPos = 0
/** True if at least [nbits] more bits remain. */
fun hasBits(nbits: Int): Boolean = bitPos + nbits <= data.size * 8
/**
* Reads [nbits] bits (MSB first) as an unsigned value in a Long. Throws
* [IndexOutOfBoundsException] if the buffer is exhausted — callers decoding a fixed,
* known-length CAM structure should treat that as "truncated/corrupt frame," same as any
* other malformed-input case.
*/
fun getBits(nbits: Int): Long {
var value = 0L
repeat(nbits) {
val byteIdx = bitPos / 8
if (byteIdx >= data.size) throw IndexOutOfBoundsException("BitReader: buffer exhausted")
val bitIdx = 7 - (bitPos % 8)
val bit = (data[byteIdx].toInt() ushr bitIdx) and 1
value = (value shl 1) or bit.toLong()
bitPos++
}
return value
}
fun getBitsInt(nbits: Int): Int = getBits(nbits).toInt()
val bitPosition: Int get() = bitPos
}
@@ -0,0 +1,41 @@
package com.hawhamburg.micr0bu.domain.asn1
/**
* MSB-first bit packer — ASN.1 UPER is a bitstream, not a byte stream. Direct Kotlin port of
* `bitwriter_t` / `bw_put_bits` in the ESP32 firmware's `obu-firmware/main/cam.c`, kept
* bit-for-bit identical since [CamUperCodec] depends on matching that layout exactly for
* interop with the firmware's `gn_unwrap.c` / ESP32-side (de facto reference) understanding of
* the wire format.
*
* Not thread-safe; one instance per encode call.
*/
class BitWriter(private val maxBytes: Int) {
private val buf = ByteArray(maxBytes)
private var bitPos = 0
/**
* Writes the low [nbits] bits of [value], MSB first. Silently stops writing (rather than
* throwing) once [maxBytes] is exhausted — mirrors the firmware's overflow guard; callers
* that care should check [byteLength] against their expected size afterward, same as
* `cam_encode`'s caller checks its return value.
*/
fun putBits(value: Long, nbits: Int) {
for (i in nbits - 1 downTo 0) {
val byteIdx = bitPos / 8
if (byteIdx >= maxBytes) return // overflow guard, matches bw_put_bits
val bitIdx = 7 - (bitPos % 8)
val bit = (value ushr i) and 1L
buf[byteIdx] = (buf[byteIdx].toInt() or (bit.toInt() shl bitIdx)).toByte()
bitPos++
}
}
/** Convenience for callers passing an Int/UInt-range value. */
fun putBits(value: Int, nbits: Int) = putBits(value.toLong() and 0xFFFFFFFFL, nbits)
/** Number of whole bytes written so far, rounding up a partial final byte (like `bw_byte_len`). */
val byteLength: Int get() = (bitPos + 7) / 8
/** Returns the written bytes, trimmed to [byteLength]. */
fun toByteArray(): ByteArray = buf.copyOf(byteLength)
}
@@ -0,0 +1,260 @@
package com.hawhamburg.micr0bu.domain.asn1
import com.hawhamburg.micr0bu.domain.cam.Cam
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/**
* Real ASN.1 UPER encoder/decoder for CAM (ETSI EN 302637-2 v1.4.1 CAM-PDU-Descriptions +
* TS 102894-2 v1.3.1 ITS-Container), covering exactly the field set the ESP32 firmware's
* `obu-firmware/main/cam.c` transmits — [encode] is a bit-for-bit port of that C function (same
* field order, same bit widths, same "unavailable" sentinel values), so a real ITS-G5 receiver
* that understood the firmware's old locally-built frames understands these too. [decode] is
* new (the firmware never decoded CAM — it only ever beaconed a bench-location test frame), but
* follows the identical layout in reverse.
*
* Unlike `cam.c` — which hardcoded every vehicle-dynamics field to its ASN.1 "unavailable"
* value because it had no real sensors wired in — this encodes real values wherever the phone
* actually has them ([Cam.yawRateDps], [Cam.driveDirection], [Cam.vehicleLengthM]/[vehicleWidthM],
* [Cam.accelerationMps2]), falling back to the same sentinels only when a field is null. This is
* strictly more complete than the firmware reference, not a deviation from it — the wire format
* has always supported these fields, the old firmware just never had data to put in them.
*
* Not yet covered: PosConfidenceEllipse / AltitudeConfidence / HeadingConfidence /
* SpeedConfidence / CurvatureValue / CurvatureCalculationMode are all still encoded as
* "unavailable," same as `cam.c` — none of that is derivable from what [Cam] carries today.
* `CurvatureValue` in particular *could* be derived from yaw rate ÷ speed, but that's unstable
* at low speed and deliberately left as a follow-up rather than guessed at here.
*/
object CamUperCodec {
/** Encode buffer size — matches `cam.c`'s `cam_payload[96]`, the known-sufficient size. */
private const val ENCODE_BUFFER_BYTES = 96
// TimestampIts epoch: 2004-01-01T00:00:00Z, in Unix epoch milliseconds.
private const val TS_ITS_EPOCH_MS = 1_072_915_200_000L
// ASN.1 "unavailable" sentinel values, straight from the CAM/ITS-Container modules (also
// documented inline in cam.c against each field).
private const val HEADING_UNAVAILABLE = 3601
private const val SPEED_MAX = 16382 // 16383 is the type's own unavailable value; stay under it
private const val DRIVE_DIRECTION_UNAVAILABLE = 2
private const val VEHICLE_LENGTH_UNAVAILABLE = 1023
private const val VEHICLE_WIDTH_UNAVAILABLE = 62
private const val ACCEL_UNAVAILABLE = 161
private const val YAW_RATE_UNAVAILABLE = 32767
/** Converts a wall-clock epoch-ms timestamp to a UPER GenerationDeltaTime (TimestampIts mod 65536). */
fun generationDeltaTime(epochMs: Long): Int {
val itsMs = epochMs - TS_ITS_EPOCH_MS
// floorMod so this stays well-defined even for epochMs before the ITS epoch (shouldn't
// happen with a real clock, but avoids a negative/UB result if it ever does).
return Math.floorMod(itsMs, 65536L).toInt()
}
/**
* Encodes [cam] as a UPER CAM byte string. [cam.timestamp] is used as the wall-clock source
* for GenerationDeltaTime — pass the actual moment this CAM is being transmitted, not some
* earlier sample time, since GenerationDeltaTime is defined relative to transmission.
*/
fun encode(cam: Cam): ByteArray {
val bw = BitWriter(ENCODE_BUFFER_BYTES)
// ---- ItsPduHeader ----
bw.putBits(2, 8) // protocolVersion = 2
bw.putBits(2, 8) // messageID = cam(2)
bw.putBits(cam.stationId, 32) // stationID (low 32 bits if stationId is wider)
// ---- CoopAwareness ----
bw.putBits(generationDeltaTime(cam.timestamp), 16)
// ---- CamParameters ---- extension(0), lowFrequencyContainer present(1), specialVehicleContainer absent(0)
bw.putBits(0, 1)
bw.putBits(1, 1)
bw.putBits(0, 1)
// ---- BasicContainer ---- extension(0)
bw.putBits(0, 1)
bw.putBits(cam.stationType, 8)
// ReferencePosition
val latOffset = (cam.latitude * 1e7).roundToLong() - (-900000000L)
bw.putBits(latOffset, 31)
val lonOffset = (cam.longitude * 1e7).roundToLong() - (-1800000000L)
bw.putBits(lonOffset, 32)
bw.putBits(4095, 12) // semiMajorConfidence: unavailable
bw.putBits(4095, 12) // semiMinorConfidence: unavailable
bw.putBits(3601, 12) // semiMajorOrientation: unavailable
bw.putBits(900001, 20) // altitudeValue: unavailable
bw.putBits(15, 4) // altitudeConfidence: unavailable
// ---- HighFrequencyContainer CHOICE ---- extension(0), index 0 = basicVehicleContainerHighFrequency
bw.putBits(0, 1)
bw.putBits(0, 1)
bw.putBits(0, 7) // 7 optional-presence bits, all absent
val headingDdeg = if (cam.headingDeg.isFinite()) {
(Math.floorMod((cam.headingDeg * 10.0).roundToInt(), 3600))
} else {
HEADING_UNAVAILABLE
}
bw.putBits(headingDdeg, 12)
bw.putBits(126, 7) // headingConfidence: unavailable
val speedCmS = (cam.speedMps * 100.0).roundToInt().coerceIn(0, SPEED_MAX)
bw.putBits(speedCmS, 14)
bw.putBits(126, 7) // speedConfidence: unavailable
val driveDirection = cam.driveDirection?.coerceIn(0, 1) ?: DRIVE_DIRECTION_UNAVAILABLE
bw.putBits(driveDirection, 2)
val vehicleLengthDm = cam.vehicleLengthM
?.let { (it * 10.0).roundToInt().coerceIn(1, 1023) }
?: VEHICLE_LENGTH_UNAVAILABLE
bw.putBits(vehicleLengthDm - 1, 10)
bw.putBits(4, 3) // vehicleLengthConfidenceIndication: unavailable
val vehicleWidthDm = cam.vehicleWidthM
?.let { (it * 10.0).roundToInt().coerceIn(1, 62) }
?: VEHICLE_WIDTH_UNAVAILABLE
bw.putBits(vehicleWidthDm - 1, 6)
val accelTenths = cam.accelerationMps2
?.let { (it * 10.0).roundToInt().coerceIn(-160, 160) }
?: ACCEL_UNAVAILABLE
bw.putBits(accelTenths - (-160), 9)
bw.putBits(102, 7) // longitudinalAccelerationConfidence: unavailable
bw.putBits(1023 - (-1023), 11) // curvatureValue: unavailable (not derived - see class KDoc)
bw.putBits(7, 3) // curvatureConfidence: unavailable
bw.putBits(2, 2) // curvatureCalculationMode: unavailable
val yawRateCentiDegS = cam.yawRateDps
?.let { (it * 100.0).roundToInt().coerceIn(-32766, 32766) }
?: YAW_RATE_UNAVAILABLE
bw.putBits(yawRateCentiDegS - (-32766), 16)
bw.putBits(7, 3) // yawRateConfidence: unavailable
// ---- LowFrequencyContainer CHOICE ---- extension(0) -> basicVehicleContainerLowFrequency
bw.putBits(0, 1)
bw.putBits(0, 4) // vehicleRole: default(0)
bw.putBits(0, 8) // exteriorLights: all off
bw.putBits(0, 6) // pathHistory: empty
return bw.toByteArray()
}
/**
* Decodes a UPER CAM byte string into a domain [Cam] (always `isOwn = false` — this is only
* used for CAMs received from other stations; the ego's own CAM never round-trips through
* this). Returns null if the bytes aren't a CAM this codec understands: wrong
* protocolVersion/messageID, a CamParameters/HighFrequencyContainer/LowFrequencyContainer
* shape we don't decode (extension in use, RSU container instead of vehicle, or a
* specialVehicleContainer present — none of those are things this project transmits or
* currently needs to receive), or a truncated frame.
*
* [receivedAtEpochMs] becomes [Cam.timestamp] (wall-clock receipt time) — GenerationDeltaTime
* alone (a value mod 65536 ms) isn't enough on its own to reconstruct an absolute timestamp
* without also knowing which 65.536s window it falls in, so local receipt time is used
* instead, same convention the rest of this app's Cam pipeline already relies on.
*/
fun decode(bytes: ByteArray, receivedAtEpochMs: Long): Cam? {
return try {
decodeOrThrow(bytes, receivedAtEpochMs)
} catch (e: IndexOutOfBoundsException) {
null // truncated frame
}
}
private fun decodeOrThrow(bytes: ByteArray, receivedAtEpochMs: Long): Cam? {
val br = BitReader(bytes)
val protocolVersion = br.getBitsInt(8)
val messageId = br.getBitsInt(8)
if (protocolVersion != 2 || messageId != 2) return null // not a CAM we recognize
val stationId = br.getBits(32)
br.getBits(16) // generationDeltaTime - not used, we timestamp on receipt instead
val camParamsExt = br.getBitsInt(1)
if (camParamsExt != 0) return null // extension in use - unsupported shape
val lowFreqPresent = br.getBitsInt(1) == 1
val specialVehiclePresent = br.getBitsInt(1) == 1
if (specialVehiclePresent) return null // different container shape we don't parse
val basicContainerExt = br.getBitsInt(1)
if (basicContainerExt != 0) return null
val stationType = br.getBitsInt(8)
val latOffset = br.getBits(31)
val latitude = (latOffset + (-900000000L)) / 1e7
val lonOffset = br.getBits(32)
val longitude = (lonOffset + (-1800000000L)) / 1e7
br.getBits(12) // semiMajorConfidence
br.getBits(12) // semiMinorConfidence
br.getBits(12) // semiMajorOrientation
br.getBits(20) // altitudeValue
br.getBits(4) // altitudeConfidence
val highFreqExt = br.getBitsInt(1)
val highFreqIndex = br.getBitsInt(1)
if (highFreqExt != 0 || highFreqIndex != 0) return null // extension, or rsuContainerHighFrequency
br.getBits(7) // 7 optional-presence bits
val headingRaw = br.getBitsInt(12)
br.getBits(7) // headingConfidence
val headingDeg = headingRaw / 10.0
val speedRaw = br.getBitsInt(14)
br.getBits(7) // speedConfidence
val speedMps = speedRaw / 100.0
val driveDirectionRaw = br.getBitsInt(2)
val driveDirection = if (driveDirectionRaw == DRIVE_DIRECTION_UNAVAILABLE) null else driveDirectionRaw
val vehicleLengthRaw = br.getBitsInt(10) + 1
br.getBits(3) // vehicleLengthConfidenceIndication
val vehicleLengthM = if (vehicleLengthRaw == VEHICLE_LENGTH_UNAVAILABLE) null else vehicleLengthRaw / 10.0
val vehicleWidthRaw = br.getBitsInt(6) + 1
val vehicleWidthM = if (vehicleWidthRaw == VEHICLE_WIDTH_UNAVAILABLE) null else vehicleWidthRaw / 10.0
val accelRaw = br.getBitsInt(9) + (-160)
br.getBits(7) // longitudinalAccelerationConfidence
val accelerationMps2 = if (accelRaw == ACCEL_UNAVAILABLE) null else accelRaw / 10.0
br.getBits(11) // curvatureValue
br.getBits(3) // curvatureConfidence
br.getBits(2) // curvatureCalculationMode
val yawRateRaw = br.getBitsInt(16) + (-32766)
br.getBits(3) // yawRateConfidence
val yawRateDps = if (yawRateRaw == YAW_RATE_UNAVAILABLE) null else yawRateRaw / 100.0
if (lowFreqPresent) {
val lowFreqExt = br.getBitsInt(1)
if (lowFreqExt != 0) return null
br.getBits(4) // vehicleRole
br.getBits(8) // exteriorLights
br.getBits(6) // pathHistory count (0..40) - not decoded into path points, just consumed
}
return Cam(
stationId = stationId,
stationType = stationType,
latitude = latitude,
longitude = longitude,
speedMps = speedMps,
headingDeg = headingDeg,
yawRateDps = yawRateDps,
driveDirection = driveDirection,
vehicleLengthM = vehicleLengthM,
vehicleWidthM = vehicleWidthM,
accelerationMps2 = accelerationMps2,
timestamp = receivedAtEpochMs,
isOwn = false,
)
}
}
@@ -0,0 +1,43 @@
package com.hawhamburg.micr0bu.domain.cam
/**
* A geofenced area where CAM transmit rate should increase above the base rate — e.g. a known
* signalized intersection where VRU-vehicle conflicts are more likely (Phase 03, Section 13).
*
* **Placeholder values.** Exact radius and elevated rate are explicitly "still to be tuned"
* per the user's Phase 03 spec — these are initial engineering estimates only, matching the
* disclaimer pattern already used for [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig].
*/
data class CamGeofence(
val label: String,
val latitude: Double,
val longitude: Double,
val radiusM: Double = 60.0,
)
/**
* CAM transmit-rate policy for the phone-generated CAM path (ESP32-C5 hardware only — see
* [PhoneCamBuilder]). The CiT One generates and rates its own CAM autonomously onboard; this
* config has no effect on that path.
*
* Requirements (Section 13): 1 Hz base rate, only while a recording session is active; rate
* increases inside known high-risk geofenced areas. Exact elevated rate/radius are not yet
* tuned — [elevatedRateHz] and each [CamGeofence.radiusM] are placeholders.
*/
data class CamTransmitConfig(
/** Base transmit rate, Hz, used everywhere outside a geofence. */
val baseRateHz: Double = 1.0,
/** Elevated transmit rate, Hz, used inside a [geofences] entry. Not yet tuned. */
val elevatedRateHz: Double = 4.0,
/** Known high-risk areas (e.g. signalized intersections) where [elevatedRateHz] applies. */
val geofences: List<CamGeofence> = emptyList(),
/**
* CAM transmission only runs while a recording session is active (Section 13) — this is
* not a rate knob, it's a hard on/off gate enforced by whatever wires [PhoneCamBuilder]
* into [com.hawhamburg.micr0bu.service.TripRecordingService].
*/
val activeOnlyDuringRecording: Boolean = true,
)
@@ -0,0 +1,71 @@
package com.hawhamburg.micr0bu.domain.cam
import com.hawhamburg.micr0bu.data.GnssReading
import kotlin.math.abs
/**
* Builds an outgoing [Cam] from the phone's own GNSS + gyroscope, for the ESP32-C5 hardware
* path (Phase 03, Section 13) where the OBU itself generates no CAM at all — the phone must.
*
* This class only does the sensor-fusion-into-CAM-fields part, which is independent of the
* (not yet defined) wire protocol to the ESP32-C5. It is **not yet wired into any transmit
* pipeline** — nothing calls this today. Once the ESP32 firmware protocol is translated to
* Kotlin and [com.hawhamburg.micr0bu.domain.asn1.Asn1UperCodec] has a real implementation, the
* intended flow is:
*
* `PhoneCamBuilder.build(...)` → `Asn1UperCodec.encodeCam(...)` → `UsbSerialTransport` (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
* upright) rather than from GNSS heading deltas, which are noisy at low speed — same rationale
* already used for remote-vehicle turn detection in [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionEngine].
*/
object PhoneCamBuilder {
/** Placeholder station ID until real station-ID assignment/config exists for this path. */
private const val PLACEHOLDER_OWN_STATION_ID = 0L
/**
* @param gnss latest phone GNSS fix.
* @param gyroZRadPerSec latest gyroscope z-axis reading, rad/s (device frame). Positive per
* Android's convention is counter-clockwise around +Z; converted to the clockwise-positive
* yaw rate convention already used by [Cam.yawRateDps] to match OBU/remote CAM data.
* @param stationId this device's own station ID. Defaults to a placeholder until Phase 03
* defines how the phone learns/assigns an ID on the ESP32-C5 path (the CiT One path
* currently learns this from `v2x/rx/obu_gnss`'s own_info, which doesn't exist here).
*/
fun build(
gnss: GnssReading,
gyroZRadPerSec: Float?,
stationId: Long = PLACEHOLDER_OWN_STATION_ID,
): Cam {
val yawRateDps = gyroZRadPerSec?.let { -it * RAD_TO_DEG } // negate: CCW+ -> CW+ convention
return Cam(
stationId = stationId,
stationType = StationType.CYCLIST,
latitude = gnss.latitude,
longitude = gnss.longitude,
speedMps = gnss.speedMs.toDouble(),
headingDeg = normalizeHeading(gnss.bearingDeg.toDouble()),
yawRateDps = yawRateDps?.let { if (abs(it) < YAW_RATE_NOISE_FLOOR_DPS) 0.0 else it },
timestamp = gnss.timestamp,
isOwn = true,
)
}
private fun normalizeHeading(deg: Double): Double {
var h = deg % 360.0
if (h < 0) h += 360.0
return h
}
private const val RAD_TO_DEG = 180.0 / Math.PI
/**
* Gyro noise floor below which yaw rate is clamped to zero. Placeholder — initial
* engineering estimate pending real-world tuning, same disclaimer as
* [com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig].
*/
private const val YAW_RATE_NOISE_FLOOR_DPS = 1.0
}
@@ -49,6 +49,17 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
/** Currently active alerts across all remote road users, most severe first. */
val currentAlerts: StateFlow<List<UseCaseAlert>> = _currentAlerts.asStateFlow()
// ── Live positions (Section 13 — V2X Monitor live map view) ──────────────────────────
// Exposed purely for the map view; detection logic above never reads these back.
private val _ownPosition = MutableStateFlow<Cam?>(null)
/** Ego bike's latest known state, for plotting on the live map. */
val ownPosition: StateFlow<Cam?> = _ownPosition.asStateFlow()
private val _remotePositions = MutableStateFlow<Map<Long, Cam>>(emptyMap())
/** Each tracked remote road user's latest known CAM, keyed by station ID, for the live map. */
val remotePositions: StateFlow<Map<Long, Cam>> = _remotePositions.asStateFlow()
/**
* Feed the ego bike's own most recent state (from `v2x/rx/obu_gnss`, or a fallback source
* — see `CamUseCaseRepository`). Out-of-order/late updates are ignored. Re-evaluates all
@@ -58,6 +69,7 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
val current = ownCam
if (current != null && cam.timestamp < current.timestamp) return // stale/out-of-order
ownCam = cam
_ownPosition.value = cam
remoteHistory.keys.toList().forEach { id -> remoteHistory[id]?.lastOrNull()?.let { evaluate(it) } }
publish()
}
@@ -70,6 +82,7 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
val history = remoteHistory.getOrPut(cam.stationId) { ArrayDeque() }
history.addLast(cam)
trimHistory(history, cam.timestamp)
_remotePositions.value = _remotePositions.value + (cam.stationId to cam)
evaluate(cam)
publish()
}
@@ -97,6 +110,7 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
remoteHistory.remove(id)
activeAlerts.keys.filter { it.first == id }.forEach { activeAlerts.remove(it) }
}
_remotePositions.value = _remotePositions.value - staleIds
publish()
}
@@ -105,6 +119,8 @@ class UseCaseDetectionEngine(private val config: UseCaseDetectionConfig = UseCas
ownCam = null
remoteHistory.clear()
activeAlerts.clear()
_ownPosition.value = null
_remotePositions.value = emptyMap()
publish()
}
@@ -0,0 +1,124 @@
package com.hawhamburg.micr0bu.service
import android.content.Context
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
import com.hawhamburg.micr0bu.domain.cam.CamTransmitConfig
import com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Real CAM transmit loop for the ESP32-C5 hardware path (Phase 03, Section 13) — the phone-side
* counterpart to the OBU's old autonomous beacon, now driven from here since the ESP32-C5 has
* no onboard CAM generation at all (see `obu-firmware/main/main.c`'s rewritten TX path, which
* is purely receive-and-transmit-on-serial-arrival with no timer of its own).
*
* Started/stopped by [TripRecordingService] around an active recording session — per the
* Section 13 spec, CAM transmission only runs while recording, matching the CiT One path's
* behavior of "no traffic until there's a trip to correlate it with." Internally also gated on
* [ObuHardwarePreferences] currently reporting [ObuHardware.ESP32_C5] — on the CiT One path
* this loop stays parked (via [kotlinx.coroutines.flow.collectLatest] on the hardware
* preference) and never sends anything.
*
* Rate policy: [CamTransmitConfig.baseRateHz] (1 Hz) everywhere, bumped to
* [CamTransmitConfig.elevatedRateHz] inside a [com.hawhamburg.micr0bu.domain.cam.CamGeofence] or
* for [ELEVATED_HOLD_MS] after an external event trigger (harsh braking/turning/stopping — see
* [onDetectedEvent], called by [TripRecordingService] from the same
* [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.
*/
@Singleton
class CamTransmitLoop @Inject constructor(
@ApplicationContext private val context: Context,
private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport,
private val codec: RealAsn1UperCodec,
) {
private val config = CamTransmitConfig()
private val sensorRepository = SensorRepository(context)
private var job: Job? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@Volatile private var latestGnss: GnssReading? = null
@Volatile private var latestGyroZ: Float? = null
@Volatile private var elevatedUntilMs: Long = 0L
/** Own station id for the ESP32-C5 path — see [PhoneCamBuilder]'s KDoc on why this is a placeholder. */
@Volatile var stationId: Long = 0L
/**
* 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
* denser updates through the maneuver, not just at the instant it was detected.
*/
fun onDetectedEvent() {
elevatedUntilMs = System.currentTimeMillis() + ELEVATED_HOLD_MS
}
/**
* Starts the loop for the duration of a recording session. Internally stays idle (no
* transmission) unless/until the ESP32-C5 is the selected OBU hardware, and automatically
* pauses/resumes if that selection changes mid-trip.
*/
fun start() {
if (job?.isActive == true) return
elevatedUntilMs = 0L
job = scope.launch {
obuHardwarePrefs.obuHardwareFlow.collectLatest { hardware ->
if (hardware != ObuHardware.ESP32_C5) return@collectLatest
runTransmitLoop()
}
}
}
fun stop() {
job?.cancel()
job = null
elevatedUntilMs = 0L
}
private suspend fun runTransmitLoop() = coroutineScope {
launch { sensorRepository.gnssFlow().collect { latestGnss = it } }
launch { sensorRepository.gyroscopeFlow().collect { latestGyroZ = it.z } }
while (true) {
val gnss = latestGnss
if (gnss != null) {
val cam = PhoneCamBuilder.build(gnss, latestGyroZ, stationId)
val bytes = codec.encodeCam(cam)
usbSerialTransport.sendCamTx(bytes)
}
delay((1000.0 / currentRateHz(gnss)).toLong())
}
}
private fun currentRateHz(gnss: GnssReading?): Double {
val now = System.currentTimeMillis()
val inGeofence = gnss != null && config.geofences.any { fence ->
GeoMath.haversineMeters(gnss.latitude, gnss.longitude, fence.latitude, fence.longitude) <= fence.radiusM
}
val eventBoosted = now < elevatedUntilMs
return if (inGeofence || eventBoosted) config.elevatedRateHz else config.baseRateHz
}
companion object {
private const val ELEVATED_HOLD_MS = 5_000L
}
}
@@ -24,11 +24,13 @@ import com.google.android.gms.location.Priority
import com.hawhamburg.micr0bu.MainActivity
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.TripRepository
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
import com.hawhamburg.micr0bu.data.db.AppDatabase
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
import com.hawhamburg.micr0bu.domain.detection.EventDetector
import com.hawhamburg.micr0bu.domain.detection.EventType
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -88,6 +90,17 @@ class TripRecordingService : Service() {
private lateinit var sensorManager: SensorManager
private lateinit var fusedLocation: FusedLocationProviderClient
private lateinit var repository: TripRepository
// ESP32-C5 CAM transmit loop (Phase 03/Section 13) - internally a no-op unless that
// hardware is the one currently selected (see CamTransmitLoop's KDoc). Started/stopped
// alongside the trip, same lifecycle as everything else in this service.
@Inject lateinit var camTransmitLoop: CamTransmitLoop
// V2X message retention (Phase 03) - every CAM this repository processes (own + remote,
// either hardware path) gets persisted for the duration of a recording session; see
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
private var v2xLoggingJob: Job? = null
private val detector = EventDetector(
DetectionConfig(
brakingSpeedDropThreshold = 1.0,
@@ -266,6 +279,9 @@ class TripRecordingService : Service() {
detector.events.collect { event ->
if (currentTripId < 0) return@collect
repository.insertEvent(currentTripId, event)
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
camTransmitLoop.onDetectedEvent()
when (event.type) {
EventType.BRAKING -> brakingCount++
EventType.TURNING -> turningCount++
@@ -296,6 +312,19 @@ class TripRecordingService : Service() {
// Register sensors
registerSensors()
requestLocationUpdates()
camTransmitLoop.start()
// V2X message retention — every CAM processed while this trip is recording gets
// persisted, unbounded, regardless of hardware path (MQTT/CiT One or serial/ESP32-C5).
// Explicit user requirement: outside of a recording session, this collector doesn't
// run at all, so nothing is retained beyond the detection engine's own bounded
// in-memory history.
v2xLoggingJob = serviceScope.launch {
camUseCaseRepository.processedCam.collect { cam ->
if (currentTripId < 0) return@collect
repository.insertV2xMessage(currentTripId, cam)
}
}
// Start foreground ASAP (within 5 s required by Android).
// Specify FOREGROUND_SERVICE_TYPE_LOCATION so Android 10+ knows why we need
@@ -315,6 +344,9 @@ class TripRecordingService : Service() {
timerJob?.cancel()
unregisterSensors()
removeLocationUpdates()
camTransmitLoop.stop()
v2xLoggingJob?.cancel()
v2xLoggingJob = null
val endTime = System.currentTimeMillis()
val totalEvents = brakingCount + turningCount + stoppingCount
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
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.viewmodel.MqttViewModel
@@ -62,6 +63,7 @@ fun ConnectionSetupScreen(
val detectedObuIp by viewModel.detectedObuIp.collectAsState()
val activeTransport by viewModel.activeTransport.collectAsState()
val mqttPrefs by viewModel.mqttPrefs.collectAsState()
val obuHardware by viewModel.obuHardware.collectAsState()
val isConnected = connectionState == MqttConnectionState.CONNECTED
val isConnecting = connectionState == MqttConnectionState.CONNECTING
@@ -85,12 +87,13 @@ fun ConnectionSetupScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// ── USB-C Primary Card ─────────────────────────────────────────────
// ── USB-C Primary Card (CiT One only — see ESP32-C5 placeholder card below) ────────
val usbContainerColor = when {
usbConnected && isConnected -> UsbGreenBg
usbConnected && isConnecting -> UsbAmberBg
else -> UsbGrayBg
}
if (obuHardware == ObuHardware.CIT_ONE) {
Card(
colors = CardDefaults.cardColors(containerColor = usbContainerColor),
shape = RoundedCornerShape(12.dp),
@@ -222,6 +225,41 @@ fun ConnectionSetupScreen(
}
}
}
} else {
// ── ESP32-C5 placeholder card ───────────────────────────────────────
// Real USB-serial connection handling lives in UsbSerialTransport, which is a
// stub until the ESP32 firmware protocol is translated to Kotlin (Section 13).
Card(
colors = CardDefaults.cardColors(containerColor = UsbGrayBg),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Usb,
contentDescription = null,
tint = UsbGray,
modifier = Modifier.size(20.dp),
)
Text(
stringResource(R.string.conn_esp32_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = UsbGray,
)
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.conn_esp32_phase3_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -49,6 +50,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
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.viewmodel.SensorUiState
import kotlin.math.sqrt
@@ -59,12 +61,14 @@ fun DashboardScreen(
state: SensorUiState,
mqttConnectionState: MqttConnectionState,
activeTransport: TransportType = TransportType.USB_C,
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null,
onNavigateToConnection: () -> Unit,
onNavigateToSensors: () -> Unit,
onNavigateToMap: () -> Unit,
onNavigateToRecord: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -207,6 +211,23 @@ fun DashboardScreen(
}
}
// ── "Start Driving Session" shortcut (Section 13 dashboard redesign) — straight to
// Record. Hidden while already recording since the banner above covers that state. ──
if (!state.isRecording) {
androidx.compose.material3.Button(
onClick = onNavigateToRecord,
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Icon(Icons.Default.FiberManualRecord, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(
stringResource(R.string.dash_start_driving_session),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
}
// GNSS card — tapping opens the bottom sheet
StatusCard(
title = stringResource(R.string.dash_gnss),
@@ -236,11 +257,13 @@ fun DashboardScreen(
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
val transportIcon = 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) {
TransportType.USB_C -> Icons.Default.Usb
TransportType.USB_SERIAL -> Icons.Default.Usb
TransportType.WIFI -> Icons.Default.WifiOff
TransportType.BLUETOOTH -> Icons.Default.BluetoothDisabled
}
@@ -285,24 +308,34 @@ fun DashboardScreen(
}
}
Spacer(Modifier.height(10.dp))
// Transport chip row — USB-C / Wi-Fi / BT with active highlighted
// Transport chip row — which chips show depends on the selected OBU hardware,
// since CiT One and ESP32-C5 use non-overlapping transports (Section 13).
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TransportChip(
label = stringResource(R.string.dash_transport_usbc),
icon = Icons.Default.Usb,
active = activeTransport == TransportType.USB_C,
hasCable = usbCableConnected,
)
TransportChip(
label = stringResource(R.string.dash_transport_wifi),
icon = Icons.Default.Wifi,
active = activeTransport == TransportType.WIFI,
)
if (obuHardware == ObuHardware.CIT_ONE) {
TransportChip(
label = stringResource(R.string.dash_transport_usbc),
icon = Icons.Default.Usb,
active = activeTransport == TransportType.USB_C,
hasCable = usbCableConnected,
)
TransportChip(
label = stringResource(R.string.dash_transport_wifi),
icon = Icons.Default.Wifi,
active = activeTransport == TransportType.WIFI,
)
} else {
TransportChip(
label = stringResource(R.string.dash_transport_usb_serial),
icon = Icons.Default.Usb,
active = activeTransport == TransportType.USB_SERIAL,
hasCable = usbCableConnected,
)
}
TransportChip(
label = stringResource(R.string.dash_transport_bt),
icon = Icons.Default.Bluetooth,
active = activeTransport == TransportType.BLUETOOTH,
dimmed = true, // Phase 03 — not yet available
dimmed = true, // Phase 03 — production BT transport still under discussion
)
}
}
@@ -65,6 +65,7 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
@@ -78,6 +79,9 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** List (raw topics) vs Map (V2X live map, Section 13) toggle for [TopicListPane]. */
private enum class TopicViewMode { LIST, MAP }
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val ConnectedGreen = Color(0xFF4CAF50)
@@ -111,6 +115,9 @@ fun MqttTopicViewerScreen(
val activeDenmUseCase by viewModel.activeDenmUseCase.collectAsState()
val useCaseAlerts by viewModel.useCaseAlerts.collectAsState()
val ownStationId by viewModel.ownStationId.collectAsState()
val obuHardware by viewModel.obuHardware.collectAsState()
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
val remoteCamPositions by viewModel.remoteCamPositions.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith(
@@ -188,6 +195,9 @@ fun MqttTopicViewerScreen(
lastDenmPayload = lastDenmPayload,
activeDenmUseCase = activeDenmUseCase,
useCaseAlerts = useCaseAlerts,
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
ownCamPosition = ownCamPosition,
remoteCamPositions = remoteCamPositions,
onSelectTopic = { viewModel.selectTopic(it) },
onSendDenm = { viewModel.sendDenm() },
onStopDenm = { viewModel.stopDenm() },
@@ -213,11 +223,15 @@ private fun TopicListPane(
lastDenmPayload: String?,
activeDenmUseCase: String?,
useCaseAlerts: List<UseCaseAlert>,
showDenmTrigger: Boolean = true,
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
remoteCamPositions: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam> = emptyMap(),
onSelectTopic: (String) -> Unit,
onSendDenm: () -> Unit,
onStopDenm: () -> Unit,
) {
val isConnected = connectionState == MqttConnectionState.CONNECTED
var viewMode by rememberSaveable { mutableStateOf(TopicViewMode.LIST) }
Column(modifier = Modifier.fillMaxSize()) {
@@ -227,19 +241,52 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
// ── DENM TX Control card — manual/test trigger only, not use-case-driven ──
DenmTxCard(
isConnected = isConnected,
denmActive = denmActive,
lastDenmPayload = lastDenmPayload,
activeDenmUseCase = activeDenmUseCase,
onSendDenm = onSendDenm,
onStopDenm = onStopDenm,
)
// CiT-One-only: the OBU's Use Case API (v2x-uca/input/denmtrg) doesn't exist on the
// ESP32-C5 path, which has no onboard use-case engine (Section 13).
if (showDenmTrigger) {
DenmTxCard(
isConnected = isConnected,
denmActive = denmActive,
lastDenmPayload = lastDenmPayload,
activeDenmUseCase = activeDenmUseCase,
onSendDenm = onSendDenm,
onStopDenm = onStopDenm,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
// ── Topic rows ──────────────────────────────────────────────────────
if (topics.isEmpty()) {
// ── List / Map toggle — the raw topic list stays available either way (Section 13
// asks for the map "in addition to", not instead of, the topic list). ──────────────
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { viewMode = TopicViewMode.LIST },
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (viewMode == TopicViewMode.LIST) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.mqtt_view_list)) }
OutlinedButton(
onClick = { viewMode = TopicViewMode.MAP },
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (viewMode == TopicViewMode.MAP) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.mqtt_view_map)) }
}
// ── Topic rows / live map ─────────────────────────────────────────────
if (viewMode == TopicViewMode.MAP) {
V2xLiveMapView(
own = ownCamPosition,
remotes = remoteCamPositions,
alerts = useCaseAlerts,
modifier = Modifier.fillMaxSize(),
)
} else if (topics.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
@@ -47,6 +47,7 @@ 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.ObuHardware
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
@@ -162,13 +163,17 @@ private fun SubScreen(title: String, onBack: () -> Unit, content: @Composable ()
fun ConnectionSettingsScreen(
mqttPrefs: MqttPrefs,
onMqttPrefsChange: (MqttPrefs) -> Unit,
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
onObuHardwareChange: (ObuHardware) -> Unit = {},
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_connection), onBack) {
SectionCard {
// Active transport selector
// 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.
Text(
stringResource(R.string.settings_usb_transport),
stringResource(R.string.settings_obu_hardware),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
@@ -178,47 +183,91 @@ fun ConnectionSettingsScreen(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val isUsb = mqttPrefs.activeTransport == com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_USB_C
val isCitOne = obuHardware == ObuHardware.CIT_ONE
OutlinedButton(
onClick = { onMqttPrefsChange(mqttPrefs.copy(activeTransport = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_USB_C)) },
onClick = { onObuHardwareChange(ObuHardware.CIT_ONE) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (isUsb) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (isUsb) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
containerColor = if (isCitOne) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (isCitOne) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_transport_usbc), fontWeight = if (isUsb) FontWeight.Bold else FontWeight.Normal) }
) { Text(stringResource(R.string.settings_obu_hardware_cit_one), fontWeight = if (isCitOne) FontWeight.Bold else FontWeight.Normal) }
OutlinedButton(
onClick = { onMqttPrefsChange(mqttPrefs.copy(activeTransport = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_WIFI)) },
onClick = { onObuHardwareChange(ObuHardware.ESP32_C5) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (!isUsb) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (!isUsb) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
containerColor = if (!isCitOne) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (!isCitOne) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_transport_wifi), fontWeight = if (!isUsb) FontWeight.Bold else FontWeight.Normal) }
) { Text(stringResource(R.string.settings_obu_hardware_esp32), fontWeight = if (!isCitOne) FontWeight.Bold else FontWeight.Normal) }
}
if (obuHardware == ObuHardware.ESP32_C5) {
Text(
stringResource(R.string.settings_obu_hardware_esp32_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
SectionCard {
// USB-C auto-detect toggle
SettingToggleRow(
label = stringResource(R.string.settings_usb_auto_detect),
checked = mqttPrefs.usbAutoDetect,
onCheckedChange = { onMqttPrefsChange(mqttPrefs.copy(usbAutoDetect = it)) },
)
Divider()
// Manual IP override
Spacer(Modifier.height(4.dp))
var usbIp by remember { mutableStateOf(mqttPrefs.usbIpOverride) }
MqttTextField(
label = stringResource(R.string.settings_usb_manual_ip),
value = usbIp,
placeholder = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.DEFAULT_USB_IP,
keyboardType = KeyboardType.Uri,
onValueChange = { usbIp = it },
onFocusLost = { onMqttPrefsChange(mqttPrefs.copy(usbIpOverride = usbIp)) },
)
Spacer(Modifier.height(4.dp))
if (obuHardware == ObuHardware.CIT_ONE) {
SectionCard {
// Active transport selector
Text(
stringResource(R.string.settings_usb_transport),
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),
) {
val isUsb = mqttPrefs.activeTransport == com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_USB_C
OutlinedButton(
onClick = { onMqttPrefsChange(mqttPrefs.copy(activeTransport = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_USB_C)) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (isUsb) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (isUsb) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_transport_usbc), fontWeight = if (isUsb) FontWeight.Bold else FontWeight.Normal) }
OutlinedButton(
onClick = { onMqttPrefsChange(mqttPrefs.copy(activeTransport = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.TRANSPORT_WIFI)) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (!isUsb) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (!isUsb) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_transport_wifi), fontWeight = if (!isUsb) FontWeight.Bold else FontWeight.Normal) }
}
}
SectionCard {
// USB-C auto-detect toggle
SettingToggleRow(
label = stringResource(R.string.settings_usb_auto_detect),
checked = mqttPrefs.usbAutoDetect,
onCheckedChange = { onMqttPrefsChange(mqttPrefs.copy(usbAutoDetect = it)) },
)
Divider()
// Manual IP override
Spacer(Modifier.height(4.dp))
var usbIp by remember { mutableStateOf(mqttPrefs.usbIpOverride) }
MqttTextField(
label = stringResource(R.string.settings_usb_manual_ip),
value = usbIp,
placeholder = com.hawhamburg.micr0bu.data.mqtt.MqttPrefs.DEFAULT_USB_IP,
keyboardType = KeyboardType.Uri,
onValueChange = { usbIp = it },
onFocusLost = { onMqttPrefsChange(mqttPrefs.copy(usbIpOverride = usbIp)) },
)
Spacer(Modifier.height(4.dp))
}
}
}
}
@@ -0,0 +1,173 @@
package com.hawhamburg.micr0bu.ui.screens
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
/**
* V2X Monitor live map view (Phase 03, Section 13) — plots the ego bike's own position plus
* every currently-tracked remote road user's last-known CAM position, in addition to (not
* replacing) the raw topic list already on this screen. Reuses the same osmdroid pattern as
* [MapScreen]; unlike that screen, this one has no phone-GNSS-only fallback because [own] here
* always reflects whichever ego source [com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository]
* currently trusts (obu_gnss / phone GNSS / CAM-topic-own — see that class's KDoc).
*
* Remote markers are colored by that station's most severe active alert level, if any, so a
* glance at the map shows not just "who's nearby" but "who's a warning right now" — the same
* severity coloring already used by [UseCaseAlertPanel].
*/
@Composable
fun V2xLiveMapView(
own: Cam?,
remotes: Map<Long, Cam>,
alerts: List<UseCaseAlert>,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
if (own == null) {
NoFixPlaceholder(modifier)
return
}
val ownGeoPoint = remember(own.latitude, own.longitude) { GeoPoint(own.latitude, own.longitude) }
val alertByStation = remember(alerts) {
alerts.groupBy { it.remoteStationId }
.mapValues { (_, a) -> a.maxByOrNull { it.alertLevel.ordinal }?.alertLevel }
}
val mapViewRef = remember { mutableStateOf<MapView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapViewRef.value?.onResume()
Lifecycle.Event.ON_PAUSE -> mapViewRef.value?.onPause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapViewRef.value?.onDetach()
}
}
Column(modifier = modifier.fillMaxSize()) {
Text(
text = stringResource(R.string.v2x_map_remote_count, remotes.size),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
AndroidView(
factory = { ctx ->
initOsmForV2xMap(ctx)
MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
controller.setZoom(17.0)
controller.setCenter(ownGeoPoint)
mapViewRef.value = this
}
},
update = { mv ->
mv.overlays.clear()
// Own marker — distinct from remotes via a dedicated title prefix; osmdroid
// doesn't tint default pins per-instance without a custom drawable, so color
// differentiation for now relies on the title label shown on tap.
mv.overlays.add(
Marker(mv).apply {
position = ownGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = context.getString(R.string.v2x_map_own_label)
}
)
remotes.forEach { (stationId, cam) ->
val level = alertByStation[stationId]
val label = when (level) {
AlertLevel.WARNING -> context.getString(R.string.v2x_map_remote_warning, stationId)
AlertLevel.AWARENESS -> context.getString(R.string.v2x_map_remote_awareness, stationId)
AlertLevel.INFO -> context.getString(R.string.v2x_map_remote_info, stationId)
null -> context.getString(R.string.v2x_map_remote_plain, stationId)
}
mv.overlays.add(
Marker(mv).apply {
position = GeoPoint(cam.latitude, cam.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = label
}
)
}
mv.controller.animateTo(ownGeoPoint)
mv.invalidate()
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun NoFixPlaceholder(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.GpsOff,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.gnss_no_fix),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
private fun initOsmForV2xMap(context: Context) {
Configuration.getInstance().apply {
load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
userAgentValue = context.packageName
}
}
@@ -8,6 +8,8 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
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.domain.denm.DenmUseCase
@@ -30,6 +32,7 @@ class MqttViewModel @Inject constructor(
private val prefs: MqttPreferences,
private val usbDetector: UsbNetworkDetector,
private val camUseCaseRepository: CamUseCaseRepository,
private val obuHardwarePrefs: ObuHardwarePreferences,
) : ViewModel() {
// ── MQTT connection & messages ────────────────────────────────────────────
@@ -48,6 +51,13 @@ class MqttViewModel @Inject constructor(
val activeTransport: StateFlow<TransportType> = repo.activeTransport
/** Which physical OBU (Section 13) is currently selected — CiT One or ESP32-C5. */
val obuHardware: StateFlow<ObuHardware> = repo.obuHardware
fun setObuHardware(hardware: ObuHardware) {
viewModelScope.launch { obuHardwarePrefs.setObuHardware(hardware) }
}
/** True when a 192.168.42.x USB-C tethering network is detected. */
val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork
.map { it != null }
@@ -111,6 +121,12 @@ class MqttViewModel @Inject constructor(
/** Per-use-case enable/disable state (Settings > Use Case Alerts). */
val useCaseEnabledMap: StateFlow<Map<UseCaseType, Boolean>> = camUseCaseRepository.enabledMap
/** Ego bike's latest known position, for the V2X Monitor live map view (Section 13). */
val ownCamPosition: StateFlow<com.hawhamburg.micr0bu.domain.cam.Cam?> = camUseCaseRepository.ownPosition
/** Latest known CAM per tracked remote road user, for the live map view (Section 13). */
val remoteCamPositions: StateFlow<Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam>> = camUseCaseRepository.remotePositions
/** True if [stationId] is the ego OBU's own — used for OWN/REMOTE badges in the raw message list. */
fun isOwnStationId(stationId: Long): Boolean = camUseCaseRepository.isOwnStationId(stationId)