From b9d90e987e8f94780f34f909b4ea8e8418025d2f Mon Sep 17 00:00:00 2001 From: Erwin Ried <1091420+eried@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:24:46 +0200 Subject: [PATCH 1/5] docs: design for Varia radar overlay widget --- ...06-19-varia-radar-overlay-widget-design.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-19-varia-radar-overlay-widget-design.md 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. +``` From 611ab3e80cc50dee818556e3e3f74f9fa8aaf63a Mon Sep 17 00:00:00 2001 From: Erwin Ried <1091420+eried@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:00:21 +0200 Subject: [PATCH 2/5] Add Varia rear-view radar overlay widget New OverlayElementType.RADAR Overlay Studio control with three view modes (Lane / Mirror / Minimal), rendered both in the phone Overlay Studio and, via the existing customOverlayJson pipe, on the HUD's Custom screen. The radar data already exists on the phone (RadarRepository); this surfaces it for video overlays and the helmet HUD. hud-protocol: - HudState gains radarConnected / radarBatteryPercent / radarTargets (List); PROTOCOL_MINOR 7 -> 8 (additive, old HUDs ignore). - OverlayElementType.RADAR + radarMode / radarRangeM / radarShowDistanceLabels on OverlayElement. app: - HudServer.snapshot() maps the live RadarRepository frame to the wire. - HudDemoSource synthesises closing cars so the widget is testable on an emulator without a Varia. - RadarElement renderer (3 modes; threat colours from theme status tokens) + Studio editor wiring (picker entry, icon, config sheet, defaults) + OverlayPresetJson radar fields (save/load + wire payload). hud: - RadarElement renderer twin; StudioElementData carries the radar frame; CustomOverlayScreen's hand-rolled parser reads the radar fields. tests: - Wire round-trip + additive-compat (frozen v1.0 still decodes, MINOR bump). The Varia is rear-facing with no bearing, so Mirror lights both sides by the worst threat level (spelled out in the config hint). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../eucplanet/data/store/OverlayPresetJson.kt | 10 +- .../eucplanet/service/hud/HudDemoSource.kt | 45 ++- .../eried/eucplanet/service/hud/HudServer.kt | 21 ++ .../ui/studio/OverlayStudioScreen.kt | 24 +- .../ui/studio/OverlayStudioViewModel.kt | 35 ++ .../eucplanet/ui/studio/StudioConfigSheets.kt | 54 +++- .../ui/studio/StudioOverlayElements.kt | 299 ++++++++++++++++- app/src/main/res/values/strings.xml | 10 + .../eried/eucplanet/hud/protocol/HudWire.kt | 44 ++- .../eucplanet/hud/protocol/OverlayLayout.kt | 25 +- .../eucplanet/hud/protocol/WireFormatTest.kt | 39 +++ .../eucplanet/hud/overlay/OverlayElements.kt | 303 ++++++++++++++++++ .../hud/overlay/StudioElementData.kt | 15 +- .../hud/ui/screens/CustomOverlayScreen.kt | 8 +- 14 files changed, 920 insertions(+), 12 deletions(-) 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/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 e62d3135c..4ef3cc774 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 @@ -7,6 +7,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 @@ -14,6 +15,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 @@ -67,6 +69,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 @@ -405,6 +408,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: @@ -496,6 +505,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..e42c6bb3c 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, @@ -1557,6 +1563,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..6f9f3c3fe 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,292 @@ 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 -> { + Text( + text = "${closest.distanceM} m", + color = fg, + fontWeight = FontWeight.Bold, + fontSize = (w * 0.13f).coerceIn(16f, 44f).sp, + maxLines = 1 + ) + Text( + text = "+${targets.maxOf { it.approachSpeedKmh }} km/h", + color = markColor, + fontWeight = FontWeight.SemiBold, + fontSize = (w * 0.06f).coerceIn(9f, 22f).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 87d714de9..fc1d94811 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1227,6 +1227,7 @@ Clock G-Force Map + Radar The connected wheel\'s name @@ -1241,6 +1242,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 @@ -1335,6 +1337,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 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 c4efc4495..7a2a7c15a 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/java/com/eried/eucplanet/hud/overlay/OverlayElements.kt b/hud/src/main/java/com/eried/eucplanet/hud/overlay/OverlayElements.kt index 2b5c3e188..bdef179b4 100644 --- a/hud/src/main/java/com/eried/eucplanet/hud/overlay/OverlayElements.kt +++ b/hud/src/main/java/com/eried/eucplanet/hud/overlay/OverlayElements.kt @@ -26,7 +26,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.nativeCanvas import androidx.core.graphics.drawable.toBitmap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -87,6 +89,7 @@ fun OverlayElementRenderer(element: OverlayElement, data: StudioElementData) { OverlayElementType.CLOCK -> ClockElement(element, data) OverlayElementType.G_FORCE -> GForceElement(element, data) OverlayElementType.MAP -> MapElement(element, data) + OverlayElementType.RADAR -> RadarElement(element, data) // FLOATING_CAMERA + IMAGE skipped on the HUD; silently no-op. else -> Unit } @@ -845,3 +848,303 @@ private fun lonLatToTileFloat(lon: Double, lat: Double, z: Int): Pair= 2 -> RADAR_RED + level == 1 -> RADAR_AMBER + else -> RADAR_GREEN +} + +@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 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 + } + 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 + // Lane fill + edges (far = narrow top, near = wide bottom). + 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 + ) + // Distance gridlines at 25/50/75% of range. + 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 + ) + } + // Rider marker (you) at the bottom. + 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, far drawn first so nearer cars sit on top. + 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 = radarLevelColor(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 = RADAR_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 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) radarLevelColor(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) { + ChevronSide(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 = RADAR_GREEN, + fontWeight = FontWeight.Bold, + fontSize = (w * 0.10f).coerceIn(12f, 30f).sp, + maxLines = 1 + ) + else -> { + Text( + text = "${closest.distanceM} m", + color = fg, + fontWeight = FontWeight.Bold, + fontSize = (w * 0.13f).coerceIn(16f, 44f).sp, + maxLines = 1 + ) + Text( + text = "+${targets.maxOf { it.approachSpeedKmh }} km/h", + color = markColor, + fontWeight = FontWeight.SemiBold, + fontSize = (w * 0.06f).coerceIn(9f, 22f).sp, + maxLines = 1 + ) + } + } + } + ChevronSide(left = false, color = markColor, modifier = Modifier.fillMaxHeight().weight(1f)) + } + } +} + +@Composable +private fun ChevronSide(left: Boolean, color: Color, modifier: Modifier) { + 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 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 = RADAR_GREEN; line1 = "Clear"; line2 = null + } + else -> { + dotColor = radarLevelColor(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/hud/src/main/java/com/eried/eucplanet/hud/overlay/StudioElementData.kt b/hud/src/main/java/com/eried/eucplanet/hud/overlay/StudioElementData.kt index dd5b516ad..d818fb684 100644 --- a/hud/src/main/java/com/eried/eucplanet/hud/overlay/StudioElementData.kt +++ b/hud/src/main/java/com/eried/eucplanet/hud/overlay/StudioElementData.kt @@ -2,6 +2,7 @@ package com.eried.eucplanet.hud.overlay import androidx.compose.ui.geometry.Offset import com.eried.eucplanet.hud.protocol.HudState +import com.eried.eucplanet.hud.protocol.RadarTargetWire /** * HUD-side mirror of the phone's StudioElementData. Same shape so the @@ -29,7 +30,14 @@ data class StudioElementData( /** Heading degrees (0 = north, +clockwise). NaN when no bearing. */ val gpsHeadingDeg: Float = Float.NaN, /** Rider's accent colour as ARGB. */ - val accentArgb: Long = 0xFF00C853L + val accentArgb: Long = 0xFF00C853L, + /** Rear-view radar: true when a radar is paired and linked. Lets the + * RADAR widget 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). */ + val radarTargets: List = emptyList() ) { companion object { /** Build a StudioElementData from a [HudState] frame. The phone @@ -63,7 +71,10 @@ data class StudioElementData( latitude = hud.latitude, longitude = hud.longitude, gpsHeadingDeg = hud.gpsHeadingDeg, - accentArgb = parseArgbStringToLong(hud.accentArgb) + accentArgb = parseArgbStringToLong(hud.accentArgb), + radarConnected = hud.radarConnected, + radarBatteryPercent = hud.radarBatteryPercent, + radarTargets = hud.radarTargets ) } } diff --git a/hud/src/main/java/com/eried/eucplanet/hud/ui/screens/CustomOverlayScreen.kt b/hud/src/main/java/com/eried/eucplanet/hud/ui/screens/CustomOverlayScreen.kt index c88e7ad45..360b927f7 100644 --- a/hud/src/main/java/com/eried/eucplanet/hud/ui/screens/CustomOverlayScreen.kt +++ b/hud/src/main/java/com/eried/eucplanet/hud/ui/screens/CustomOverlayScreen.kt @@ -339,6 +339,12 @@ private fun parseElement(o: JSONObject): OverlayElement? { 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), + // Radar widget options. Like clock24Hour above, this hand-rolled + // parser must read every field the phone emits or the HUD silently + // falls back to defaults (here: always LANE mode at 140 m). + radarMode = o.optString("radarMode", d.radarMode), + radarRangeM = o.optDouble("radarRangeM", d.radarRangeM.toDouble()).toFloat(), + radarShowDistanceLabels = o.optBoolean("radarShowDistanceLabels", d.radarShowDistanceLabels) ) } From 9fb03393b4953f6f965cc06fb65c577834d3659b Mon Sep 17 00:00:00 2001 From: Erwin Ried <1091420+eried@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:39:03 +0200 Subject: [PATCH 3/5] Center the number in radar Mirror mode A "12 m" string centred as a whole left the digits sitting left of centre (the unit's width pushed them over). Put the distance number on its own centred line with the unit + closing speed on a smaller line below, so the number reads dead-centre between the chevrons in both the phone Studio and the HUD. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../eucplanet/ui/studio/StudioOverlayElements.kt | 11 +++++++---- .../eried/eucplanet/hud/overlay/OverlayElements.kt | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) 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 6f9f3c3fe..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 @@ -812,18 +812,21 @@ private fun RadarMirror(element: OverlayElement, data: StudioElementData) { 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} m", + text = "${closest.distanceM}", color = fg, fontWeight = FontWeight.Bold, - fontSize = (w * 0.13f).coerceIn(16f, 44f).sp, + fontSize = (w * 0.17f).coerceIn(20f, 54f).sp, maxLines = 1 ) Text( - text = "+${targets.maxOf { it.approachSpeedKmh }} km/h", + text = "m · +${targets.maxOf { it.approachSpeedKmh }} km/h", color = markColor, fontWeight = FontWeight.SemiBold, - fontSize = (w * 0.06f).coerceIn(9f, 22f).sp, + fontSize = (w * 0.055f).coerceIn(9f, 20f).sp, maxLines = 1 ) } diff --git a/hud/src/main/java/com/eried/eucplanet/hud/overlay/OverlayElements.kt b/hud/src/main/java/com/eried/eucplanet/hud/overlay/OverlayElements.kt index bdef179b4..20805d458 100644 --- a/hud/src/main/java/com/eried/eucplanet/hud/overlay/OverlayElements.kt +++ b/hud/src/main/java/com/eried/eucplanet/hud/overlay/OverlayElements.kt @@ -1039,18 +1039,21 @@ private fun RadarMirror(element: OverlayElement, data: StudioElementData) { 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} m", + text = "${closest.distanceM}", color = fg, fontWeight = FontWeight.Bold, - fontSize = (w * 0.13f).coerceIn(16f, 44f).sp, + fontSize = (w * 0.17f).coerceIn(20f, 54f).sp, maxLines = 1 ) Text( - text = "+${targets.maxOf { it.approachSpeedKmh }} km/h", + text = "m · +${targets.maxOf { it.approachSpeedKmh }} km/h", color = markColor, fontWeight = FontWeight.SemiBold, - fontSize = (w * 0.06f).coerceIn(9f, 22f).sp, + fontSize = (w * 0.055f).coerceIn(9f, 20f).sp, maxLines = 1 ) } From 916471c3f490ed721ee616f110c01fd75cc35689 Mon Sep 17 00:00:00 2001 From: Erwin Ried <1091420+eried@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:17:46 +0200 Subject: [PATCH 4/5] HUD link: snappy internal reconnect + resilience, relaxed visual alert Addresses a tester whose HUD link died after ~2 min and only a Motoeye reboot recovered it, plus the goal that reconnect be snappy internally without flashing the disconnect alert on a brief blip. Detection / reconnect (internal, fast): - Phone OkHttp pingInterval 15s -> 5s: a frozen/half-open HUD (frame sends still succeed into the TCP buffer) is now caught by a missed pong in ~5s, not 15s, so the dial loop rediscovers + reconnects promptly. - HUD Ktor pingPeriod 15s -> 5s, timeout 30s -> 12s: frees the connection (peer/status) and unblocks the incoming loop within ~12s of the phone vanishing instead of 30s. - connSeq guard: a stale, slowly-timing-out handler can no longer clobber the status/peer of a newer connection from a fast reconnect. Resilience (self-recover without a reboot): - Partial CPU wake lock while the link is up (+ WAKE_LOCK permission) so the Ktor server, UDP beacon and watchdog can't be frozen by a suspend window. - Watchdog interval 30s -> 10s so a wedged listener restarts in ~20-30s; it now also revives the mDNS advertise if it was down, without a full restart. Visual alert stays relaxed: the disconnect splash remains gated by HudApp's existing 10s reconnect grace, decoupled from the now-faster internal detection so a quick reconnect never flashes it. Diagnostics: HudServer records watchdogRestarts / lastEndReason / lastDisconnectMs and logs the end reason + a per-connection token so the next "link died" report carries the cause. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../eried/eucplanet/service/hud/HudServer.kt | 8 +- hud/src/main/AndroidManifest.xml | 4 + .../com/eried/eucplanet/hud/HudActivity.kt | 6 +- .../com/eried/eucplanet/hud/net/HudServer.kt | 105 +++++++++++++++--- 4 files changed, 105 insertions(+), 18 deletions(-) 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 4d7811c6c..266413a67 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 @@ -130,7 +130,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) 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 @@ + +