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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user