diff --git a/app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetJson.kt b/app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetJson.kt index f33d90846..be04b478c 100644 --- a/app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetJson.kt +++ b/app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetJson.kt @@ -162,6 +162,9 @@ object OverlayPresetJson { put("dialShowColorBand", el.dialShowColorBand) put("dialOrangeThresholdPct", el.dialOrangeThresholdPct) put("dialRedThresholdPct", el.dialRedThresholdPct) + put("radarMode", el.radarMode) + put("radarRangeM", el.radarRangeM.toDouble()) + put("radarShowDistanceLabels", el.radarShowDistanceLabels) } private fun elementFromJson(o: JSONObject): OverlayElement? { @@ -225,7 +228,12 @@ object OverlayPresetJson { unitPosition = o.optString("unitPosition", d.unitPosition), dialShowColorBand = o.optBoolean("dialShowColorBand", d.dialShowColorBand), dialOrangeThresholdPct = o.optInt("dialOrangeThresholdPct", d.dialOrangeThresholdPct), - dialRedThresholdPct = o.optInt("dialRedThresholdPct", d.dialRedThresholdPct) + dialRedThresholdPct = o.optInt("dialRedThresholdPct", d.dialRedThresholdPct), + radarMode = o.optString("radarMode", d.radarMode), + radarRangeM = o.optDouble("radarRangeM", d.radarRangeM.toDouble()).toFloat(), + radarShowDistanceLabels = o.optBoolean( + "radarShowDistanceLabels", d.radarShowDistanceLabels + ) ) } diff --git a/app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetStore.kt b/app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetStore.kt index 14debc568..cdbf8c51b 100644 --- a/app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetStore.kt +++ b/app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetStore.kt @@ -99,18 +99,26 @@ class OverlayPresetStore @Inject constructor( .sortedBy { it.lowercase() } } - /** Write [preset] as `.json`, overwriting any file with that name. */ + /** Write [preset] as `.json`. If a file with that name already + * exists it is overwritten IN PLACE (same document), not deleted and + * recreated: `delete()` + `createFile()` races the SAF provider, which + * then dedupes the new file to `name (1).json` instead of overwriting -- + * exactly the bug a rider hit when tapping "Overwrite". The "wt" mode + * truncates so a shorter preset doesn't leave stale trailing bytes. + * + * To deliberately keep both copies, the caller passes a fresh unique name + * (e.g. "name (1)") -- which won't be found here, so a new file is made. */ suspend fun savePreset(name: String, preset: OverlayPreset): Boolean = withContext(Dispatchers.IO) { val safe = syncManager.sanitizeBackupName(name) ?: return@withContext false val folder = overlaysFolder() ?: return@withContext false val fileName = "$safe$PRESET_SUFFIX" runCatching { - folder.findFile(fileName)?.delete() - val file = folder.createFile("application/json", fileName) - ?: return@withContext false val json = OverlayPresetJson.toJson(preset.copy(name = safe)).toString(2) - context.contentResolver.openOutputStream(file.uri)?.use { out -> + val target = (folder.findFile(fileName) + ?: folder.createFile("application/json", fileName))?.uri + ?: return@withContext false + context.contentResolver.openOutputStream(target, "wt")?.use { out -> out.write(json.toByteArray(Charsets.UTF_8)) } ?: return@withContext false true diff --git a/app/src/main/java/com/eried/eucplanet/service/hud/HudDemoSource.kt b/app/src/main/java/com/eried/eucplanet/service/hud/HudDemoSource.kt index 71dc731c8..c325e4805 100644 --- a/app/src/main/java/com/eried/eucplanet/service/hud/HudDemoSource.kt +++ b/app/src/main/java/com/eried/eucplanet/service/hud/HudDemoSource.kt @@ -2,6 +2,7 @@ package com.eried.eucplanet.service.hud import android.util.Log import com.eried.eucplanet.hud.protocol.HudState +import com.eried.eucplanet.hud.protocol.RadarTargetWire import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -115,6 +116,11 @@ class HudDemoSource { } val arrived = phase > 0.97 + // Rear-view radar: a main car closing 140 -> 8 m across the loop, plus + // a couple of extras phased in so the widget shows several colour + // bands (green far, amber mid, red near) and multiple blips at once. + val radarTargets = radarTargets(phase) + return Frame( speedKmh = speedKmh, batteryPct = battery, @@ -131,10 +137,42 @@ class HudDemoSource { navAngleDeg = turnAngle, navPrimary = navPrimary, navDistance = if (arrived) "" else "${distM} m", - navArrived = arrived + navArrived = arrived, + radarConnected = true, + radarBatteryPercent = 78, + radarTargets = radarTargets ) } + /** Scripted radar cars for the demo. Distances chosen so a full loop + * walks the LANE/MIRROR colour bands; ids stay stable while a car is + * present so the renderer can track it. */ + private fun radarTargets(phase: Double): List { + val targets = mutableListOf() + // Main car: closes steadily over the whole cycle. + val mainDist = (140.0 - 132.0 * phase).toInt().coerceIn(6, 140) + targets += RadarTargetWire(1, mainDist, 22, radarLevel(mainDist)) + // Second car: drifts in for the back two-thirds of the loop. + if (phase > 0.35) { + val d2 = (95.0 - 60.0 * ((phase - 0.35) / 0.65)).toInt().coerceIn(20, 95) + targets += RadarTargetWire(2, d2, 9, radarLevel(d2)) + } + // Third car: a brief fast approacher mid-cycle (always red). + if (phase in 0.45..0.75) { + val d3 = (40.0 - 30.0 * ((phase - 0.45) / 0.30)).toInt().coerceIn(8, 40) + targets += RadarTargetWire(3, d3, 46, 2) + } + return targets + } + + /** Demo threat level by distance: red <=30 m, amber <=80 m, else green. + * Mirrors the visual bands the LANE/MIRROR renderers use. */ + private fun radarLevel(distM: Int): Int = when { + distM <= 30 -> 2 + distM <= 80 -> 1 + else -> 0 + } + data class Frame( val speedKmh: Float = 0f, val batteryPct: Int = 100, @@ -150,6 +188,9 @@ class HudDemoSource { val navAngleDeg: Float = 0f, val navPrimary: String = "", val navDistance: String = "", - val navArrived: Boolean = false + val navArrived: Boolean = false, + val radarConnected: Boolean = false, + val radarBatteryPercent: Int = -1, + val radarTargets: List = emptyList() ) } diff --git a/app/src/main/java/com/eried/eucplanet/service/hud/HudServer.kt b/app/src/main/java/com/eried/eucplanet/service/hud/HudServer.kt index 0b079134c..a013d3f64 100644 --- a/app/src/main/java/com/eried/eucplanet/service/hud/HudServer.kt +++ b/app/src/main/java/com/eried/eucplanet/service/hud/HudServer.kt @@ -11,6 +11,7 @@ import com.eried.eucplanet.ble.ConnectionState import com.eried.eucplanet.data.model.AppSettings import com.eried.eucplanet.data.model.arrowAngleDeg import com.eried.eucplanet.data.repository.ExternalGpsRepository +import com.eried.eucplanet.data.repository.RadarRepository import com.eried.eucplanet.data.repository.SettingsRepository import com.eried.eucplanet.data.repository.TripRepository import com.eried.eucplanet.data.repository.WheelRepository @@ -18,6 +19,7 @@ import com.eried.eucplanet.hud.protocol.HudCommand import com.eried.eucplanet.hud.protocol.HudDebug import com.eried.eucplanet.hud.protocol.HudDiscovery import com.eried.eucplanet.hud.protocol.HudState +import com.eried.eucplanet.hud.protocol.RadarTargetWire import com.eried.eucplanet.nav.NavigationEngine import com.eried.eucplanet.ui.theme.AccentOptions import com.eried.eucplanet.ui.theme.AccentTeal @@ -75,6 +77,7 @@ class HudServer @Inject constructor( private val settingsRepository: SettingsRepository, private val externalGpsRepository: ExternalGpsRepository, private val tripRepository: TripRepository, + private val radarRepository: RadarRepository, private val navigationEngine: NavigationEngine, private val commandSink: HudCommandSink, private val themeController: com.eried.eucplanet.ui.theme.ThemeController, @@ -134,7 +137,13 @@ class HudServer @Inject constructor( private val lifecycleLock = Mutex() private val http: OkHttpClient = OkHttpClient.Builder() - .pingInterval(15, TimeUnit.SECONDS) + // 5s, not 15s: a frozen/half-open HUD keeps the TCP socket open, so + // outbound frame sends succeed into the OS buffer and never error -- + // only a missed pong reveals the dead peer. On the local hotspot link + // (sub-10ms RTT) a 5s ping detects a dead HUD in ~5s and triggers the + // dial-loop's rediscover+reconnect, instead of the rider staring at a + // frozen HUD for 15s+. Cheap on a LAN. + .pingInterval(5, TimeUnit.SECONDS) .connectTimeout(4, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) .retryOnConnectionFailure(true) @@ -750,6 +759,12 @@ class HudServer @Inject constructor( val d = if (demo.active) demo.frame else null + // Rear-view radar (Varia). In demo mode the synthetic source supplies + // scripted cars; otherwise read the live frame. "connected" gates the + // HUD radar widget between "idle / no radar" and "lane clear". + val radarFrame = radarRepository.currentFrame.value + val radarLive = radarRepository.connectionState.value == ConnectionState.CONNECTED + // Debug-only protocol-version overrides so a tester can drive // the version-mismatch UI on a single APK pair without rebuilding. // Set via: @@ -841,6 +856,18 @@ class HudServer @Inject constructor( joystickDown = joystickLabel(s.hudActionDown), joystickLeft = joystickLabel(s.hudActionLeft), joystickRight = joystickLabel(s.hudActionRight), + radarConnected = if (d != null) d.radarConnected else radarLive, + radarBatteryPercent = if (d != null) d.radarBatteryPercent + else (radarFrame?.batteryPercent ?: -1), + radarTargets = if (d != null) d.radarTargets + else radarFrame?.threats?.take(8)?.map { + RadarTargetWire( + id = it.id, + distanceM = it.distanceM, + approachSpeedKmh = it.approachSpeedKmh, + level = it.threatLevel.ordinal + ) + }.orEmpty(), timestampMs = System.currentTimeMillis() ) } diff --git a/app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioScreen.kt b/app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioScreen.kt index b7997dd60..d93eb358a 100644 --- a/app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioScreen.kt +++ b/app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioScreen.kt @@ -141,6 +141,7 @@ fun OverlayStudioScreen( val trips by viewModel.trips.collectAsState() val wheelName by viewModel.wheelName.collectAsState() val connected by viewModel.connected.collectAsState() + val radar by viewModel.radar.collectAsState() val history by viewModel.history.collectAsState() val folderAvailable by viewModel.folderAvailable.collectAsState() val savedPresets by viewModel.savedPresets.collectAsState() @@ -938,7 +939,12 @@ fun OverlayStudioScreen( // this empty; the overlay falls back to wheelData / // history derived from the scrubbed trip row. liveGForceTrail = if (replayMode) emptyList() else liveGForceTrail, - riderMarkerPhotoDataUrl = riderMarkerPhoto + riderMarkerPhotoDataUrl = riderMarkerPhoto, + // Radar has no trip-replay history (CSV doesn't store it), + // so it only feeds the live preview; replay shows idle. + radarConnected = if (replayMode) false else radar.connected, + radarBatteryPercent = if (replayMode) -1 else radar.batteryPercent, + radarTargets = if (replayMode) emptyList() else radar.targets ), editable = editable, selectedId = selectedId, @@ -1322,6 +1328,15 @@ fun OverlayStudioScreen( val existingNames = remember(savedPresets) { savedPresets.map { it.trim().lowercase() }.toSet() } + // First free "base (n)" for the "Keep both" choice. + fun uniqueName(base: String): String { + var n = 1 + var candidate = "$base ($n)" + while (candidate.trim().lowercase() in existingNames) { + n++; candidate = "$base ($n)" + } + return candidate + } fun doSave(name: String) { viewModel.savePresetAs(name) { result -> val msg = when (result) { @@ -1358,10 +1373,21 @@ fun OverlayStudioScreen( title = { Text(stringResource(R.string.studio_overwrite_title)) }, text = { Text(stringResource(R.string.studio_overwrite_body, pending)) }, confirmButton = { - androidx.compose.material3.TextButton(onClick = { - pendingOverwriteName = null - doSave(pending) - }) { Text(stringResource(R.string.studio_overwrite_confirm)) } + androidx.compose.foundation.layout.Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Keep both: save under the next free "name (n)" + // instead of replacing the existing preset. + androidx.compose.material3.TextButton(onClick = { + val keep = uniqueName(pending) + pendingOverwriteName = null + doSave(keep) + }) { Text(stringResource(R.string.studio_save_keep_both)) } + androidx.compose.material3.TextButton(onClick = { + pendingOverwriteName = null + doSave(pending) + }) { Text(stringResource(R.string.studio_overwrite_confirm)) } + } }, dismissButton = { androidx.compose.material3.TextButton(onClick = { @@ -1557,6 +1583,22 @@ private fun newElement( rotationDeg = ((360 - deviceRotation) % 360).toFloat() ) } + if (type == OverlayElementType.RADAR) { + // Narrow by default: the LANE mode is a tall proximity bar. The rider + // widens it or switches to MIRROR/MINIMAL from the config sheet. + return OverlayElement( + type = type, + x = nx, + y = ny, + width = 0.2f, + radarMode = "LANE", + radarRangeM = 140f, + radarShowDistanceLabels = true, + foreground = 0xFFFFFFFFL, // lane lines / labels / rider marker + background = 0x66000000L, + rotationDeg = ((360 - deviceRotation) % 360).toFloat() + ) + } return OverlayElement( type = type, x = nx, diff --git a/app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioViewModel.kt b/app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioViewModel.kt index 18dd29d0a..1a4d31509 100644 --- a/app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioViewModel.kt +++ b/app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioViewModel.kt @@ -5,12 +5,14 @@ import androidx.lifecycle.viewModelScope import com.eried.eucplanet.ble.ConnectionState import com.eried.eucplanet.hud.protocol.OverlayElement import com.eried.eucplanet.hud.protocol.OverlayPreset +import com.eried.eucplanet.hud.protocol.RadarTargetWire import com.eried.eucplanet.hud.protocol.ViewportConfig import com.eried.eucplanet.hud.protocol.ViewportLayout import com.eried.eucplanet.data.model.TripRecord import com.eried.eucplanet.data.model.WheelData import com.eried.eucplanet.data.repository.ExternalGpsRepository import com.eried.eucplanet.data.repository.PhoneSensorRepository +import com.eried.eucplanet.data.repository.RadarRepository import com.eried.eucplanet.data.repository.SettingsRepository import com.eried.eucplanet.data.repository.TripRepository import com.eried.eucplanet.data.repository.WheelRepository @@ -86,6 +88,13 @@ data class ReplayExportPrefs( /** Outcome of a "save preset" attempt, surfaced to the UI as a snackbar. */ enum class PresetSaveResult { SAVED, NO_FOLDER, FAILED } +/** Snapshot of rear-view radar state handed to the Studio's RADAR element. */ +data class StudioRadarState( + val connected: Boolean = false, + val batteryPercent: Int = -1, + val targets: List = emptyList() +) + /** * Holds the working Overlay Studio layout and feeds the studio screen its live * telemetry. The layout is a single [OverlayPreset] mutated through the `update` @@ -100,6 +109,7 @@ class OverlayStudioViewModel @Inject constructor( private val tripRepository: TripRepository, private val phoneSensorRepository: PhoneSensorRepository, private val externalGpsRepository: ExternalGpsRepository, + private val radarRepository: RadarRepository, private val navMarkerStore: com.eried.eucplanet.data.store.NavMarkerStore ) : ViewModel() { @@ -180,6 +190,31 @@ class OverlayStudioViewModel @Inject constructor( .map { it == ConnectionState.CONNECTED } .stateIn(viewModelScope, SharingStarted.Eagerly, false) + /** + * Live rear-view radar state for the RADAR overlay element's Studio + * preview. Targets are mapped to the same compact [RadarTargetWire] the + * HUD receives so the phone preview and the mirrored HUD render share one + * renderer shape. Empty / not-connected when no radar is paired; the + * widget then shows its idle "No radar" state. + */ + val radar: StateFlow = combine( + radarRepository.currentFrame, + radarRepository.connectionState + ) { frame, conn -> + StudioRadarState( + connected = conn == ConnectionState.CONNECTED, + batteryPercent = frame?.batteryPercent ?: -1, + targets = frame?.threats?.take(8)?.map { + RadarTargetWire( + id = it.id, + distanceM = it.distanceM, + approachSpeedKmh = it.approachSpeedKmh, + level = it.threatLevel.ordinal + ) + } ?: emptyList() + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, StudioRadarState()) + /** * Base64 `data:image/png` URL of the rider's custom marker photo (set in * the Navigator) or null when none has been picked. Exposed here so the diff --git a/app/src/main/java/com/eried/eucplanet/ui/studio/StudioConfigSheets.kt b/app/src/main/java/com/eried/eucplanet/ui/studio/StudioConfigSheets.kt index 44813d3d9..47b3fe507 100644 --- a/app/src/main/java/com/eried/eucplanet/ui/studio/StudioConfigSheets.kt +++ b/app/src/main/java/com/eried/eucplanet/ui/studio/StudioConfigSheets.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.Colorize +import androidx.compose.material.icons.filled.Radar import androidx.compose.material.icons.filled.TrackChanges import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Delete @@ -214,6 +215,7 @@ private val OverlayElementType.labelRes: Int OverlayElementType.CLOCK -> R.string.studio_element_clock OverlayElementType.G_FORCE -> R.string.studio_element_g_force OverlayElementType.MAP -> R.string.studio_element_map + OverlayElementType.RADAR -> R.string.studio_element_radar } @Composable @@ -233,6 +235,7 @@ private val OverlayElementType.icon OverlayElementType.CLOCK -> Icons.Default.Schedule OverlayElementType.G_FORCE -> Icons.Default.TrackChanges OverlayElementType.MAP -> Icons.Default.Map + OverlayElementType.RADAR -> Icons.Default.Radar } // -------------------------------------------------------------------------- @@ -582,7 +585,8 @@ private val ADD_ELEMENT_GROUPS: List>> = list OverlayElementType.DATA_DIAL, // Dial gauge OverlayElementType.G_FORCE, // G-Force OverlayElementType.DATA_BAR, // Linear bar - OverlayElementType.MAP // Map + OverlayElementType.MAP, // Map + OverlayElementType.RADAR // Rear-view radar ), R.string.studio_group_text to listOf( OverlayElementType.APP_BADGE, // App badge @@ -663,6 +667,7 @@ private fun elementHint(type: OverlayElementType): String = when (type) { OverlayElementType.CLOCK -> stringResource(R.string.studio_hint_clock) OverlayElementType.G_FORCE -> stringResource(R.string.studio_hint_g_force) OverlayElementType.MAP -> stringResource(R.string.studio_hint_map) + OverlayElementType.RADAR -> stringResource(R.string.studio_hint_radar) } // -------------------------------------------------------------------------- @@ -1855,6 +1860,53 @@ fun ElementConfigSheet( Spacer(Modifier.height(8.dp)) } + if (element.type == OverlayElementType.RADAR) { + Text(stringResource(R.string.studio_cfg_radar_mode), fontWeight = FontWeight.SemiBold) + val radarLane = stringResource(R.string.studio_cfg_radar_lane) + val radarMirror = stringResource(R.string.studio_cfg_radar_mirror) + val radarMinimal = stringResource(R.string.studio_cfg_radar_minimal) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + "LANE" to radarLane, "MIRROR" to radarMirror, + "MINIMAL" to radarMinimal + ).forEach { (key, lbl) -> + FilterChip( + selected = element.radarMode == key, + onClick = { onChange(element.copy(radarMode = key)) }, + label = { Text(lbl) }, + colors = themedFilterChipColors(), + ) + } + } + // The Varia is rear-facing only (no left/right bearing), so the + // Mirror view lights both sides together. Spell that out so the + // rider isn't surprised it can't point at a specific lane. + if (element.radarMode == "MIRROR") { + Text( + stringResource(R.string.studio_cfg_radar_mirror_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp) + ) + } + LabeledSlider( + stringResource(R.string.studio_cfg_radar_range), + stringResource(R.string.studio_cfg_radar_range_fmt, element.radarRangeM.roundToInt()), + element.radarRangeM, 40f, 200f, steps = 7 + ) { onChange(element.copy(radarRangeM = it)) } + if (element.radarMode == "LANE") { + ToggleRow( + stringResource(R.string.studio_cfg_radar_distance_labels), + element.radarShowDistanceLabels + ) { onChange(element.copy(radarShowDistanceLabels = it)) } + ToggleRow( + stringResource(R.string.studio_cfg_show_label), + element.showLabel + ) { onChange(element.copy(showLabel = it)) } + } + Spacer(Modifier.height(8.dp)) + } + if (element.type == OverlayElementType.FLOATING_CAMERA) { Text(stringResource(R.string.studio_cfg_camera_label), fontWeight = FontWeight.SemiBold) CameraPicker(cameras, element.cameraKey, inUseKeys) { diff --git a/app/src/main/java/com/eried/eucplanet/ui/studio/StudioOverlayElements.kt b/app/src/main/java/com/eried/eucplanet/ui/studio/StudioOverlayElements.kt index 1f893a265..c27407ae5 100644 --- a/app/src/main/java/com/eried/eucplanet/ui/studio/StudioOverlayElements.kt +++ b/app/src/main/java/com/eried/eucplanet/ui/studio/StudioOverlayElements.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.OpenInFull import androidx.compose.material.icons.filled.Sync import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -63,6 +64,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.StrokeCap @@ -124,7 +126,15 @@ data class StudioElementData( * this in place of its default dot marker when its * [OverlayElement.mapUseCustomMarker] flag is on and a photo is set. */ - val riderMarkerPhotoDataUrl: String? = null + val riderMarkerPhotoDataUrl: String? = null, + /** Rear-view radar: true when a radar is paired and linked. Lets the + * RADAR element tell "no radar" apart from "lane clear". */ + val radarConnected: Boolean = false, + /** Radar device battery %, or -1 when unknown / no radar. */ + val radarBatteryPercent: Int = -1, + /** Vehicles the radar currently tracks (empty = clear lane). Uses the + * same compact wire type the HUD receives so both renderers match. */ + val radarTargets: List = emptyList() ) /** Compose [Color] -> the 0xAARRGGBB [Long] stored in [OverlayElement]. */ @@ -598,6 +608,7 @@ private fun minRenderedHeightDp(type: OverlayElementType): androidx.compose.ui.u OverlayElementType.DATA_GRAPH -> 36.dp OverlayElementType.MAP -> 60.dp OverlayElementType.FLOATING_CAMERA -> 60.dp + OverlayElementType.RADAR -> 56.dp else -> 16.dp } @@ -617,6 +628,295 @@ private fun ElementContent(element: OverlayElement, data: StudioElementData) { OverlayElementType.CLOCK -> ClockElement(element, data) OverlayElementType.G_FORCE -> GForceTrailElement(element, data) OverlayElementType.MAP -> MapElement(element, data) + OverlayElementType.RADAR -> RadarElement(element, data) + } +} + +// ---------- RADAR (Garmin Varia rear-view) ------------------------------ +// Studio twin of the HUD's RadarElement. Same geometry so a radar widget +// reads identical in the phone preview and when mirrored to the HUD Custom +// screen. Threat colours come from the theme's status tokens (per CLAUDE.md); +// in the built-in themes those equal the fixed green/amber/red the HUD +// hardcodes, so parity holds. The Varia reports range + closing speed only +// (no bearing), so MIRROR lights both sides together by the worst level. + +@Composable +private fun RadarElement(element: OverlayElement, data: StudioElementData) { + when (element.radarMode) { + "MIRROR" -> RadarMirror(element, data) + "MINIMAL" -> RadarMinimal(element, data) + else -> RadarLane(element, data) + } +} + +@Composable +private fun RadarLane(element: OverlayElement, data: StudioElementData) { + val fg = Color(element.foreground) + val green = MaterialTheme.appColors.statusGood + val amber = MaterialTheme.appColors.statusWarn + val red = MaterialTheme.appColors.statusDanger + fun levelColor(l: Int): Color = when { l >= 2 -> red; l == 1 -> amber; else -> green } + val range = element.radarRangeM.coerceAtLeast(20f) + val targets = data.radarTargets + BoxWithConstraints( + Modifier + .fillMaxWidth() + .aspectRatio(0.5f) + .background(Color(element.background), RoundedCornerShape(12.dp)) + .padding(8.dp) + ) { + val w = maxWidth.value + Column(Modifier.fillMaxSize()) { + if (element.showLabel) { + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = "RADAR", + color = fg.copy(alpha = 0.7f), + fontWeight = FontWeight.SemiBold, + fontSize = (w * 0.09f).coerceIn(9f, 22f).sp, + modifier = Modifier.weight(1f), + maxLines = 1 + ) + if (data.radarBatteryPercent >= 0) { + Text( + text = "${data.radarBatteryPercent}%", + color = fg.copy(alpha = 0.6f), + fontSize = (w * 0.08f).coerceIn(8f, 18f).sp, + maxLines = 1 + ) + } + } + Spacer(Modifier.height(4.dp)) + } + Box(Modifier.fillMaxWidth().weight(1f)) { + if (!data.radarConnected) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "No radar", + color = fg.copy(alpha = 0.5f), + fontSize = (w * 0.10f).coerceIn(10f, 22f).sp + ) + } + return@Box + } + androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { + val cw = size.width + val ch = size.height + val cx = cw / 2f + val topHalf = cw * 0.13f + val botHalf = cw * 0.44f + val lane = Path().apply { + moveTo(cx - topHalf, 0f) + lineTo(cx + topHalf, 0f) + lineTo(cx + botHalf, ch) + lineTo(cx - botHalf, ch) + close() + } + drawPath(lane, color = fg.copy(alpha = 0.10f)) + drawLine( + fg.copy(alpha = 0.35f), + Offset(cx - topHalf, 0f), Offset(cx - botHalf, ch), 2f + ) + drawLine( + fg.copy(alpha = 0.35f), + Offset(cx + topHalf, 0f), Offset(cx + botHalf, ch), 2f + ) + listOf(0.25f, 0.5f, 0.75f).forEach { f -> + val y = ch * (1f - f) + val half = topHalf + (botHalf - topHalf) * (1f - f) + drawLine( + fg.copy(alpha = 0.15f), + Offset(cx - half, y), Offset(cx + half, y), 1.2f + ) + } + val rsz = cw * 0.10f + val rider = Path().apply { + moveTo(cx, ch - rsz) + lineTo(cx - rsz * 0.8f, ch) + lineTo(cx + rsz * 0.8f, ch) + close() + } + drawPath(rider, color = fg.copy(alpha = 0.9f)) + targets.sortedByDescending { it.distanceM }.forEach { t -> + val ratio = (t.distanceM / range).coerceIn(0f, 1f) + val y = ch * (1f - ratio) + val near = 1f - ratio + val r = cw * 0.07f + cw * 0.09f * near + val c = levelColor(t.level) + drawCircle(c.copy(alpha = 0.25f), radius = r * 1.6f, center = Offset(cx, y)) + drawCircle(c, radius = r, center = Offset(cx, y)) + if (element.radarShowDistanceLabels) { + val paint = android.graphics.Paint().apply { + color = android.graphics.Color.WHITE + textSize = r * 1.0f + isAntiAlias = true + textAlign = android.graphics.Paint.Align.CENTER + isFakeBoldText = true + } + drawContext.canvas.nativeCanvas.drawText( + t.distanceM.toString(), cx, y + paint.textSize * 0.35f, paint + ) + } + } + } + if (targets.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "CLEAR", + color = green, + fontWeight = FontWeight.Bold, + fontSize = (w * 0.11f).coerceIn(11f, 26f).sp + ) + } + } + } + } + } +} + +@Composable +private fun RadarMirror(element: OverlayElement, data: StudioElementData) { + val fg = Color(element.foreground) + val green = MaterialTheme.appColors.statusGood + val amber = MaterialTheme.appColors.statusWarn + val red = MaterialTheme.appColors.statusDanger + fun levelColor(l: Int): Color = when { l >= 2 -> red; l == 1 -> amber; else -> green } + val targets = data.radarTargets + val maxLevel = targets.maxOfOrNull { it.level } ?: 0 + val closest = targets.minByOrNull { it.distanceM } + val active = data.radarConnected && targets.isNotEmpty() + val markColor = if (active) levelColor(maxLevel) else fg.copy(alpha = 0.22f) + BoxWithConstraints( + Modifier + .fillMaxWidth() + .aspectRatio(2.0f) + .background(Color(element.background), RoundedCornerShape(12.dp)) + .padding(10.dp) + ) { + val w = maxWidth.value + Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + RadarChevronSide(left = true, color = markColor, modifier = Modifier.fillMaxHeight().weight(1f)) + Column(Modifier.weight(1.5f), horizontalAlignment = Alignment.CenterHorizontally) { + when { + !data.radarConnected -> Text( + text = "No radar", + color = fg.copy(alpha = 0.5f), + fontSize = (w * 0.07f).coerceIn(10f, 22f).sp, + maxLines = 1 + ) + closest == null -> Text( + text = "CLEAR", + color = green, + fontWeight = FontWeight.Bold, + fontSize = (w * 0.10f).coerceIn(12f, 30f).sp, + maxLines = 1 + ) + else -> { + // Number on its own line so the digits sit dead-centre; + // the unit + closing speed ride a smaller line below + // (a "12 m" string would push the number off-centre). + Text( + text = "${closest.distanceM}", + color = fg, + fontWeight = FontWeight.Bold, + fontSize = (w * 0.17f).coerceIn(20f, 54f).sp, + maxLines = 1 + ) + Text( + text = "m · +${targets.maxOf { it.approachSpeedKmh }} km/h", + color = markColor, + fontWeight = FontWeight.SemiBold, + fontSize = (w * 0.055f).coerceIn(9f, 20f).sp, + maxLines = 1 + ) + } + } + } + RadarChevronSide(left = false, color = markColor, modifier = Modifier.fillMaxHeight().weight(1f)) + } + } +} + +@Composable +private fun RadarChevronSide(left: Boolean, color: Color, modifier: Modifier) { + androidx.compose.foundation.Canvas(modifier) { + val cw = size.width + val ch = size.height + val cy = ch / 2f + val half = ch * 0.30f + val chevW = cw * 0.45f + val gap = cw * 0.26f + val strokeW = cw * 0.13f + for (i in 0 until 3) { + val baseX = if (left) cw - i * gap else i * gap + val tipX = if (left) baseX - chevW else baseX + chevW + val a = (color.alpha * (1f - i * 0.3f)).coerceIn(0f, 1f) + val p = Path().apply { + moveTo(baseX, cy - half) + lineTo(tipX, cy) + lineTo(baseX, cy + half) + } + drawPath(p, color = color.copy(alpha = a), style = Stroke(width = strokeW, cap = StrokeCap.Round)) + } + } +} + +@Composable +private fun RadarMinimal(element: OverlayElement, data: StudioElementData) { + val fg = Color(element.foreground) + val green = MaterialTheme.appColors.statusGood + val amber = MaterialTheme.appColors.statusWarn + val red = MaterialTheme.appColors.statusDanger + fun levelColor(l: Int): Color = when { l >= 2 -> red; l == 1 -> amber; else -> green } + val targets = data.radarTargets + val maxLevel = targets.maxOfOrNull { it.level } ?: 0 + val closest = targets.minByOrNull { it.distanceM } + val dotColor: Color + val line1: String + val line2: String? + when { + !data.radarConnected -> { dotColor = fg.copy(alpha = 0.4f); line1 = "No radar"; line2 = null } + closest == null -> { dotColor = green; line1 = "Clear"; line2 = null } + else -> { + dotColor = levelColor(maxLevel) + line1 = "${closest.distanceM} m" + line2 = "+${targets.maxOf { it.approachSpeedKmh }} km/h" + } + } + BoxWithConstraints( + Modifier + .fillMaxWidth() + .background(Color(element.background), RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + val w = maxWidth.value + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size((w * 0.12f).coerceIn(10f, 28f).dp) + .clip(CircleShape) + .background(dotColor) + ) + Spacer(Modifier.width(10.dp)) + Column { + Text( + text = line1, + color = fg, + fontWeight = FontWeight.Bold, + fontSize = (w * 0.16f).coerceIn(14f, 40f).sp, + maxLines = 1 + ) + if (line2 != null) { + Text( + text = line2, + color = dotColor, + fontWeight = FontWeight.SemiBold, + fontSize = (w * 0.09f).coerceIn(9f, 22f).sp, + maxLines = 1 + ) + } + } + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96c16d5dc..4963e78fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1243,6 +1243,7 @@ Clock G-Force Map + Radar The connected wheel\'s name @@ -1257,6 +1258,7 @@ A clock, analog watch or stopwatch A G-force trail circle A live mini-map with your trace + Rear-view radar: approaching vehicles Camera panes @@ -1351,6 +1353,14 @@ Show trace Prefer customized marker Set your photo in Navigator → Customize my marker + Radar view + Lane + Mirror + Minimal + The radar sees behind you but not which side, so both markers light by threat. + Range + %1$d m + Distance labels Camera Replace image Make a colour transparent @@ -1619,6 +1629,7 @@ Overwrite preset? A preset named \"%1$s\" already exists. Overwrite + Keep both App warnings Needs attention Fix diff --git a/docs/superpowers/specs/2026-06-19-varia-radar-overlay-widget-design.md b/docs/superpowers/specs/2026-06-19-varia-radar-overlay-widget-design.md new file mode 100644 index 000000000..997c54051 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-varia-radar-overlay-widget-design.md @@ -0,0 +1,111 @@ +# Varia rear-view radar overlay widget — design + +Date: 2026-06-19 +Branch: `feature/varia-radar-overlay-widget` + +## Goal + +Surface the already-existing Garmin Varia rear-view radar data in two new +places: the **Overlay Studio** (so any rider can record video with radar data) +and, via the existing custom-overlay pipe, on the **HUD companion's "Custom" +screen**. This is one new Overlay Studio control (`OverlayElementType.RADAR`), +not a new screen — exactly like `DATA_DIAL`, `MAP`, and `G_FORCE` already are. + +## What already exists (do not rebuild) + +- `RadarRepository.currentFrame: StateFlow` (phone, `:app`). +- `RadarFrame(vendor, threats: List, batteryPercent: Int?, timestamp)`. +- `RadarThreat(id, distanceM, approachSpeedKmh, threatLevel, firstSeenMs)`. +- `ThreatLevel { NONE, APPROACHING, FAST_APPROACH }` — locally classified. +- Dashboard already draws radar as a vertical "lane" bar (`DashboardRadarMini`). +- Overlay Studio element model + render dispatch (phone + HUD), shared + `:hud-protocol` `OverlayLayout`/`OverlayElement`/`OverlayElementType`. +- HUD wire protocol `HudState` (`:hud-protocol/HudWire.kt`), `customOverlayJson` + carries the studio layout to the HUD; `HudDemoSource` provides synthetic + telemetry for emulator testing behind `debug.eucplanet.demo=true`. + +## Hardware reality + +The Varia is rear-facing and reports **distance + closing speed + a stable +per-car track id**, but **no bearing** (it cannot tell left from right). The +"Mirror" mode is therefore a *style*: both blind-spot markers illuminate +together to the current max threat level. This is called out in the UI copy. + +## View modes (`OverlayElement.radarMode`, following `dialStyle`/`mapStyle`) + +- **LANE** — vertical proximity bar; cars as colour-coded blips by distance + (0…`radarRangeM`), optional distance labels. Mirrors `DashboardRadarMini`. +- **MIRROR** — two blind-spot chevrons (left + right) that light together to the + max threat level, with the closest distance / fastest closing readout. +- **MINIMAL** — compact card: threat dot + closest distance + fastest closing + speed; "lane clear" idle state when no targets. + +## Data flow + +``` +RadarRepository.currentFrame ──phone──▶ HudServer.snapshot() + RadarFrame{threats:[RadarThreat...], battery} │ maps to wire + │ ▼ + phone Overlay Studio live preview HudState.radarTargets: List + RadarWidgetElement (3 modes) + radarConnected + radarBatteryPercent + │ SSE 5 Hz; customOverlayJson carries layout + ▼ + HUD Custom screen → OverlayElementRenderer + → RadarWidgetElement (same 3 modes) +``` + +## Changes by module + +### `:hud-protocol` (the contract) +- `HudWire.kt`: add `radarConnected: Boolean = false`, + `radarBatteryPercent: Int = -1`, `radarTargets: List = + emptyList()` (capped at 8). New `@Serializable RadarTargetWire(id, distanceM, + approachSpeedKmh, level: Int)` where `level = ThreatLevel.ordinal`. Bump + `PROTOCOL_MINOR` 7 → 8 (additive; old HUDs ignore the new fields). +- `OverlayLayout.kt`: add `RADAR` to `OverlayElementType`; add + `radarMode: String = "LANE"`, `radarRangeM: Float = 140f`, + `radarShowDistanceLabels: Boolean = true` to `OverlayElement`. Reuse existing + `foreground` / `background` / `opacity` / `shadow`. + +### `:app` (producer + phone studio editor & preview) +- `service/hud/HudServer.kt`: inject `RadarRepository`; populate the new wire + fields in `snapshot()`. +- `service/hud/HudDemoSource.kt`: synthesize 0–3 closing cars so the widget is + testable on emulators without a physical Varia. +- `ui/studio` render: `RADAR ->` branch + `RadarWidgetElement` (3 modes), threat + colours from theme tokens `statusGood/Warn/Danger` (per `CLAUDE.md`). +- `ui/studio` editor: add to the Data group in the add-element picker, icon + + label, and a radar config section (mode chips, range slider, label toggle, + colours). `newElement()` radar default. Wire the phone studio's live data to + expose the current `RadarFrame`. +- `res/values/strings.xml`: new strings (English; other locales fall back to the + default — translation parity is a follow-up). + +### `:hud` (consumer + renderer) +- `overlay/StudioElementData.kt`: carry `radarTargets` / `radarConnected` / + `radarBatteryPercent` from `HudState` (HUD already depends on `:hud-protocol`, + so it reuses `RadarTargetWire` directly). +- `overlay/OverlayElements.kt`: `RADAR ->` branch + `RadarWidgetElement` (same 3 + modes; fixed green/amber/red over the dark HUD surface — the documented + theming exception). + +## Edge cases + +- Radar unpaired / no targets → idle "lane clear" / muted placeholder, never an + error. +- Old HUD + new phone → unknown wire fields ignored (`ignoreUnknownKeys`); RADAR + element silently dropped by the HUD's existing unknown-type fallback. +- Old preset JSON → radar fields take defaults. +- Targets capped (8) to bound frame size. + +## Testing + +- `:hud-protocol` unit tests: radar fields round-trip; the frozen v1.0 baseline + still decodes (additive-only); a RADAR `OverlayElement` survives preset JSON + round-trip; `PROTOCOL_MINOR` bumped. +- Emulator end-to-end (AVDs `new-version` = phone, `motoeye_e6_proxy` = HUD): + build/install both, enable HUD server + `debug.eucplanet.demo=true`, add the + RADAR widget in Overlay Studio, cycle modes, confirm matching live render on + the HUD Custom screen. Capture screenshots of each mode, the editor config, + and the HUD. +``` diff --git a/hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/HudWire.kt b/hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/HudWire.kt index ee756f273..8ed501297 100644 --- a/hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/HudWire.kt +++ b/hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/HudWire.kt @@ -168,6 +168,21 @@ data class HudState( val joystickLeft: String = "", val joystickRight: String = "", + // --- Rear-view radar (Garmin Varia) --- + /** True when a rear-view radar is paired AND has a live BLE link. + * Lets the HUD distinguish "no radar configured" (idle) from + * "radar connected, lane clear" (empty [radarTargets]). Older HUDs + * (PROTOCOL_MINOR < 8) ignore this and never draw a radar widget. */ + val radarConnected: Boolean = false, + /** Radar device battery percentage, or -1 when the radar does not + * publish it / no radar is paired. */ + val radarBatteryPercent: Int = -1, + /** Vehicles the radar currently tracks, nearest-first is NOT guaranteed + * -- the renderer sorts. Empty = the radar reports a clear lane (only + * meaningful when [radarConnected]). Capped phone-side at 8 to bound + * the frame size; the Varia rarely reports more. */ + val radarTargets: List = emptyList(), + /** * Server clock at the moment of capture in epoch millis. Lets the HUD * compute a "frame freshness" signal independent of its own wall clock, @@ -195,8 +210,12 @@ data class HudState( * Old HUDs ignore the new field but the link keeps working. Phone * surfaces a soft "update available" hint when the HUD's reported * minor is below ours. + * + * 8: added rear-view radar fields ([radarConnected], + * [radarBatteryPercent], [radarTargets]) and the RADAR overlay + * element type. Older HUDs ignore them and skip the radar widget. */ - const val PROTOCOL_MINOR: Int = 7 + const val PROTOCOL_MINOR: Int = 8 /** Legacy alias. New code should read [PROTOCOL_MAJOR] / [PROTOCOL_MINOR]. */ @Deprecated( @@ -207,6 +226,29 @@ data class HudState( } } +/** + * One vehicle tracked by the rear-view radar, in the compact form sent over + * the wire. Mirrors the phone-side `RadarThreat` minus the fields the HUD + * doesn't need (vendor track bookkeeping, first-seen timestamp). + * + * The Varia is rear-facing and reports range + closing speed only -- there + * is NO bearing, so the HUD cannot place a car left/right; the "mirror" view + * mode lights both sides together by [level]. + */ +@Serializable +data class RadarTargetWire( + /** Stable per-car track id while the vehicle is in view. */ + val id: Int = 0, + /** Range from the rider's rear, in metres (Varia saturates ~140 m). */ + val distanceM: Int = 0, + /** Closing speed in km/h; positive = approaching. */ + val approachSpeedKmh: Int = 0, + /** Severity, as the `ThreatLevel` ordinal: 0 = NONE, 1 = APPROACHING, + * 2 = FAST_APPROACH. An int (not the phone enum) so the protocol module + * stays free of app types and tolerates future levels. */ + val level: Int = 0 +) + /** * Outcome of comparing the protocol version on the OTHER side of the link * against ours. Both the phone and the HUD compute this independently from diff --git a/hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/OverlayLayout.kt b/hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/OverlayLayout.kt index ac214720f..9f31ec93c 100644 --- a/hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/OverlayLayout.kt +++ b/hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/OverlayLayout.kt @@ -112,7 +112,10 @@ enum class OverlayElementType { /** A circular crosshair plotting live lateral × forward G-force with a comet trail. */ G_FORCE, /** A live mini-map of the rider's GPS position with a route trace. */ - MAP + MAP, + /** A rear-view radar (Garmin Varia) display: approaching vehicles by + * distance + closing speed. Renders in one of several [OverlayElement.radarMode]s. */ + RADAR } /** @@ -249,7 +252,25 @@ data class OverlayElement( */ val dialShowColorBand: Boolean = false, val dialOrangeThresholdPct: Int = 80, - val dialRedThresholdPct: Int = 90 + val dialRedThresholdPct: Int = 90, + + // RADAR, rear-view radar widget options. + /** + * Radar view mode: + * - LANE: a vertical proximity bar, cars as colour-coded blips placed by + * distance (the recorded-overlay twin of the dashboard radar mini). + * - MIRROR: two blind-spot chevrons (left + right) that light together to + * the current max threat level. The Varia has no bearing, so this is a + * style, not real left/right placement. + * - MINIMAL: a compact card with the threat dot, closest distance, and + * fastest closing speed. + */ + val radarMode: String = "LANE", + /** Distance in metres mapped to the far end of the LANE bar / MIRROR + * ramp. The Varia's advertised reach is ~140 m. */ + val radarRangeM: Float = 140f, + /** LANE mode: draw the per-car distance label inside each blip. */ + val radarShowDistanceLabels: Boolean = true ) /** diff --git a/hud-protocol/src/test/java/com/eried/eucplanet/hud/protocol/WireFormatTest.kt b/hud-protocol/src/test/java/com/eried/eucplanet/hud/protocol/WireFormatTest.kt index a09d1f524..6a626259d 100644 --- a/hud-protocol/src/test/java/com/eried/eucplanet/hud/protocol/WireFormatTest.kt +++ b/hud-protocol/src/test/java/com/eried/eucplanet/hud/protocol/WireFormatTest.kt @@ -136,6 +136,45 @@ class WireFormatTest { assertEquals(4, decoded.protocolMinor) } + @Test fun radar_targets_roundtrip_preserves_all_fields() { + val original = HudState( + radarConnected = true, + radarBatteryPercent = 64, + radarTargets = listOf( + RadarTargetWire(id = 3, distanceM = 12, approachSpeedKmh = 28, level = 2), + RadarTargetWire(id = 7, distanceM = 84, approachSpeedKmh = 5, level = 1), + RadarTargetWire(id = 9, distanceM = 140, approachSpeedKmh = 0, level = 0) + ) + ) + val encoded = json.encodeToString(original) + val decoded = json.decodeFromString(encoded) + assertEquals(original, decoded) + assertEquals(3, decoded.radarTargets.size) + assertEquals(12, decoded.radarTargets[0].distanceM) + assertEquals(2, decoded.radarTargets[0].level) + assertTrue(decoded.radarConnected) + assertEquals(64, decoded.radarBatteryPercent) + } + + @Test fun frozen_v1_0_baseline_decodes_with_empty_radar() { + // The v1.0 snapshot predates the radar fields. Decoding it must + // yield the additive defaults: no radar, empty target list, unknown + // battery (-1). This proves adding radar was a MINOR (additive) bump. + val decoded = json.decodeFromString(DEFAULT_FROZEN_V1_JSON) + assertTrue("radar targets default empty", decoded.radarTargets.isEmpty()) + assertEquals(false, decoded.radarConnected) + assertEquals(-1, decoded.radarBatteryPercent) + } + + @Test fun protocol_minor_bumped_for_radar() { + // Radar fields were added at MINOR 8. Guards against forgetting the + // bump (old phones/HUDs use it to surface the soft update hint). + assertTrue( + "PROTOCOL_MINOR must be >= 8 now that radar fields exist", + HudState.PROTOCOL_MINOR >= 8 + ) + } + @Test fun pair_command_roundtrips_with_protocol_fields() { val original: HudCommand = HudCommand.Pair( hudId = "motoeye-e6-7f3a", diff --git a/hud/src/main/AndroidManifest.xml b/hud/src/main/AndroidManifest.xml index 26eed4673..e3665ff81 100644 --- a/hud/src/main/AndroidManifest.xml +++ b/hud/src/main/AndroidManifest.xml @@ -11,6 +11,10 @@ + +