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:
Binary file not shown.
@@ -5,6 +5,8 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_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 -->
|
<!-- Location -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
@@ -21,6 +23,10 @@
|
|||||||
<!-- Keep CPU active while recording with screen off -->
|
<!-- Keep CPU active while recording with screen off -->
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<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.accelerometer" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.sensor.gyroscope" 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" />
|
<uses-feature android:name="android.hardware.sensor.compass" android:required="false" />
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.hawhamburg.micr0bu
|
package com.hawhamburg.micr0bu
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.activity.SystemBarStyle
|
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.SensorsSettingsScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.SettingsScreen
|
import com.hawhamburg.micr0bu.ui.screens.SettingsScreen
|
||||||
import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen
|
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.ui.theme.MicrOBUTheme
|
||||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||||
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
|
import com.hawhamburg.micr0bu.viewmodel.SensorViewModel
|
||||||
@@ -74,7 +78,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val activeTransport by mqttViewModel.activeTransport.collectAsState()
|
val activeTransport by mqttViewModel.activeTransport.collectAsState()
|
||||||
val usbConnected by mqttViewModel.usbConnected.collectAsState()
|
val usbConnected by mqttViewModel.usbConnected.collectAsState()
|
||||||
val obuStationTypeWarning by mqttViewModel.obuStationTypeWarning.collectAsState()
|
val obuStationTypeWarning by mqttViewModel.obuStationTypeWarning.collectAsState()
|
||||||
|
val obuStationType by mqttViewModel.obuStationType.collectAsState()
|
||||||
val tripServiceState by tripViewModel.serviceState.collectAsState()
|
val tripServiceState by tripViewModel.serviceState.collectAsState()
|
||||||
|
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
|
||||||
|
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
||||||
|
|
||||||
MicrOBUTheme(darkTheme = state.darkTheme) {
|
MicrOBUTheme(darkTheme = state.darkTheme) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
@@ -128,6 +135,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
activeTransport = activeTransport,
|
activeTransport = activeTransport,
|
||||||
usbCableConnected = usbConnected,
|
usbCableConnected = usbConnected,
|
||||||
obuStationTypeWarning = obuStationTypeWarning,
|
obuStationTypeWarning = obuStationTypeWarning,
|
||||||
|
obuStationType = obuStationType,
|
||||||
onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
|
onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
|
||||||
onNavigateToSensors = {
|
onNavigateToSensors = {
|
||||||
navController.navigate(Screen.Sensors.route) {
|
navController.navigate(Screen.Sensors.route) {
|
||||||
@@ -147,6 +155,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
state = state,
|
state = state,
|
||||||
mqttConnectionState = mqttConnectionState,
|
mqttConnectionState = mqttConnectionState,
|
||||||
tripServiceState = tripServiceState,
|
tripServiceState = tripServiceState,
|
||||||
|
showBatteryOptPrompt = showBatteryOptPrompt,
|
||||||
onToggleRecording = {
|
onToggleRecording = {
|
||||||
// Start / stop both CSV recording and the event detection service
|
// Start / stop both CSV recording and the event detection service
|
||||||
viewModel.toggleRecording()
|
viewModel.toggleRecording()
|
||||||
@@ -155,6 +164,15 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onOpenSessionLog = {
|
onOpenSessionLog = {
|
||||||
navController.navigate(Screen.Log.route)
|
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) {
|
composable(Screen.Log.route) {
|
||||||
@@ -205,10 +223,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onOpenUnits = { navController.navigate(Screen.SettingsUnits.route) },
|
onOpenUnits = { navController.navigate(Screen.SettingsUnits.route) },
|
||||||
onOpenConnection = { navController.navigate(Screen.SettingsConnection.route) },
|
onOpenConnection = { navController.navigate(Screen.SettingsConnection.route) },
|
||||||
onOpenMqtt = { navController.navigate(Screen.SettingsMqtt.route) },
|
onOpenMqtt = { navController.navigate(Screen.SettingsMqtt.route) },
|
||||||
|
onOpenUseCaseAlerts = { navController.navigate(Screen.SettingsUseCaseAlerts.route) },
|
||||||
onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) },
|
onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) },
|
||||||
onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) },
|
onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Screen.SettingsUseCaseAlerts.route) {
|
||||||
|
UseCaseAlertsSettingsScreen(
|
||||||
|
enabledMap = useCaseEnabledMap,
|
||||||
|
onToggle = mqttViewModel::setUseCaseEnabled,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Screen.SettingsConnection.route) {
|
composable(Screen.SettingsConnection.route) {
|
||||||
ConnectionSettingsScreen(
|
ConnectionSettingsScreen(
|
||||||
mqttPrefs = mqttPrefs,
|
mqttPrefs = mqttPrefs,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.data
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.hawhamburg.micr0bu.viewmodel.RecordingSession
|
import com.hawhamburg.micr0bu.viewmodel.RecordingSession
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -17,28 +20,34 @@ fun sessionFileName(session: RecordingSession): String =
|
|||||||
.replace(" ", "_").replace(":", "-")}.csv"
|
.replace(" ", "_").replace(":", "-")}.csv"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the full CSV content for a session.
|
* Shares the session CSV via Android's share sheet.
|
||||||
* 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.
|
* 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 {
|
suspend fun shareSessionCsv(context: Context, session: RecordingSession) {
|
||||||
val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv")
|
val fileName = sessionFileName(session)
|
||||||
return if (dataFile.exists()) dataFile.readText() else buildSessionCsv(session)
|
val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv")
|
||||||
}
|
val cacheFile = File(context.cacheDir, fileName)
|
||||||
|
|
||||||
fun shareSessionCsv(context: Context, session: RecordingSession) {
|
withContext(Dispatchers.IO) {
|
||||||
val fileName = sessionFileName(session)
|
if (dataFile.exists()) {
|
||||||
val content = readSessionCsvContent(context, session)
|
dataFile.inputStream().buffered().use { input ->
|
||||||
|
cacheFile.outputStream().buffered().use { output ->
|
||||||
// Write to cache so FileProvider can serve it via ACTION_SEND
|
input.copyTo(output)
|
||||||
val file = File(context.cacheDir, fileName).also { it.writeText(content) }
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cacheFile.writeText(buildSessionCsv(session))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val uri = FileProvider.getUriForFile(
|
val uri = FileProvider.getUriForFile(
|
||||||
context,
|
context,
|
||||||
"${context.packageName}.fileprovider",
|
"${context.packageName}.fileprovider",
|
||||||
file,
|
cacheFile,
|
||||||
)
|
)
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "text/csv"
|
type = "text/csv"
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
@@ -48,6 +57,25 @@ fun shareSessionCsv(context: Context, session: RecordingSession) {
|
|||||||
context.startActivity(Intent.createChooser(intent, "Export session"))
|
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,
|
* 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.
|
* 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
|
package com.hawhamburg.micr0bu.data.mqtt
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
@@ -34,6 +35,8 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import org.eclipse.paho.client.mqttv3.MqttMessage as PahoMqttMessage
|
import org.eclipse.paho.client.mqttv3.MqttMessage as PahoMqttMessage
|
||||||
|
|
||||||
|
private const val MAX_MESSAGES_PER_TOPIC = 50
|
||||||
|
|
||||||
private val SUBSCRIBED_TOPICS = listOf(
|
private val SUBSCRIBED_TOPICS = listOf(
|
||||||
"sys/state/heartbeat",
|
"sys/state/heartbeat",
|
||||||
"sys/state/cellular",
|
"sys/state/cellular",
|
||||||
@@ -47,7 +50,6 @@ private val SUBSCRIBED_TOPICS = listOf(
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class MqttRepository @Inject constructor(
|
class MqttRepository @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val prefs: MqttPreferences,
|
private val prefs: MqttPreferences,
|
||||||
private val usbDetector: UsbNetworkDetector,
|
private val usbDetector: UsbNetworkDetector,
|
||||||
) {
|
) {
|
||||||
@@ -64,6 +66,12 @@ class MqttRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
|
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. */
|
/** Active transport derived from persisted prefs. */
|
||||||
val activeTransport: StateFlow<TransportType> = prefs.prefsFlow
|
val activeTransport: StateFlow<TransportType> = prefs.prefsFlow
|
||||||
.map { p ->
|
.map { p ->
|
||||||
@@ -75,6 +83,115 @@ class MqttRepository @Inject constructor(
|
|||||||
@Volatile private var activeClient: MqttAsyncClient? = null
|
@Volatile private var activeClient: MqttAsyncClient? = null
|
||||||
private var connectJob: Job? = 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 ────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun connect() {
|
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.
|
* 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.
|
* 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 connectionLost(cause: Throwable?) { lostSignal.complete(cause) }
|
||||||
|
|
||||||
override fun messageArrived(topic: String, message: PahoMqttMessage) {
|
override fun messageArrived(topic: String, message: PahoMqttMessage) {
|
||||||
_messages.tryEmit(
|
recordMessage(
|
||||||
MqttMessage(
|
MqttMessage(
|
||||||
topic = topic,
|
topic = topic,
|
||||||
payload = message.payload.toString(Charsets.UTF_8),
|
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.
|
// 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.
|
// Without this, traffic would be routed over Wi-Fi even in USB-C mode.
|
||||||
if (usbActive) {
|
if (isUsbC && usbNet != null) {
|
||||||
socketFactory = usbNet!!.socketFactory
|
socketFactory = usbNet.socketFactory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect (bridge callback → coroutine)
|
// Connect (bridge callback → coroutine)
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
client.connect(options, null, object : IMqttActionListener {
|
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) =
|
override fun onFailure(token: IMqttToken, ex: Throwable) =
|
||||||
cont.resumeWithException(ex)
|
cont.resumeWithException(ex)
|
||||||
})
|
})
|
||||||
@@ -230,7 +331,7 @@ class MqttRepository @Inject constructor(
|
|||||||
val qosArr = IntArray(topicsArr.size) { 0 }
|
val qosArr = IntArray(topicsArr.size) { 0 }
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
client.subscribe(topicsArr, qosArr, null, object : IMqttActionListener {
|
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) =
|
override fun onFailure(token: IMqttToken, ex: Throwable) =
|
||||||
cont.resumeWithException(ex)
|
cont.resumeWithException(ex)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
|||||||
private var turningStartTime = 0L
|
private var turningStartTime = 0L
|
||||||
private var turningBothAgreed = false
|
private var turningBothAgreed = false
|
||||||
private var peakGyroTurning = 0.0
|
private var peakGyroTurning = 0.0
|
||||||
|
private var turningEventFired = false
|
||||||
|
|
||||||
// ── Stopping state ────────────────────────────────────────────────────────
|
// ── Stopping state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -169,12 +170,16 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
|||||||
turningStartTime = timestamp
|
turningStartTime = timestamp
|
||||||
peakGyroTurning = 0.0
|
peakGyroTurning = 0.0
|
||||||
turningBothAgreed = false
|
turningBothAgreed = false
|
||||||
|
turningEventFired = false
|
||||||
}
|
}
|
||||||
turningFrames++
|
turningFrames++
|
||||||
peakGyroTurning = maxOf(peakGyroTurning, gyroMagnitude)
|
peakGyroTurning = maxOf(peakGyroTurning, gyroMagnitude)
|
||||||
if (gyroAbove && speedAbove && bearingAbove) turningBothAgreed = true
|
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
|
val confidence = if (turningBothAgreed) Confidence.HIGH else Confidence.LOW
|
||||||
|
|
||||||
_events.tryEmit(
|
_events.tryEmit(
|
||||||
@@ -190,10 +195,11 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
|||||||
durationMs = timestamp - turningStartTime,
|
durationMs = timestamp - turningStartTime,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
turningFrames = 0
|
turningEventFired = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
turningFrames = 0
|
turningFrames = 0
|
||||||
|
turningEventFired = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorEvent
|
import android.hardware.SensorEvent
|
||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
@@ -12,6 +13,7 @@ import android.hardware.SensorManager
|
|||||||
import android.location.Location
|
import android.location.Location
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.google.android.gms.location.FusedLocationProviderClient
|
import com.google.android.gms.location.FusedLocationProviderClient
|
||||||
import com.google.android.gms.location.LocationCallback
|
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.DetectionConfig
|
||||||
import com.hawhamburg.micr0bu.domain.detection.EventDetector
|
import com.hawhamburg.micr0bu.domain.detection.EventDetector
|
||||||
import com.hawhamburg.micr0bu.domain.detection.EventType
|
import com.hawhamburg.micr0bu.domain.detection.EventType
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -44,7 +47,16 @@ import kotlin.math.sqrt
|
|||||||
* • Accelerometer + Gyroscope at [SensorManager.SENSOR_DELAY_GAME] (~50 Hz)
|
* • Accelerometer + Gyroscope at [SensorManager.SENSOR_DELAY_GAME] (~50 Hz)
|
||||||
* • FusedLocationProviderClient at 1 Hz
|
* • FusedLocationProviderClient at 1 Hz
|
||||||
* • Accel/gyro magnitudes + last GPS state → [EventDetector] → Room via [TripRepository]
|
* • 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() {
|
class TripRecordingService : Service() {
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────
|
||||||
@@ -66,6 +78,11 @@ class TripRecordingService : Service() {
|
|||||||
private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob)
|
private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob)
|
||||||
private var timerJob: Job? = null
|
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 ──────────────────────────────────────────────────────────
|
// ── Dependencies ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private lateinit var sensorManager: SensorManager
|
private lateinit var sensorManager: SensorManager
|
||||||
@@ -207,6 +224,14 @@ class TripRecordingService : Service() {
|
|||||||
// ── Trip control ─────────────────────────────────────────────────────────
|
// ── Trip control ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun startTrip() {
|
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()
|
detector.reset()
|
||||||
brakingCount = 0
|
brakingCount = 0
|
||||||
turningCount = 0
|
turningCount = 0
|
||||||
@@ -272,11 +297,21 @@ class TripRecordingService : Service() {
|
|||||||
registerSensors()
|
registerSensors()
|
||||||
requestLocationUpdates()
|
requestLocationUpdates()
|
||||||
|
|
||||||
// Start foreground ASAP (within 5 s required by Android)
|
// Start foreground ASAP (within 5 s required by Android).
|
||||||
startForeground(NOTIFICATION_ID, buildNotification(0L, 0))
|
// 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() {
|
private fun stopTrip() {
|
||||||
|
wakeLock?.let { if (it.isHeld) it.release() }
|
||||||
|
wakeLock = null
|
||||||
timerJob?.cancel()
|
timerJob?.cancel()
|
||||||
unregisterSensors()
|
unregisterSensors()
|
||||||
removeLocationUpdates()
|
removeLocationUpdates()
|
||||||
@@ -377,7 +412,7 @@ class TripRecordingService : Service() {
|
|||||||
.createNotificationChannel(channel)
|
.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(elapsedSeconds: Long, eventCount: Int) =
|
private fun buildNotification(elapsedSeconds: Long) =
|
||||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Recording trip")
|
.setContentTitle("Recording trip")
|
||||||
.setContentText(
|
.setContentText(
|
||||||
@@ -390,17 +425,22 @@ class TripRecordingService : Service() {
|
|||||||
.setContentIntent(
|
.setContentIntent(
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getActivity(
|
||||||
this, 0,
|
this, 0,
|
||||||
Intent(this, MainActivity::class.java),
|
Intent(this, MainActivity::class.java).apply {
|
||||||
PendingIntent.FLAG_IMMUTABLE,
|
// 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()
|
.build()
|
||||||
|
|
||||||
private fun updateNotification() {
|
private fun updateNotification() {
|
||||||
val elapsed = TripServiceBus.state.value.elapsedSeconds
|
val elapsed = TripServiceBus.state.value.elapsedSeconds
|
||||||
val total = brakingCount + turningCount + stoppingCount
|
|
||||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
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 {
|
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 SettingsUnits : Screen("settings/units", R.string.settings_units)
|
||||||
data object SettingsConnection : Screen("settings/connection", R.string.settings_connection)
|
data object SettingsConnection : Screen("settings/connection", R.string.settings_connection)
|
||||||
data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section)
|
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 SettingsDeveloper : Screen("settings/developer", R.string.settings_developer)
|
||||||
data object SettingsAbout : Screen("settings/about", R.string.settings_about)
|
data object SettingsAbout : Screen("settings/about", R.string.settings_about)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ fun DashboardScreen(
|
|||||||
activeTransport: TransportType = TransportType.USB_C,
|
activeTransport: TransportType = TransportType.USB_C,
|
||||||
usbCableConnected: Boolean = false,
|
usbCableConnected: Boolean = false,
|
||||||
obuStationTypeWarning: Boolean = false,
|
obuStationTypeWarning: Boolean = false,
|
||||||
|
obuStationType: Int? = null,
|
||||||
onNavigateToConnection: () -> Unit,
|
onNavigateToConnection: () -> Unit,
|
||||||
onNavigateToSensors: () -> Unit,
|
onNavigateToSensors: () -> Unit,
|
||||||
onNavigateToMap: () -> Unit,
|
onNavigateToMap: () -> Unit,
|
||||||
@@ -195,7 +196,10 @@ fun DashboardScreen(
|
|||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color(0xFFFFC107),
|
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.MessageDirection
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
|
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 com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
@@ -86,6 +91,12 @@ private val DisconnectedGrayBg = Color(0xFF1C2733)
|
|||||||
private val DenmRed = Color(0xFFE53935)
|
private val DenmRed = Color(0xFFE53935)
|
||||||
private val DenmRedBg = Color(0xFF3A0A0A)
|
private val DenmRedBg = Color(0xFF3A0A0A)
|
||||||
private val DenmAmber = Color(0xFFFFC107)
|
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
|
@Composable
|
||||||
fun MqttTopicViewerScreen(
|
fun MqttTopicViewerScreen(
|
||||||
@@ -97,6 +108,9 @@ fun MqttTopicViewerScreen(
|
|||||||
val autoScroll by viewModel.autoScroll.collectAsState()
|
val autoScroll by viewModel.autoScroll.collectAsState()
|
||||||
val denmActive by viewModel.denmActive.collectAsState()
|
val denmActive by viewModel.denmActive.collectAsState()
|
||||||
val lastDenmPayload by viewModel.lastDenmPayload.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
|
// Sort: sys/ topics first (heartbeat/health), then alphabetical
|
||||||
val sortedTopics = topicMessages.keys.sortedWith(
|
val sortedTopics = topicMessages.keys.sortedWith(
|
||||||
@@ -172,14 +186,17 @@ fun MqttTopicViewerScreen(
|
|||||||
connectionState = connectionState,
|
connectionState = connectionState,
|
||||||
denmActive = denmActive,
|
denmActive = denmActive,
|
||||||
lastDenmPayload = lastDenmPayload,
|
lastDenmPayload = lastDenmPayload,
|
||||||
|
activeDenmUseCase = activeDenmUseCase,
|
||||||
|
useCaseAlerts = useCaseAlerts,
|
||||||
onSelectTopic = { viewModel.selectTopic(it) },
|
onSelectTopic = { viewModel.selectTopic(it) },
|
||||||
onSendDenm = { viewModel.sendDenm() },
|
onSendDenm = { viewModel.sendDenm() },
|
||||||
onStopDenm = { viewModel.stopDenm() },
|
onStopDenm = { viewModel.stopDenm() },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MessageDetailPane(
|
MessageDetailPane(
|
||||||
messages = topicMessages[selectedTopic] ?: emptyList(),
|
messages = topicMessages[selectedTopic] ?: emptyList(),
|
||||||
autoScroll = autoScroll,
|
autoScroll = autoScroll,
|
||||||
|
ownStationId = ownStationId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,6 +211,8 @@ private fun TopicListPane(
|
|||||||
connectionState: MqttConnectionState,
|
connectionState: MqttConnectionState,
|
||||||
denmActive: Boolean,
|
denmActive: Boolean,
|
||||||
lastDenmPayload: String?,
|
lastDenmPayload: String?,
|
||||||
|
activeDenmUseCase: String?,
|
||||||
|
useCaseAlerts: List<UseCaseAlert>,
|
||||||
onSelectTopic: (String) -> Unit,
|
onSelectTopic: (String) -> Unit,
|
||||||
onSendDenm: () -> Unit,
|
onSendDenm: () -> Unit,
|
||||||
onStopDenm: () -> Unit,
|
onStopDenm: () -> Unit,
|
||||||
@@ -202,11 +221,17 @@ private fun TopicListPane(
|
|||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
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(
|
DenmTxCard(
|
||||||
isConnected = isConnected,
|
isConnected = isConnected,
|
||||||
denmActive = denmActive,
|
denmActive = denmActive,
|
||||||
lastDenmPayload = lastDenmPayload,
|
lastDenmPayload = lastDenmPayload,
|
||||||
|
activeDenmUseCase = activeDenmUseCase,
|
||||||
onSendDenm = onSendDenm,
|
onSendDenm = onSendDenm,
|
||||||
onStopDenm = onStopDenm,
|
onStopDenm = onStopDenm,
|
||||||
)
|
)
|
||||||
@@ -310,6 +335,7 @@ private fun TopicRow(
|
|||||||
private fun MessageDetailPane(
|
private fun MessageDetailPane(
|
||||||
messages: List<MqttMessage>,
|
messages: List<MqttMessage>,
|
||||||
autoScroll: Boolean,
|
autoScroll: Boolean,
|
||||||
|
ownStationId: Long?,
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@@ -331,18 +357,29 @@ private fun MessageDetailPane(
|
|||||||
|
|
||||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
itemsIndexed(reversed, key = { index, msg -> "${msg.topic}-${msg.timestamp}-$index" }) { _, msg ->
|
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))
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageRow(msg: MqttMessage) {
|
private fun MessageRow(msg: MqttMessage, ownStationId: Long?) {
|
||||||
// Show TX badge for all outbound messages; RX badge only on DENM topics
|
// CAM messages get an OWN/REMOTE badge instead of a TX/RX badge — that's the
|
||||||
// (to avoid noisy badges on high-frequency CAM/SPAT traffic).
|
// distinction the V2X Monitor and Use Case Alert panel actually care about (Section 10.4).
|
||||||
val showBadge = msg.direction == MessageDirection.TX ||
|
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))
|
(msg.direction == MessageDirection.RX && msg.topic.contains("denm", ignoreCase = true))
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -359,7 +396,8 @@ private fun MessageRow(msg: MqttMessage) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontFamily = FontFamily.Monospace,
|
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))
|
Spacer(Modifier.height(4.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -389,6 +427,7 @@ private fun DenmTxCard(
|
|||||||
isConnected: Boolean,
|
isConnected: Boolean,
|
||||||
denmActive: Boolean,
|
denmActive: Boolean,
|
||||||
lastDenmPayload: String?,
|
lastDenmPayload: String?,
|
||||||
|
activeDenmUseCase: String?,
|
||||||
onSendDenm: () -> Unit,
|
onSendDenm: () -> Unit,
|
||||||
onStopDenm: () -> Unit,
|
onStopDenm: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -450,7 +489,7 @@ private fun DenmTxCard(
|
|||||||
shape = RoundedCornerShape(4.dp),
|
shape = RoundedCornerShape(4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"hln-sv",
|
activeDenmUseCase ?: DenmUseCase.STATIONARY.id,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = if (denmActive) DenmRed
|
color = if (denmActive) DenmRed
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
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 ────────────────────────────────────────────────────
|
// ── Connection status chip ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.FiberManualRecord
|
import androidx.compose.material.icons.filled.FiberManualRecord
|
||||||
import androidx.compose.material.icons.filled.TableChart
|
import androidx.compose.material.icons.filled.TableChart
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -25,6 +26,7 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -44,10 +46,33 @@ fun RecordingScreen(
|
|||||||
state: SensorUiState,
|
state: SensorUiState,
|
||||||
mqttConnectionState: MqttConnectionState,
|
mqttConnectionState: MqttConnectionState,
|
||||||
tripServiceState: TripServiceBus.State,
|
tripServiceState: TripServiceBus.State,
|
||||||
|
showBatteryOptPrompt: Boolean,
|
||||||
onToggleRecording: () -> Unit,
|
onToggleRecording: () -> Unit,
|
||||||
onOpenSessionLog: () -> Unit,
|
onOpenSessionLog: () -> Unit,
|
||||||
|
onOpenBatterySettings: () -> Unit,
|
||||||
|
onDismissBatteryOptPrompt: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hawhamburg.micr0bu.R
|
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.sessionFileName
|
||||||
import com.hawhamburg.micr0bu.data.shareSessionCsv
|
import com.hawhamburg.micr0bu.data.shareSessionCsv
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import com.hawhamburg.micr0bu.viewmodel.RecordingSession
|
import com.hawhamburg.micr0bu.viewmodel.RecordingSession
|
||||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -56,6 +58,7 @@ fun SessionLogScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
var pendingDelete by remember { mutableStateOf<RecordingSession?>(null) }
|
var pendingDelete by remember { mutableStateOf<RecordingSession?>(null) }
|
||||||
var pendingSave by remember { mutableStateOf<RecordingSession?>(null) }
|
var pendingSave by remember { mutableStateOf<RecordingSession?>(null) }
|
||||||
|
|
||||||
@@ -64,8 +67,8 @@ fun SessionLogScreen(
|
|||||||
) { uri ->
|
) { uri ->
|
||||||
uri ?: return@rememberLauncherForActivityResult
|
uri ?: return@rememberLauncherForActivityResult
|
||||||
val session = pendingSave ?: return@rememberLauncherForActivityResult
|
val session = pendingSave ?: return@rememberLauncherForActivityResult
|
||||||
context.contentResolver.openOutputStream(uri)?.use { it.write(readSessionCsvContent(context, session).toByteArray(Charsets.UTF_8)) }
|
|
||||||
pendingSave = null
|
pendingSave = null
|
||||||
|
scope.launch { saveSessionCsvToUri(context, session, uri) }
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingDelete?.let { session ->
|
pendingDelete?.let { session ->
|
||||||
@@ -103,7 +106,7 @@ fun SessionLogScreen(
|
|||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
index = state.sessions.size - index,
|
index = state.sessions.size - index,
|
||||||
onShare = { shareSessionCsv(context, session) },
|
onShare = { scope.launch { shareSessionCsv(context, session) } },
|
||||||
onSaveToDevice = { pendingSave = session; saveLauncher.launch(sessionFileName(session)) },
|
onSaveToDevice = { pendingSave = session; saveLauncher.launch(sessionFileName(session)) },
|
||||||
onDelete = { pendingDelete = session },
|
onDelete = { pendingDelete = session },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import com.hawhamburg.micr0bu.R
|
import com.hawhamburg.micr0bu.R
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
|
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
|
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||||
|
|
||||||
private data class LangOption(val tag: String, val flag: String, val labelRes: Int)
|
private data class LangOption(val tag: String, val flag: String, val labelRes: Int)
|
||||||
@@ -66,6 +68,7 @@ fun SettingsScreen(
|
|||||||
onOpenUnits: () -> Unit,
|
onOpenUnits: () -> Unit,
|
||||||
onOpenConnection: () -> Unit,
|
onOpenConnection: () -> Unit,
|
||||||
onOpenMqtt: () -> Unit,
|
onOpenMqtt: () -> Unit,
|
||||||
|
onOpenUseCaseAlerts: () -> Unit,
|
||||||
onOpenDeveloper: () -> Unit,
|
onOpenDeveloper: () -> Unit,
|
||||||
onOpenAbout: () -> Unit,
|
onOpenAbout: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -99,6 +102,8 @@ fun SettingsScreen(
|
|||||||
RowDivider()
|
RowDivider()
|
||||||
MenuRow(stringResource(R.string.settings_mqtt_section), onOpenMqtt)
|
MenuRow(stringResource(R.string.settings_mqtt_section), onOpenMqtt)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
|
MenuRow(stringResource(R.string.settings_usecase_alerts), onOpenUseCaseAlerts)
|
||||||
|
RowDivider()
|
||||||
MenuRow(stringResource(R.string.settings_developer), onOpenDeveloper)
|
MenuRow(stringResource(R.string.settings_developer), onOpenDeveloper)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
MenuRow(stringResource(R.string.settings_about), onOpenAbout)
|
MenuRow(stringResource(R.string.settings_about), onOpenAbout)
|
||||||
@@ -284,7 +289,57 @@ fun MqttSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
SubScreen(stringResource(R.string.settings_mqtt_section), onBack) {
|
SubScreen(stringResource(R.string.settings_mqtt_section), onBack) {
|
||||||
SectionCard {
|
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
|
@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
|
// 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.
|
// external save (e.g. via the Save button), while preserving in-progress edits.
|
||||||
var ip by remember(prefs.brokerIp) { mutableStateOf(prefs.brokerIp) }
|
var ip by remember(prefs.brokerIp) { mutableStateOf(prefs.brokerIp) }
|
||||||
@@ -401,6 +460,7 @@ private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> Unit)
|
|||||||
password = password,
|
password = password,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
onSave()
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.hawhamburg.micr0bu.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.MqttConnectionState
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
|
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
|
||||||
import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences
|
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.mqtt.MqttRepository
|
||||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||||
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -17,29 +20,23 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import javax.inject.Inject
|
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
|
@HiltViewModel
|
||||||
class MqttViewModel @Inject constructor(
|
class MqttViewModel @Inject constructor(
|
||||||
private val repo: MqttRepository,
|
private val repo: MqttRepository,
|
||||||
private val prefs: MqttPreferences,
|
private val prefs: MqttPreferences,
|
||||||
private val usbDetector: UsbNetworkDetector,
|
private val usbDetector: UsbNetworkDetector,
|
||||||
|
private val camUseCaseRepository: CamUseCaseRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
// ── MQTT connection & messages ────────────────────────────────────────────
|
// ── MQTT connection & messages ────────────────────────────────────────────
|
||||||
|
|
||||||
val connectionState: StateFlow<MqttConnectionState> = repo.connectionState
|
val connectionState: StateFlow<MqttConnectionState> = repo.connectionState
|
||||||
|
|
||||||
private val _topicMessages = MutableStateFlow<Map<String, List<MqttMessage>>>(emptyMap())
|
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = repo.topicMessages
|
||||||
val topicMessages: StateFlow<Map<String, List<MqttMessage>>> = _topicMessages.asStateFlow()
|
|
||||||
|
|
||||||
private val _selectedTopic = MutableStateFlow<String?>(null)
|
private val _selectedTopic = MutableStateFlow<String?>(null)
|
||||||
val selectedTopic: StateFlow<String?> = _selectedTopic.asStateFlow()
|
val selectedTopic: StateFlow<String?> = _selectedTopic.asStateFlow()
|
||||||
@@ -69,11 +66,16 @@ class MqttViewModel @Inject constructor(
|
|||||||
|
|
||||||
// ── OBU identity (parsed from v2x/rx/obu_gnss own_info) ──────────────────
|
// ── 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
|
* 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.
|
* 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()
|
val obuStationType: StateFlow<Int?> = _obuStationType.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,30 +89,42 @@ class MqttViewModel @Inject constructor(
|
|||||||
|
|
||||||
// ── DENM transmission ─────────────────────────────────────────────────────
|
// ── DENM transmission ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private val _denmActive = MutableStateFlow(false)
|
/** True while a DENM use case is actively broadcasting on the OBU. */
|
||||||
/** True while the DENM use case is actively broadcasting on the OBU. */
|
val denmActive: StateFlow<Boolean> = repo.denmActive
|
||||||
val denmActive: StateFlow<Boolean> = _denmActive.asStateFlow()
|
|
||||||
|
|
||||||
private val _lastDenmPayload = MutableStateFlow<String?>(null)
|
|
||||||
/** JSON string of the most recently transmitted DENM control message. */
|
/** 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. */
|
/** The use case id currently active on the OBU, if any (e.g. for showing in the UI). */
|
||||||
private var activeDenmUseCase: String? = null
|
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 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
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 ->
|
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") {
|
if (msg.topic == "v2x/rx/obu_gnss") {
|
||||||
runCatching {
|
runCatching {
|
||||||
val stType = JSONObject(msg.payload)
|
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
|
* 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) {
|
fun sendDenm(useCase: String = DenmUseCase.STATIONARY.id) {
|
||||||
if (_denmActive.value) return // prevent concurrent activations
|
repo.activateDenm(useCase)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish a uca-denmctrl deactivate message (retained).
|
* Publish a uca-denmctrl deactivate message (retained) for whichever use case is
|
||||||
* Uses the same usecase string as the activate call so the OBU phases out the correct DENM.
|
* currently active.
|
||||||
*/
|
*/
|
||||||
fun stopDenm() {
|
fun stopDenm() {
|
||||||
if (!_denmActive.value) return
|
repo.deactivateDenm()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.hawhamburg.micr0bu.viewmodel
|
package com.hawhamburg.micr0bu.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.PowerManager
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.hawhamburg.micr0bu.data.TripRepository
|
import com.hawhamburg.micr0bu.data.TripRepository
|
||||||
@@ -27,6 +29,32 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
|
|
||||||
private val repository = TripRepository(AppDatabase.getInstance(application))
|
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) ───────────────────────────────
|
// ── Service state (live during a recording) ───────────────────────────────
|
||||||
|
|
||||||
/** Live recording state forwarded from [TripServiceBus]. */
|
/** Live recording state forwarded from [TripServiceBus]. */
|
||||||
|
|||||||
@@ -165,18 +165,40 @@
|
|||||||
<string name="mqtt_no_topics_hint">Mit der OBU verbinden und auf V2X-Verkehr warten</string>
|
<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>
|
<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_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_send_denm">Test-DENM senden</string>
|
||||||
<string name="mqtt_stop_denm">DENM stoppen</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_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_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_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_show">Letztes TX anzeigen</string>
|
||||||
<string name="mqtt_denm_hide">Ausblenden</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 -->
|
<!-- Dashboard transport -->
|
||||||
<string name="dash_transport_usbc">USB-C</string>
|
<string name="dash_transport_usbc">USB-C</string>
|
||||||
<string name="dash_transport_wifi">WLAN</string>
|
<string name="dash_transport_wifi">WLAN</string>
|
||||||
|
|||||||
@@ -166,18 +166,40 @@
|
|||||||
<string name="mqtt_no_topics_hint">Connect to the OBU and wait for V2X traffic</string>
|
<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>
|
<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_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_send_denm">Send Test DENM</string>
|
||||||
<string name="mqtt_stop_denm">Stop 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_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_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_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_show">Show last TX</string>
|
||||||
<string name="mqtt_denm_hide">Hide</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 -->
|
<!-- Dashboard transport -->
|
||||||
<string name="dash_transport_usbc">USB-C</string>
|
<string name="dash_transport_usbc">USB-C</string>
|
||||||
<string name="dash_transport_wifi">Wi-Fi</string>
|
<string name="dash_transport_wifi">Wi-Fi</string>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
agp = "8.7.3"
|
agp = "8.7.3"
|
||||||
kotlin = "2.1.0"
|
kotlin = "2.1.0"
|
||||||
ksp = "2.1.0-1.0.29"
|
ksp = "2.1.0-1.0.29"
|
||||||
hilt = "2.51.1"
|
hilt = "2.56.2"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
hiltNavigationCompose = "1.2.0"
|
hiltNavigationCompose = "1.2.0"
|
||||||
datastore = "1.1.1"
|
datastore = "1.1.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user