feat: CAM-based use case detection engine, decouple DENM from sensor triggers

Added:
- UseCaseDetectionEngine: IMA-B, IMA-S, RTW-B, LTW-B, SMVA/BCW-B via per-station
  CAM history, yaw-rate/heading-trend turn detection, Info/Awareness/Warning alerts
- Ego state from obu_gnss (~4Hz, yaw rate) with phone GPS fallback when stale
- Human-readable alert narratives + technical detail in V2X Monitor
- Settings > Use Case Alerts per-use-case toggles

Fixed:
- DENM no longer auto-fires on braking/stopping; manual-only (antenna/RSU test)
- Removed unwired CamBuilder.kt (dead code, contradicted CAM-only architecture)
- TripRecordingService no longer depends on MQTT/OBU connectivity
This commit is contained in:
Ashin Walpola
2026-07-02 15:04:34 +02:00
parent 4b20820ded
commit 7b8a8cbb32
19 changed files with 652 additions and 194 deletions
+6
View File
@@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Location -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -21,6 +23,10 @@
<!-- Keep CPU active while recording with screen off -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Allows prompting the user to exempt this app from battery optimization
so GPS callbacks are not throttled when the screen is off during a trip. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="false" />
<uses-feature android:name="android.hardware.sensor.gyroscope" android:required="false" />
<uses-feature android:name="android.hardware.sensor.compass" android:required="false" />
@@ -1,8 +1,11 @@
package com.hawhamburg.micr0bu
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.SystemBarStyle
@@ -46,6 +49,7 @@ import com.hawhamburg.micr0bu.ui.screens.MqttSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.SensorsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.SettingsScreen
import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen
import com.hawhamburg.micr0bu.ui.screens.UseCaseAlertsSettingsScreen
import com.hawhamburg.micr0bu.ui.theme.MicrOBUTheme
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
@@ -74,7 +78,10 @@ class MainActivity : AppCompatActivity() {
val activeTransport by mqttViewModel.activeTransport.collectAsState()
val usbConnected by mqttViewModel.usbConnected.collectAsState()
val obuStationTypeWarning by mqttViewModel.obuStationTypeWarning.collectAsState()
val obuStationType by mqttViewModel.obuStationType.collectAsState()
val tripServiceState by tripViewModel.serviceState.collectAsState()
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current
@@ -128,6 +135,7 @@ class MainActivity : AppCompatActivity() {
activeTransport = activeTransport,
usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType,
onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
onNavigateToSensors = {
navController.navigate(Screen.Sensors.route) {
@@ -147,6 +155,7 @@ class MainActivity : AppCompatActivity() {
state = state,
mqttConnectionState = mqttConnectionState,
tripServiceState = tripServiceState,
showBatteryOptPrompt = showBatteryOptPrompt,
onToggleRecording = {
// Start / stop both CSV recording and the event detection service
viewModel.toggleRecording()
@@ -155,6 +164,15 @@ class MainActivity : AppCompatActivity() {
onOpenSessionLog = {
navController.navigate(Screen.Log.route)
},
onOpenBatterySettings = {
startActivity(
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
)
tripViewModel.recheckBatteryOpt()
},
onDismissBatteryOptPrompt = tripViewModel::dismissBatteryOptPrompt,
)
}
composable(Screen.Log.route) {
@@ -205,10 +223,18 @@ class MainActivity : AppCompatActivity() {
onOpenUnits = { navController.navigate(Screen.SettingsUnits.route) },
onOpenConnection = { navController.navigate(Screen.SettingsConnection.route) },
onOpenMqtt = { navController.navigate(Screen.SettingsMqtt.route) },
onOpenUseCaseAlerts = { navController.navigate(Screen.SettingsUseCaseAlerts.route) },
onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) },
onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) },
)
}
composable(Screen.SettingsUseCaseAlerts.route) {
UseCaseAlertsSettingsScreen(
enabledMap = useCaseEnabledMap,
onToggle = mqttViewModel::setUseCaseEnabled,
onBack = { navController.popBackStack() },
)
}
composable(Screen.SettingsConnection.route) {
ConnectionSettingsScreen(
mqttPrefs = mqttPrefs,
@@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import com.hawhamburg.micr0bu.viewmodel.RecordingSession
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
@@ -17,28 +20,34 @@ fun sessionFileName(session: RecordingSession): String =
.replace(" ", "_").replace(":", "-")}.csv"
/**
* Returns the full CSV content for a session.
* If stream-to-file data exists (v0.2.0+ recording) the real per-sample rows are returned.
* Falls back to a metadata-only summary for older sessions.
* Shares the session CSV via Android's share sheet.
*
* Streams the on-disk data file directly to the cache without loading it into memory —
* avoids OOM for long sessions (1 hr+ at 50 Hz can exceed 100 MB as a String).
* Must be called from a coroutine; file I/O runs on [Dispatchers.IO].
*/
fun readSessionCsvContent(context: Context, session: RecordingSession): String {
val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv")
return if (dataFile.exists()) dataFile.readText() else buildSessionCsv(session)
}
suspend fun shareSessionCsv(context: Context, session: RecordingSession) {
val fileName = sessionFileName(session)
val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv")
val cacheFile = File(context.cacheDir, fileName)
fun shareSessionCsv(context: Context, session: RecordingSession) {
val fileName = sessionFileName(session)
val content = readSessionCsvContent(context, session)
// Write to cache so FileProvider can serve it via ACTION_SEND
val file = File(context.cacheDir, fileName).also { it.writeText(content) }
withContext(Dispatchers.IO) {
if (dataFile.exists()) {
dataFile.inputStream().buffered().use { input ->
cacheFile.outputStream().buffered().use { output ->
input.copyTo(output)
}
}
} else {
cacheFile.writeText(buildSessionCsv(session))
}
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
cacheFile,
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/csv"
putExtra(Intent.EXTRA_STREAM, uri)
@@ -48,6 +57,25 @@ fun shareSessionCsv(context: Context, session: RecordingSession) {
context.startActivity(Intent.createChooser(intent, "Export session"))
}
/**
* Streams the session CSV directly into a SAF [Uri] (from the "Save to device" picker).
*
* Same streaming approach as [shareSessionCsv] — no full-file String ever allocated.
* Must be called from a coroutine; file I/O runs on [Dispatchers.IO].
*/
suspend fun saveSessionCsvToUri(context: Context, session: RecordingSession, uri: Uri) {
val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv")
withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.buffered()?.use { output ->
if (dataFile.exists()) {
dataFile.inputStream().buffered().use { input -> input.copyTo(output) }
} else {
output.write(buildSessionCsv(session).toByteArray(Charsets.UTF_8))
}
}
}
}
/**
* Metadata-only CSV fallback — used for sessions recorded before stream-to-file was implemented,
* and by [readSessionCsvContent] when no data file is present on disk.
@@ -1,57 +0,0 @@
package com.hawhamburg.micr0bu.data.mqtt
import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.ImuReading
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.PI
/**
* MQTT topic used to inject a CAM message into the OBU.
*
* TODO: Confirm this topic with consider it GmbH / HAW Hamburg before production use.
* The MQTT API v6 documentation only covers *received/processed* output topics
* (v2x-uca/output/json/…). No inbound CAM injection topic is currently documented.
* "v2x/tx/cam" is a reasonable candidate based on the naming convention; verify with
* the OBU firmware team before using in production.
*/
const val CAM_TX_TOPIC = "v2x/tx/cam"
/**
* Builds a CAM JSON payload for test transmission.
*
* The schema mirrors the processed RX format on [v2x-uca/output/json/cam] per MQTT API v6 §2.2.3,
* adapted for outbound injection with phone sensor data.
*/
object CamBuilder {
fun build(
gnss: GnssReading,
gyro: ImuReading?,
stationId: Long,
): String {
// Gyroscope Z axis (rad/s) → yaw rate (deg/s)
val yawRateDps = gyro?.z?.let { it.toDouble() * (180.0 / PI) } ?: 0.0
return JSONObject().apply {
put("type", "v2x-cam")
put("stationId", stationId)
put("stationType", "cyclist") // ETSI stationType = 2 (cyclist) mapped to string label
put("position", JSONObject().apply {
put("type", "Point")
// GeoJSON coordinate order: [longitude, latitude, altitude]
put("coordinates", JSONArray().apply {
put(gnss.longitude)
put(gnss.latitude)
put(gnss.altitude)
})
})
put("heading_deg", gnss.bearingDeg.toDouble())
put("speed_mps", gnss.speedMs.toDouble())
put("yawRate_dps", yawRateDps)
put("vehicleRole", "default")
put("pathHistory", JSONArray())
put("positionError_stdDev", gnss.accuracyM.toDouble())
}.toString()
}
}
@@ -1,9 +1,9 @@
package com.hawhamburg.micr0bu.data.mqtt
import android.content.Context
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import dagger.hilt.android.qualifiers.ApplicationContext
import com.hawhamburg.micr0bu.domain.denm.DENM_CTRL_TOPIC
import com.hawhamburg.micr0bu.domain.denm.buildDenmPayload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -34,6 +35,8 @@ import javax.inject.Inject
import javax.inject.Singleton
import org.eclipse.paho.client.mqttv3.MqttMessage as PahoMqttMessage
private const val MAX_MESSAGES_PER_TOPIC = 50
private val SUBSCRIBED_TOPICS = listOf(
"sys/state/heartbeat",
"sys/state/cellular",
@@ -47,7 +50,6 @@ private val SUBSCRIBED_TOPICS = listOf(
@Singleton
class MqttRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: MqttPreferences,
private val usbDetector: UsbNetworkDetector,
) {
@@ -64,6 +66,12 @@ class MqttRepository @Inject constructor(
)
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
// Per-topic message log, kept here (singleton) so it survives even when no screen is
// collecting — e.g. DENM TX messages emitted by TripRecordingService while the V2X
// Monitor screen isn't open.
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 ->
@@ -75,6 +83,115 @@ class MqttRepository @Inject constructor(
@Volatile private var activeClient: MqttAsyncClient? = null
private var connectJob: Job? = null
// ── DENM use case state ──────────────────────────────────────────────────
// Shared between MqttViewModel (manual trigger) and TripRecordingService
// (automatic event-driven triggers). Only one use case may be active at a time.
private val _denmActive = MutableStateFlow(false)
val denmActive: StateFlow<Boolean> = _denmActive.asStateFlow()
private val _lastDenmPayload = MutableStateFlow<String?>(null)
val lastDenmPayload: StateFlow<String?> = _lastDenmPayload.asStateFlow()
private val _activeDenmUseCase = MutableStateFlow<String?>(null)
/** The use case id (e.g. "c2c-eebl") currently active on the OBU, if any. */
val activeDenmUseCase: StateFlow<String?> = _activeDenmUseCase.asStateFlow()
/**
* True if the last activate/deactivate couldn't reach the OBU because the link was down.
* Cleared once [resyncDenmOnReconnect] successfully republishes on the next connection.
*/
@Volatile private var pendingDenmResync = false
init {
// Re-send the last DENM control message once the link comes back, so a use case
// triggered (or cleared) while the OBU was unreachable still takes effect.
scope.launch {
_connectionState.collect { state ->
if (state == MqttConnectionState.CONNECTED) resyncDenmOnReconnect()
}
}
}
/**
* Activate [useCaseId] on the OBU. No-op if a use case is already active
* (per spec, only one DENM use case may be active at a time).
* Local state updates regardless of connectivity — recording/event-detection never
* blocks on the OBU link; if the link is down, the activation is resent on reconnect.
* Returns true if the activation was recorded.
*/
fun activateDenm(useCaseId: String): Boolean {
if (_denmActive.value) return false
val payload = buildDenmPayload(useCase = useCaseId, active = true)
_lastDenmPayload.value = payload
_denmActive.value = true
_activeDenmUseCase.value = useCaseId
sendDenmPayload(payload)
return true
}
/**
* Deactivate whatever DENM use case is currently active. No-op if none is active.
* Same offline-safe behaviour as [activateDenm] — resent on reconnect if needed.
*/
fun deactivateDenm() {
if (!_denmActive.value) return
val useCaseId = _activeDenmUseCase.value ?: return
val payload = buildDenmPayload(useCase = useCaseId, active = false)
_lastDenmPayload.value = payload
_denmActive.value = false
_activeDenmUseCase.value = null
sendDenmPayload(payload)
}
/**
* Publishes [payload] immediately if the OBU link is up. If not, marks the state for
* resync so it's republished as soon as the connection is restored — no DENM control
* message is silently dropped.
*/
private fun sendDenmPayload(payload: String) {
if (_connectionState.value == MqttConnectionState.CONNECTED) {
emitDenmTxMessage(payload)
publishRetained(DENM_CTRL_TOPIC, payload)
} else {
pendingDenmResync = true
}
}
/** Republish the last DENM control message after reconnecting, if it never went out. */
private fun resyncDenmOnReconnect() {
if (!pendingDenmResync) return
pendingDenmResync = false
val payload = _lastDenmPayload.value ?: return
emitDenmTxMessage(payload)
publishRetained(DENM_CTRL_TOPIC, payload)
}
/** Inject a synthetic TX message into the message stream so it shows in the V2X Monitor. */
private fun emitDenmTxMessage(payload: String) {
recordMessage(
MqttMessage(
topic = DENM_CTRL_TOPIC,
payload = payload,
timestamp = System.currentTimeMillis(),
direction = MessageDirection.TX,
)
)
}
/**
* Record a message into both the live [messages] stream (for screens currently open)
* and the persistent [topicMessages] log (survives even when no screen is collecting).
*/
private fun recordMessage(msg: MqttMessage) {
_messages.tryEmit(msg)
_topicMessages.update { current ->
val updated = ((current[msg.topic] ?: emptyList()) + msg)
.takeLast(MAX_MESSAGES_PER_TOPIC)
current + (msg.topic to updated)
}
}
// ── Public API ────────────────────────────────────────────────────────────
fun connect() {
@@ -93,22 +210,6 @@ class MqttRepository @Inject constructor(
}
}
/**
* Publish a message to [topic]. No-op when not connected.
* Uses QoS 0 (fire-and-forget) by default — suitable for test CAM frames.
*/
fun publish(topic: String, payload: String, qos: Int = 0) {
val client = activeClient ?: return
if (!client.isConnected) return
scope.launch {
runCatching {
val msg = PahoMqttMessage(payload.toByteArray(Charsets.UTF_8))
msg.qos = qos
client.publish(topic, msg)
}
}
}
/**
* Publish a retained message to [topic] at QoS 1. No-op when not connected.
* The retain flag ensures the OBU's Use Case app receives it on (re)connect.
@@ -185,7 +286,7 @@ class MqttRepository @Inject constructor(
override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) }
override fun messageArrived(topic: String, message: PahoMqttMessage) {
_messages.tryEmit(
recordMessage(
MqttMessage(
topic = topic,
payload = message.payload.toString(Charsets.UTF_8),
@@ -210,15 +311,15 @@ class MqttRepository @Inject constructor(
// Bind socket to the USB tethering interface only when the cable is present.
// Without this, traffic would be routed over Wi-Fi even in USB-C mode.
if (usbActive) {
socketFactory = usbNet!!.socketFactory
if (isUsbC && usbNet != null) {
socketFactory = usbNet.socketFactory
}
}
// Connect (bridge callback → coroutine)
suspendCancellableCoroutine { cont ->
client.connect(options, null, object : IMqttActionListener {
override fun onSuccess(token: IMqttToken) = cont.resume(Unit) {}
override fun onSuccess(token: IMqttToken) = cont.resumeWith(Result.success(Unit))
override fun onFailure(token: IMqttToken, ex: Throwable) =
cont.resumeWithException(ex)
})
@@ -230,7 +331,7 @@ class MqttRepository @Inject constructor(
val qosArr = IntArray(topicsArr.size) { 0 }
suspendCancellableCoroutine { cont ->
client.subscribe(topicsArr, qosArr, null, object : IMqttActionListener {
override fun onSuccess(token: IMqttToken) = cont.resume(Unit) {}
override fun onSuccess(token: IMqttToken) = cont.resumeWith(Result.success(Unit))
override fun onFailure(token: IMqttToken, ex: Throwable) =
cont.resumeWithException(ex)
})
@@ -46,6 +46,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
private var turningStartTime = 0L
private var turningBothAgreed = false
private var peakGyroTurning = 0.0
private var turningEventFired = false
// ── Stopping state ────────────────────────────────────────────────────────
@@ -169,12 +170,16 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
turningStartTime = timestamp
peakGyroTurning = 0.0
turningBothAgreed = false
turningEventFired = false
}
turningFrames++
peakGyroTurning = maxOf(peakGyroTurning, gyroMagnitude)
if (gyroAbove && speedAbove && bearingAbove) turningBothAgreed = true
if (turningFrames == config.turningSustainedFrames) {
// Emit once per continuous turn. Without turningEventFired, a single turn that
// stays above threshold for longer than turningSustainedFrames would re-trigger
// every turningSustainedFrames frames (e.g. a 2 s turn at 20 frames/event = 5 events).
if (turningFrames >= config.turningSustainedFrames && !turningEventFired) {
val confidence = if (turningBothAgreed) Confidence.HIGH else Confidence.LOW
_events.tryEmit(
@@ -190,10 +195,11 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
durationMs = timestamp - turningStartTime,
)
)
turningFrames = 0
turningEventFired = true
}
} else {
turningFrames = 0
turningFrames = 0
turningEventFired = false
}
}
@@ -5,6 +5,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
@@ -12,6 +13,7 @@ import android.hardware.SensorManager
import android.location.Location
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
@@ -26,6 +28,7 @@ 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -44,7 +47,16 @@ import kotlin.math.sqrt
* • Accelerometer + Gyroscope at [SensorManager.SENSOR_DELAY_GAME] (~50 Hz)
* • FusedLocationProviderClient at 1 Hz
* • Accel/gyro magnitudes + last GPS state → [EventDetector] → Room via [TripRepository]
*
* Per the requirements doc (Section 11), Phase A is independent of OBU connectivity — no
* cable, no MQTT broker, no hardware required — so this service has no MQTT/DENM dependency.
* Detected events are recorded for trip history/CSV only; a future phase will feed them into
* the CAM-based Use Case Detection Engine (`com.hawhamburg.micr0bu.domain.usecase`) as
* corroborating signals (Section 11.4). No DENM is triggered by detected events — the only
* way to send a DENM is the manual "Send Test DENM" button in the V2X Monitor screen, kept
* solely to verify the OBU↔RSU antenna link independent of any detected event.
*/
@AndroidEntryPoint
class TripRecordingService : Service() {
// ── Constants ─────────────────────────────────────────────────────────────
@@ -66,6 +78,11 @@ class TripRecordingService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob)
private var timerJob: Job? = null
// Held only while a trip is actively recording — keeps the CPU alive for sensor
// and GPS callbacks when the screen is off. Released immediately on stop so the
// phone can sleep normally when the app is idle.
private var wakeLock: PowerManager.WakeLock? = null
// ── Dependencies ──────────────────────────────────────────────────────────
private lateinit var sensorManager: SensorManager
@@ -207,6 +224,14 @@ class TripRecordingService : Service() {
// ── Trip control ─────────────────────────────────────────────────────────
private fun startTrip() {
// Keep the CPU awake for the duration of the trip so sensor and GPS callbacks
// are delivered reliably when the screen is off. Released in stopTrip().
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"micr0bu:TripRecording",
).also { it.acquire() }
detector.reset()
brakingCount = 0
turningCount = 0
@@ -272,11 +297,21 @@ class TripRecordingService : Service() {
registerSensors()
requestLocationUpdates()
// Start foreground ASAP (within 5 s required by Android)
startForeground(NOTIFICATION_ID, buildNotification(0L, 0))
// Start foreground ASAP (within 5 s required by Android).
// Specify FOREGROUND_SERVICE_TYPE_LOCATION so Android 10+ knows why we need
// continuous location access, and so the notification is re-posted automatically
// if the user dismisses it (Android 13+ allows users to swipe foreground
// service notifications away, but the system re-shows them after ~1 s).
startForeground(
NOTIFICATION_ID,
buildNotification(0L),
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
)
}
private fun stopTrip() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
timerJob?.cancel()
unregisterSensors()
removeLocationUpdates()
@@ -377,7 +412,7 @@ class TripRecordingService : Service() {
.createNotificationChannel(channel)
}
private fun buildNotification(elapsedSeconds: Long, eventCount: Int) =
private fun buildNotification(elapsedSeconds: Long) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Recording trip")
.setContentText(
@@ -390,17 +425,22 @@ class TripRecordingService : Service() {
.setContentIntent(
PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
Intent(this, MainActivity::class.java).apply {
// Bring the existing activity to the front rather than
// launching a second instance on top of the running one.
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
)
.setAutoCancel(false)
.build()
private fun updateNotification() {
val elapsed = TripServiceBus.state.value.elapsedSeconds
val total = brakingCount + turningCount + stoppingCount
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(elapsed, total))
nm.notify(NOTIFICATION_ID, buildNotification(elapsed))
}
private fun formatElapsed(seconds: Long): String {
@@ -47,6 +47,7 @@ sealed class Screen(val route: String, val labelRes: Int) {
data object SettingsUnits : Screen("settings/units", R.string.settings_units)
data object SettingsConnection : Screen("settings/connection", R.string.settings_connection)
data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section)
data object SettingsUseCaseAlerts : Screen("settings/usecases", R.string.settings_usecase_alerts)
data object SettingsDeveloper : Screen("settings/developer", R.string.settings_developer)
data object SettingsAbout : Screen("settings/about", R.string.settings_about)
}
@@ -61,6 +61,7 @@ fun DashboardScreen(
activeTransport: TransportType = TransportType.USB_C,
usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null,
onNavigateToConnection: () -> Unit,
onNavigateToSensors: () -> Unit,
onNavigateToMap: () -> Unit,
@@ -195,7 +196,10 @@ fun DashboardScreen(
modifier = Modifier.size(16.dp),
)
Text(
stringResource(R.string.dash_station_type_warning),
buildString {
append(stringResource(R.string.dash_station_type_warning))
if (obuStationType != null) append(" (got $obuStationType)")
},
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFFC107),
)
@@ -65,6 +65,11 @@ 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.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
import org.json.JSONArray
import org.json.JSONException
@@ -86,6 +91,12 @@ private val DisconnectedGrayBg = Color(0xFF1C2733)
private val DenmRed = Color(0xFFE53935)
private val DenmRedBg = Color(0xFF3A0A0A)
private val DenmAmber = Color(0xFFFFC107)
private val InfoBlue = Color(0xFF64B5F6)
private val InfoBlueBg = Color(0xFF10243A)
private val AwarenessAmber = Color(0xFFFFC107)
private val AwarenessAmberBg = Color(0xFF3A2D00)
private val WarningRed = Color(0xFFE53935)
private val WarningRedBg = Color(0xFF3A0A0A)
@Composable
fun MqttTopicViewerScreen(
@@ -97,6 +108,9 @@ fun MqttTopicViewerScreen(
val autoScroll by viewModel.autoScroll.collectAsState()
val denmActive by viewModel.denmActive.collectAsState()
val lastDenmPayload by viewModel.lastDenmPayload.collectAsState()
val activeDenmUseCase by viewModel.activeDenmUseCase.collectAsState()
val useCaseAlerts by viewModel.useCaseAlerts.collectAsState()
val ownStationId by viewModel.ownStationId.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith(
@@ -172,14 +186,17 @@ fun MqttTopicViewerScreen(
connectionState = connectionState,
denmActive = denmActive,
lastDenmPayload = lastDenmPayload,
activeDenmUseCase = activeDenmUseCase,
useCaseAlerts = useCaseAlerts,
onSelectTopic = { viewModel.selectTopic(it) },
onSendDenm = { viewModel.sendDenm() },
onStopDenm = { viewModel.stopDenm() },
)
} else {
MessageDetailPane(
messages = topicMessages[selectedTopic] ?: emptyList(),
autoScroll = autoScroll,
messages = topicMessages[selectedTopic] ?: emptyList(),
autoScroll = autoScroll,
ownStationId = ownStationId,
)
}
}
@@ -194,6 +211,8 @@ private fun TopicListPane(
connectionState: MqttConnectionState,
denmActive: Boolean,
lastDenmPayload: String?,
activeDenmUseCase: String?,
useCaseAlerts: List<UseCaseAlert>,
onSelectTopic: (String) -> Unit,
onSendDenm: () -> Unit,
onStopDenm: () -> Unit,
@@ -202,11 +221,17 @@ private fun TopicListPane(
Column(modifier = Modifier.fillMaxSize()) {
// ── DENM TX Control card ────────────────────────────────────────────
// ── CAM-based Use Case Alert panel (Section 10.2 / 10.4) ─────────────
UseCaseAlertPanel(alerts = useCaseAlerts, isConnected = isConnected)
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,
)
@@ -310,6 +335,7 @@ private fun TopicRow(
private fun MessageDetailPane(
messages: List<MqttMessage>,
autoScroll: Boolean,
ownStationId: Long?,
) {
if (messages.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -331,18 +357,29 @@ private fun MessageDetailPane(
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
itemsIndexed(reversed, key = { index, msg -> "${msg.topic}-${msg.timestamp}-$index" }) { _, msg ->
MessageRow(msg)
MessageRow(msg, ownStationId)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
}
}
}
@Composable
private fun MessageRow(msg: MqttMessage) {
// Show TX badge for all outbound messages; RX badge only on DENM topics
// (to avoid noisy badges on high-frequency CAM/SPAT traffic).
val showBadge = msg.direction == MessageDirection.TX ||
private fun MessageRow(msg: MqttMessage, ownStationId: Long?) {
// CAM messages get an OWN/REMOTE badge instead of a TX/RX badge — that's the
// distinction the V2X Monitor and Use Case Alert panel actually care about (Section 10.4).
val isCamTopic = msg.topic.substringAfterLast('/') == "cam"
val camIsOwn: Boolean? = if (isCamTopic) {
remember(msg.payload, ownStationId) {
CamParser.parse(msg.payload, ownStationId, msg.timestamp)?.isOwn
}
} else null
// For non-CAM topics: show TX badge for all outbound messages; RX badge only on DENM
// topics (to avoid noisy badges on high-frequency SPAT/MAP/CPM traffic).
val showDirectionBadge = camIsOwn == null && (
msg.direction == MessageDirection.TX ||
(msg.direction == MessageDirection.RX && msg.topic.contains("denm", ignoreCase = true))
)
Column(
modifier = Modifier
@@ -359,7 +396,8 @@ private fun MessageRow(msg: MqttMessage) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
if (showBadge) DirectionBadge(msg.direction)
if (camIsOwn != null) OwnRemoteBadge(isOwn = camIsOwn)
else if (showDirectionBadge) DirectionBadge(msg.direction)
}
Spacer(Modifier.height(4.dp))
Box(
@@ -389,6 +427,7 @@ private fun DenmTxCard(
isConnected: Boolean,
denmActive: Boolean,
lastDenmPayload: String?,
activeDenmUseCase: String?,
onSendDenm: () -> Unit,
onStopDenm: () -> Unit,
) {
@@ -450,7 +489,7 @@ private fun DenmTxCard(
shape = RoundedCornerShape(4.dp),
) {
Text(
"hln-sv",
activeDenmUseCase ?: DenmUseCase.STATIONARY.id,
style = MaterialTheme.typography.labelSmall,
color = if (denmActive) DenmRed
else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -587,6 +626,129 @@ private fun DirectionBadge(direction: MessageDirection) {
}
}
// ── OWN / REMOTE badge (CAM messages) ─────────────────────────────────────────
@Composable
private fun OwnRemoteBadge(isOwn: Boolean) {
val (text, fg, bg) = if (isOwn) {
Triple("OWN", ConnectedGreen, ConnectedGreenBg)
} else {
Triple("REMOTE", DenmAmber, Color(0xFF3A2D00))
}
Surface(color = bg, shape = RoundedCornerShape(3.dp)) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = fg,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
}
// ── CAM-based Use Case Alert panel ────────────────────────────────────────────
@Composable
private fun UseCaseAlertPanel(alerts: List<UseCaseAlert>, isConnected: Boolean) {
val topAlert = alerts.firstOrNull()
val cardBg = when (topAlert?.alertLevel) {
AlertLevel.WARNING -> WarningRedBg
AlertLevel.AWARENESS -> AwarenessAmberBg
AlertLevel.INFO -> InfoBlueBg
null -> MaterialTheme.colorScheme.surfaceVariant
}
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(containerColor = cardBg),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
stringResource(R.string.mqtt_usecase_panel_title),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(8.dp))
when {
!isConnected -> Text(
stringResource(R.string.mqtt_usecase_not_connected),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
alerts.isEmpty() -> Text(
stringResource(R.string.mqtt_usecase_none_active),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
alerts.forEach { alert -> UseCaseAlertRow(alert) }
}
}
}
}
}
/** Maps a use case to its human-readable narrative string resource (Section 10.4). */
private fun UseCaseType.narrativeStringRes(): Int = when (this) {
UseCaseType.IMA_B -> R.string.usecase_narrative_ima_b
UseCaseType.IMA_S -> R.string.usecase_narrative_ima_s
UseCaseType.RTW_B -> R.string.usecase_narrative_rtw_b
UseCaseType.LTW_B -> R.string.usecase_narrative_ltw_b
UseCaseType.SMVA_BCW_B -> R.string.usecase_narrative_smva_bcw_b
}
@Composable
private fun UseCaseAlertRow(alert: UseCaseAlert) {
val (fg, bg) = when (alert.alertLevel) {
AlertLevel.WARNING -> WarningRed to WarningRedBg
AlertLevel.AWARENESS -> AwarenessAmber to AwarenessAmberBg
AlertLevel.INFO -> InfoBlue to InfoBlueBg
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(color = bg, shape = RoundedCornerShape(4.dp)) {
Text(
text = alert.alertLevel.name,
style = MaterialTheme.typography.labelSmall,
color = fg,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
// Primary, human-readable line — what the rider actually needs to read at a glance.
Text(
text = stringResource(alert.useCase.narrativeStringRes(), alert.timeToConflictSec),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
// Secondary technical line — use case ID + the raw numbers + which signal fired
// (yaw-rate / heading-trend / static-heuristic / distance-trend), so the detection
// logic stays verifiable rather than a black box.
Text(
text = "${alert.useCase.label} · " + stringResource(
R.string.mqtt_usecase_detail,
alert.remoteStationId,
alert.distanceMeters,
alert.closingSpeedMps,
alert.timeToConflictSec,
alert.signalNote,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// ── Connection status chip ────────────────────────────────────────────────────
@Composable
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.TableChart
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ButtonDefaults
@@ -25,6 +26,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -44,10 +46,33 @@ fun RecordingScreen(
state: SensorUiState,
mqttConnectionState: MqttConnectionState,
tripServiceState: TripServiceBus.State,
showBatteryOptPrompt: Boolean,
onToggleRecording: () -> Unit,
onOpenSessionLog: () -> Unit,
onOpenBatterySettings: () -> Unit,
onDismissBatteryOptPrompt: () -> Unit,
modifier: Modifier = Modifier,
) {
if (showBatteryOptPrompt) {
AlertDialog(
onDismissRequest = {}, // force explicit choice
title = { Text("Allow background GPS") },
text = {
Text(
"To keep GPS running when the screen turns off during a ride, " +
"exempt MicrOBU from battery optimisation. " +
"The app will only use this during active recording."
)
},
confirmButton = {
Button(onClick = onOpenBatterySettings) { Text("Open settings") }
},
dismissButton = {
TextButton(onClick = onDismissBatteryOptPrompt) { Text("Don't show again") }
},
)
}
Column(
modifier = modifier
.fillMaxSize()
@@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -38,9 +39,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.readSessionCsvContent
import com.hawhamburg.micr0bu.data.saveSessionCsvToUri
import com.hawhamburg.micr0bu.data.sessionFileName
import com.hawhamburg.micr0bu.data.shareSessionCsv
import kotlinx.coroutines.launch
import com.hawhamburg.micr0bu.viewmodel.RecordingSession
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
import java.text.SimpleDateFormat
@@ -56,6 +58,7 @@ fun SessionLogScreen(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<RecordingSession?>(null) }
var pendingSave by remember { mutableStateOf<RecordingSession?>(null) }
@@ -64,8 +67,8 @@ fun SessionLogScreen(
) { uri ->
uri ?: return@rememberLauncherForActivityResult
val session = pendingSave ?: return@rememberLauncherForActivityResult
context.contentResolver.openOutputStream(uri)?.use { it.write(readSessionCsvContent(context, session).toByteArray(Charsets.UTF_8)) }
pendingSave = null
scope.launch { saveSessionCsvToUri(context, session, uri) }
}
pendingDelete?.let { session ->
@@ -103,7 +106,7 @@ fun SessionLogScreen(
SessionCard(
session = session,
index = state.sessions.size - index,
onShare = { shareSessionCsv(context, session) },
onShare = { scope.launch { shareSessionCsv(context, session) } },
onSaveToDevice = { pendingSave = session; saveLauncher.launch(sessionFileName(session)) },
onDelete = { pendingDelete = session },
)
@@ -47,6 +47,8 @@ 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.domain.usecase.UseCaseDetectionConfig
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
private data class LangOption(val tag: String, val flag: String, val labelRes: Int)
@@ -66,6 +68,7 @@ fun SettingsScreen(
onOpenUnits: () -> Unit,
onOpenConnection: () -> Unit,
onOpenMqtt: () -> Unit,
onOpenUseCaseAlerts: () -> Unit,
onOpenDeveloper: () -> Unit,
onOpenAbout: () -> Unit,
modifier: Modifier = Modifier,
@@ -99,6 +102,8 @@ fun SettingsScreen(
RowDivider()
MenuRow(stringResource(R.string.settings_mqtt_section), onOpenMqtt)
RowDivider()
MenuRow(stringResource(R.string.settings_usecase_alerts), onOpenUseCaseAlerts)
RowDivider()
MenuRow(stringResource(R.string.settings_developer), onOpenDeveloper)
RowDivider()
MenuRow(stringResource(R.string.settings_about), onOpenAbout)
@@ -284,7 +289,57 @@ fun MqttSettingsScreen(
) {
SubScreen(stringResource(R.string.settings_mqtt_section), onBack) {
SectionCard {
MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange)
MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange, onSave = onBack)
}
}
}
@Composable
fun UseCaseAlertsSettingsScreen(
enabledMap: Map<UseCaseType, Boolean>,
onToggle: (UseCaseType, Boolean) -> Unit,
onBack: () -> Unit,
) {
val config = remember { UseCaseDetectionConfig() }
SubScreen(stringResource(R.string.settings_usecase_alerts), onBack) {
Text(
stringResource(R.string.settings_usecase_alerts_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SectionCard {
UseCaseType.entries.forEachIndexed { index, type ->
SettingToggleRow(
label = "${type.label} · ${type.c2cId}",
checked = enabledMap[type] ?: true,
onCheckedChange = { onToggle(type, it) },
)
if (index != UseCaseType.entries.lastIndex) Divider()
}
}
Text(
stringResource(R.string.settings_usecase_alert_levels_title),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SectionCard {
DisabledRow(
stringResource(R.string.settings_usecase_level_warning),
stringResource(R.string.settings_usecase_ttc_le, config.ttcWarningSec),
)
Divider()
DisabledRow(
stringResource(R.string.settings_usecase_level_awareness),
stringResource(R.string.settings_usecase_ttc_le, config.ttcAwarenessSec),
)
Divider()
DisabledRow(
stringResource(R.string.settings_usecase_level_info),
stringResource(R.string.settings_usecase_ttc_le, config.ttcInfoSec),
)
}
}
}
@@ -334,7 +389,11 @@ private fun SectionCard(content: @Composable () -> Unit) {
}
@Composable
private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> Unit) {
private fun MqttSettingsSection(
prefs: MqttPrefs,
onChange: (MqttPrefs) -> Unit,
onSave: () -> Unit = {},
) {
// Key each state to the corresponding saved pref so fields re-sync after an
// external save (e.g. via the Save button), while preserving in-progress edits.
var ip by remember(prefs.brokerIp) { mutableStateOf(prefs.brokerIp) }
@@ -401,6 +460,7 @@ private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> Unit)
password = password,
)
)
onSave()
},
modifier = Modifier.fillMaxWidth(),
) {
@@ -2,7 +2,7 @@ package com.hawhamburg.micr0bu.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences
@@ -10,6 +10,9 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -17,29 +20,23 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.json.JSONObject
import javax.inject.Inject
private const val MAX_MESSAGES_PER_TOPIC = 50
private const val DENM_CTRL_TOPIC = "v2x-uca/input/denmtrg"
private const val DENM_USE_CASE = "hln-sv"
@HiltViewModel
class MqttViewModel @Inject constructor(
private val repo: MqttRepository,
private val prefs: MqttPreferences,
private val usbDetector: UsbNetworkDetector,
private val camUseCaseRepository: CamUseCaseRepository,
) : ViewModel() {
// ── MQTT connection & messages ────────────────────────────────────────────
val connectionState: StateFlow<MqttConnectionState> = repo.connectionState
private val _topicMessages = MutableStateFlow<Map<String, List<MqttMessage>>>(emptyMap())
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = _topicMessages.asStateFlow()
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = repo.topicMessages
private val _selectedTopic = MutableStateFlow<String?>(null)
val selectedTopic: StateFlow<String?> = _selectedTopic.asStateFlow()
@@ -69,11 +66,16 @@ class MqttViewModel @Inject constructor(
// ── OBU identity (parsed from v2x/rx/obu_gnss own_info) ──────────────────
private val _obuStationType = MutableStateFlow<Int?>(null)
/**
* stationType from the OBU's own_info (v2x/rx/obu_gnss). Should be 2 (cyclist/VRU) per
* ETSI EN 302 637-2 Table 1. Null until the first obu_gnss message arrives.
*/
private val _obuStationType = MutableStateFlow<Int?>(null)
/**
* The raw stationType value last reported by the OBU. Null until the first
* obu_gnss message arrives. Exposed so the UI can show the actual value.
*/
val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow()
/**
@@ -87,30 +89,42 @@ class MqttViewModel @Inject constructor(
// ── DENM transmission ─────────────────────────────────────────────────────
private val _denmActive = MutableStateFlow(false)
/** True while the DENM use case is actively broadcasting on the OBU. */
val denmActive: StateFlow<Boolean> = _denmActive.asStateFlow()
/** True while a DENM use case is actively broadcasting on the OBU. */
val denmActive: StateFlow<Boolean> = repo.denmActive
private val _lastDenmPayload = MutableStateFlow<String?>(null)
/** JSON string of the most recently transmitted DENM control message. */
val lastDenmPayload: StateFlow<String?> = _lastDenmPayload.asStateFlow()
val lastDenmPayload: StateFlow<String?> = repo.lastDenmPayload
/** Tracks which use case is currently active so Stop uses the same usecase field. */
private var activeDenmUseCase: String? = null
/** The use case id currently active on the OBU, if any (e.g. for showing in the UI). */
val activeDenmUseCase: StateFlow<String?> = repo.activeDenmUseCase
// ── CAM-based Use Case Detection (Section 10.2 / 10.4) ────────────────────
// Entirely separate from the DENM transmission above: this consumes CAM only, raises
// local HMI alerts, and never triggers an outbound V2X message.
/** The ego OBU's own station ID, learned from v2x/rx/obu_gnss's own_info. */
val ownStationId: StateFlow<Long?> = camUseCaseRepository.ownStationId
/** Active CAM-based use case alerts, filtered to the use cases enabled in Settings. */
val useCaseAlerts: StateFlow<List<UseCaseAlert>> = camUseCaseRepository.enabledAlerts
/** Per-use-case enable/disable state (Settings > Use Case Alerts). */
val useCaseEnabledMap: StateFlow<Map<UseCaseType, Boolean>> = camUseCaseRepository.enabledMap
/** 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)
fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) {
camUseCaseRepository.setUseCaseEnabled(type, enabled)
}
// ── Init ──────────────────────────────────────────────────────────────────
init {
viewModelScope.launch {
// Per-topic message lists now live in MqttRepository (survives screen close);
// here we just watch for own_info to track the OBU's reported stationType.
repo.messages.collect { msg ->
// Route into per-topic message lists
_topicMessages.update { current ->
val updated = ((current[msg.topic] ?: emptyList()) + msg)
.takeLast(MAX_MESSAGES_PER_TOPIC)
current + (msg.topic to updated)
}
// Check stationType from own_info — must be 2 (cyclist/VRU)
if (msg.topic == "v2x/rx/obu_gnss") {
runCatching {
val stType = JSONObject(msg.payload)
@@ -139,52 +153,19 @@ class MqttViewModel @Inject constructor(
/**
* Publish a uca-denmctrl activate message with the retain flag so the OBU's Use Case app
* receives it on any (re)connect. Only one use case may be active at a time.
* receives it on any (re)connect. Only one use case may be active at a time
* (no-op if another use case, manual or automatic, is already active).
*/
fun sendDenm(useCase: String = DENM_USE_CASE) {
if (_denmActive.value) return // prevent concurrent activations
val payload = buildDenmPayload(useCase = useCase, active = true)
_lastDenmPayload.value = payload
_denmActive.value = true
activeDenmUseCase = useCase
emitTxMessage(DENM_CTRL_TOPIC, payload)
repo.publishRetained(DENM_CTRL_TOPIC, payload)
fun sendDenm(useCase: String = DenmUseCase.STATIONARY.id) {
repo.activateDenm(useCase)
}
/**
* Publish a uca-denmctrl deactivate message (retained).
* Uses the same usecase string as the activate call so the OBU phases out the correct DENM.
* Publish a uca-denmctrl deactivate message (retained) for whichever use case is
* currently active.
*/
fun stopDenm() {
if (!_denmActive.value) return
val useCase = activeDenmUseCase ?: DENM_USE_CASE
val payload = buildDenmPayload(useCase = useCase, active = false)
_lastDenmPayload.value = payload
_denmActive.value = false
activeDenmUseCase = null
emitTxMessage(DENM_CTRL_TOPIC, payload)
repo.publishRetained(DENM_CTRL_TOPIC, payload)
}
private fun buildDenmPayload(useCase: String, active: Boolean): String =
"""{"type":"uca-denmctrl","active":$active,"usecase":"$useCase","params":{}}"""
/**
* Inject a synthetic TX [MqttMessage] into the local topic map so outbound messages
* appear in the V2X Monitor alongside received traffic.
*/
private fun emitTxMessage(topic: String, payload: String) {
val msg = MqttMessage(
topic = topic,
payload = payload,
timestamp = System.currentTimeMillis(),
direction = MessageDirection.TX,
)
_topicMessages.update { current ->
val updated = ((current[topic] ?: emptyList()) + msg)
.takeLast(MAX_MESSAGES_PER_TOPIC)
current + (topic to updated)
}
repo.deactivateDenm()
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -1,7 +1,9 @@
package com.hawhamburg.micr0bu.viewmodel
import android.app.Application
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hawhamburg.micr0bu.data.TripRepository
@@ -27,6 +29,32 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
private val repository = TripRepository(AppDatabase.getInstance(application))
// ── Battery optimisation prompt ───────────────────────────────────────────
// Shown once on the Recording screen to ask the user to exempt the app so
// GPS callbacks are not throttled when the screen turns off during a trip.
// "Don't show again" persists across sessions via SharedPreferences.
private val appPrefs = application.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
private val pm = application.getSystemService(Context.POWER_SERVICE) as PowerManager
private val _showBatteryOptPrompt = MutableStateFlow(shouldShowBatteryPrompt())
val showBatteryOptPrompt: StateFlow<Boolean> = _showBatteryOptPrompt.asStateFlow()
private fun shouldShowBatteryPrompt(): Boolean =
!pm.isIgnoringBatteryOptimizations(getApplication<Application>().packageName) &&
!appPrefs.getBoolean("battery_opt_dismissed", false)
/** Called when user taps "Don't show again" — suppresses the prompt permanently. */
fun dismissBatteryOptPrompt() {
appPrefs.edit().putBoolean("battery_opt_dismissed", true).apply()
_showBatteryOptPrompt.value = false
}
/** Re-checks the system state — call after returning from battery settings. */
fun recheckBatteryOpt() {
_showBatteryOptPrompt.value = shouldShowBatteryPrompt()
}
// ── Service state (live during a recording) ───────────────────────────────
/** Live recording state forwarded from [TripServiceBus]. */
+25 -3
View File
@@ -165,18 +165,40 @@
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
<string name="mqtt_no_messages">Noch keine Nachrichten zu diesem Thema</string>
<!-- DENM TX -->
<!-- DENM TX — nur manueller Antennen-/RSU-Reichweitentest, nicht Use-Case-gesteuert -->
<string name="mqtt_last_tx">Zuletzt gesendete Nutzlast:</string>
<string name="mqtt_denm_tx_title">DENM-Übertragung</string>
<string name="mqtt_denm_tx_title">DENM-Übertragung (manueller Test)</string>
<string name="mqtt_send_denm">Test-DENM senden</string>
<string name="mqtt_stop_denm">DENM stoppen</string>
<string name="mqtt_denm_use_case_desc">Liegengebliebenes Fahrzeug (causeCode 94)</string>
<string name="mqtt_denm_not_connected">Mit OBU verbinden, um DENM-Auslösung zu aktivieren</string>
<string name="mqtt_denm_active">DENM aktiv — OBU sendet über ITS-G5</string>
<string name="mqtt_denm_hint">Sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg</string>
<string name="mqtt_denm_hint">Manueller Antennen-/RSU-Reichweitentest — sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst.</string>
<string name="mqtt_denm_show">Letztes TX anzeigen</string>
<string name="mqtt_denm_hide">Ausblenden</string>
<!-- CAM-basiertes Use-Case-Alarm-Panel -->
<string name="mqtt_usecase_panel_title">CAM-Use-Case-Alarme</string>
<string name="mqtt_usecase_not_connected">Mit OBU verbinden, um CAM-basierte Use Cases auszuwerten</string>
<string name="mqtt_usecase_none_active">Keine aktiven Use-Case-Alarme</string>
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · Annäherung %3$.1f m/s · TTC %4$.1f s · %5$s</string>
<!-- Verständliche Use-Case-Beschreibungen -->
<string name="usecase_narrative_ima_b">Kreuzendes Fahrzeug — %1$.0f s bis Konflikt</string>
<string name="usecase_narrative_ima_s">Stehendes Fahrzeug könnte losfahren — %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Auto biegt rechts auf dich zu — %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Auto biegt links auf dich zu — %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Schnell nahendes Fahrzeug — %1$.0f s</string>
<!-- Einstellungen > Use Case Alerts -->
<string name="settings_usecase_alerts">Use Case Alerts</string>
<string name="settings_usecase_alerts_desc">Ein-/Ausschalten pro Use Case für das CAM-basierte Use-Case-Alarm-Panel im V2X-Monitor. Alle Use Cases sind CAM-only — keiner löst ein DENM aus.</string>
<string name="settings_usecase_alert_levels_title">Alarmstufen-Schwellenwerte (nur lesend)</string>
<string name="settings_usecase_level_warning">Warning</string>
<string name="settings_usecase_level_awareness">Awareness</string>
<string name="settings_usecase_level_info">Info</string>
<string name="settings_usecase_ttc_le">≤ %1$.0f s bis Konflikt</string>
<!-- Dashboard transport -->
<string name="dash_transport_usbc">USB-C</string>
<string name="dash_transport_wifi">WLAN</string>
+25 -3
View File
@@ -166,18 +166,40 @@
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
<string name="mqtt_no_messages">No messages on this topic yet</string>
<!-- DENM TX -->
<!-- DENM TX — manual antenna/RSU-range test tool only, not use-case-driven -->
<string name="mqtt_last_tx">Last transmitted payload:</string>
<string name="mqtt_denm_tx_title">DENM Transmission</string>
<string name="mqtt_denm_tx_title">DENM Transmission (Manual Test)</string>
<string name="mqtt_send_denm">Send Test DENM</string>
<string name="mqtt_stop_denm">Stop DENM</string>
<string name="mqtt_denm_use_case_desc">Aftermarket Stationary Vehicle (causeCode 94)</string>
<string name="mqtt_denm_not_connected">Connect to the OBU to enable DENM triggering</string>
<string name="mqtt_denm_active">DENM active — OBU broadcasting via ITS-G5</string>
<string name="mqtt_denm_hint">Publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg</string>
<string name="mqtt_denm_hint">Manual antenna/RSU range test — publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts.</string>
<string name="mqtt_denm_show">Show last TX</string>
<string name="mqtt_denm_hide">Hide</string>
<!-- CAM-based Use Case Alert panel -->
<string name="mqtt_usecase_panel_title">CAM Use Case Alerts</string>
<string name="mqtt_usecase_not_connected">Connect to the OBU to evaluate CAM-based use cases</string>
<string name="mqtt_usecase_none_active">No active use case alerts</string>
<string name="mqtt_usecase_detail">Station %1$d · %2$.0f m · closing %3$.1f m/s · TTC %4$.1f s · %5$s</string>
<!-- Human-readable use-case narratives (Section 10.4 — not just raw JSON) -->
<string name="usecase_narrative_ima_b">Crossing vehicle ahead — %1$.0f s to conflict</string>
<string name="usecase_narrative_ima_s">Stopped vehicle may pull out — %1$.0f s</string>
<string name="usecase_narrative_rtw_b">Car turning right toward you — %1$.0f s</string>
<string name="usecase_narrative_ltw_b">Car turning left toward you — %1$.0f s</string>
<string name="usecase_narrative_smva_bcw_b">Fast-closing vehicle nearby — %1$.0f s</string>
<!-- Settings > Use Case Alerts -->
<string name="settings_usecase_alerts">Use Case Alerts</string>
<string name="settings_usecase_alerts_desc">Per-use-case enable/disable for the CAM-based Use Case Alert panel on the V2X Monitor screen. All use cases are CAM-only — none of them trigger a DENM.</string>
<string name="settings_usecase_alert_levels_title">Alert level thresholds (read-only)</string>
<string name="settings_usecase_level_warning">Warning</string>
<string name="settings_usecase_level_awareness">Awareness</string>
<string name="settings_usecase_level_info">Info</string>
<string name="settings_usecase_ttc_le">≤ %1$.0f s to conflict</string>
<!-- Dashboard transport -->
<string name="dash_transport_usbc">USB-C</string>
<string name="dash_transport_wifi">Wi-Fi</string>