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