Skip to content

Commit 1e0d1e2

Browse files
eriedclaude
andcommitted
Merge feature/varia-radar-overlay-widget into next-version
Lands the work that's on top of the shared hud-auto-discovery base: - Varia radar overlay-studio widget (Lane / Mirror / Minimal), rendered in the phone studio and on the HUD Custom screen. - Snappy HUD reconnect + resilience (5s/12s heartbeats, 10s watchdog, CPU wake lock, mDNS revive, connection-token guard, diagnostics). - Overlay Studio "Overwrite" now overwrites in place (no "name (1)" duplicate) + a Keep both / Overwrite / Cancel choice. next-version already carries hud-auto-discovery + Varia dashboard support; those are the shared base, so the merge was conflict-free. Build green (app + hud + hud-protocol tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents e3e1762 + 1cf41ec commit 1e0d1e2

19 files changed

Lines changed: 1180 additions & 39 deletions

File tree

app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetJson.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ object OverlayPresetJson {
162162
put("dialShowColorBand", el.dialShowColorBand)
163163
put("dialOrangeThresholdPct", el.dialOrangeThresholdPct)
164164
put("dialRedThresholdPct", el.dialRedThresholdPct)
165+
put("radarMode", el.radarMode)
166+
put("radarRangeM", el.radarRangeM.toDouble())
167+
put("radarShowDistanceLabels", el.radarShowDistanceLabels)
165168
}
166169

167170
private fun elementFromJson(o: JSONObject): OverlayElement? {
@@ -225,7 +228,12 @@ object OverlayPresetJson {
225228
unitPosition = o.optString("unitPosition", d.unitPosition),
226229
dialShowColorBand = o.optBoolean("dialShowColorBand", d.dialShowColorBand),
227230
dialOrangeThresholdPct = o.optInt("dialOrangeThresholdPct", d.dialOrangeThresholdPct),
228-
dialRedThresholdPct = o.optInt("dialRedThresholdPct", d.dialRedThresholdPct)
231+
dialRedThresholdPct = o.optInt("dialRedThresholdPct", d.dialRedThresholdPct),
232+
radarMode = o.optString("radarMode", d.radarMode),
233+
radarRangeM = o.optDouble("radarRangeM", d.radarRangeM.toDouble()).toFloat(),
234+
radarShowDistanceLabels = o.optBoolean(
235+
"radarShowDistanceLabels", d.radarShowDistanceLabels
236+
)
229237
)
230238
}
231239

app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetStore.kt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,18 +99,26 @@ class OverlayPresetStore @Inject constructor(
9999
.sortedBy { it.lowercase() }
100100
}
101101

102-
/** Write [preset] as `<name>.json`, overwriting any file with that name. */
102+
/** Write [preset] as `<name>.json`. If a file with that name already
103+
* exists it is overwritten IN PLACE (same document), not deleted and
104+
* recreated: `delete()` + `createFile()` races the SAF provider, which
105+
* then dedupes the new file to `name (1).json` instead of overwriting --
106+
* exactly the bug a rider hit when tapping "Overwrite". The "wt" mode
107+
* truncates so a shorter preset doesn't leave stale trailing bytes.
108+
*
109+
* To deliberately keep both copies, the caller passes a fresh unique name
110+
* (e.g. "name (1)") -- which won't be found here, so a new file is made. */
103111
suspend fun savePreset(name: String, preset: OverlayPreset): Boolean =
104112
withContext(Dispatchers.IO) {
105113
val safe = syncManager.sanitizeBackupName(name) ?: return@withContext false
106114
val folder = overlaysFolder() ?: return@withContext false
107115
val fileName = "$safe$PRESET_SUFFIX"
108116
runCatching {
109-
folder.findFile(fileName)?.delete()
110-
val file = folder.createFile("application/json", fileName)
111-
?: return@withContext false
112117
val json = OverlayPresetJson.toJson(preset.copy(name = safe)).toString(2)
113-
context.contentResolver.openOutputStream(file.uri)?.use { out ->
118+
val target = (folder.findFile(fileName)
119+
?: folder.createFile("application/json", fileName))?.uri
120+
?: return@withContext false
121+
context.contentResolver.openOutputStream(target, "wt")?.use { out ->
114122
out.write(json.toByteArray(Charsets.UTF_8))
115123
} ?: return@withContext false
116124
true

app/src/main/java/com/eried/eucplanet/service/hud/HudDemoSource.kt

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.eried.eucplanet.service.hud
22

33
import android.util.Log
44
import com.eried.eucplanet.hud.protocol.HudState
5+
import com.eried.eucplanet.hud.protocol.RadarTargetWire
56
import kotlinx.coroutines.CoroutineScope
67
import kotlinx.coroutines.Dispatchers
78
import kotlinx.coroutines.Job
@@ -115,6 +116,11 @@ class HudDemoSource {
115116
}
116117
val arrived = phase > 0.97
117118

119+
// Rear-view radar: a main car closing 140 -> 8 m across the loop, plus
120+
// a couple of extras phased in so the widget shows several colour
121+
// bands (green far, amber mid, red near) and multiple blips at once.
122+
val radarTargets = radarTargets(phase)
123+
118124
return Frame(
119125
speedKmh = speedKmh,
120126
batteryPct = battery,
@@ -131,10 +137,42 @@ class HudDemoSource {
131137
navAngleDeg = turnAngle,
132138
navPrimary = navPrimary,
133139
navDistance = if (arrived) "" else "${distM} m",
134-
navArrived = arrived
140+
navArrived = arrived,
141+
radarConnected = true,
142+
radarBatteryPercent = 78,
143+
radarTargets = radarTargets
135144
)
136145
}
137146

147+
/** Scripted radar cars for the demo. Distances chosen so a full loop
148+
* walks the LANE/MIRROR colour bands; ids stay stable while a car is
149+
* present so the renderer can track it. */
150+
private fun radarTargets(phase: Double): List<RadarTargetWire> {
151+
val targets = mutableListOf<RadarTargetWire>()
152+
// Main car: closes steadily over the whole cycle.
153+
val mainDist = (140.0 - 132.0 * phase).toInt().coerceIn(6, 140)
154+
targets += RadarTargetWire(1, mainDist, 22, radarLevel(mainDist))
155+
// Second car: drifts in for the back two-thirds of the loop.
156+
if (phase > 0.35) {
157+
val d2 = (95.0 - 60.0 * ((phase - 0.35) / 0.65)).toInt().coerceIn(20, 95)
158+
targets += RadarTargetWire(2, d2, 9, radarLevel(d2))
159+
}
160+
// Third car: a brief fast approacher mid-cycle (always red).
161+
if (phase in 0.45..0.75) {
162+
val d3 = (40.0 - 30.0 * ((phase - 0.45) / 0.30)).toInt().coerceIn(8, 40)
163+
targets += RadarTargetWire(3, d3, 46, 2)
164+
}
165+
return targets
166+
}
167+
168+
/** Demo threat level by distance: red <=30 m, amber <=80 m, else green.
169+
* Mirrors the visual bands the LANE/MIRROR renderers use. */
170+
private fun radarLevel(distM: Int): Int = when {
171+
distM <= 30 -> 2
172+
distM <= 80 -> 1
173+
else -> 0
174+
}
175+
138176
data class Frame(
139177
val speedKmh: Float = 0f,
140178
val batteryPct: Int = 100,
@@ -150,6 +188,9 @@ class HudDemoSource {
150188
val navAngleDeg: Float = 0f,
151189
val navPrimary: String = "",
152190
val navDistance: String = "",
153-
val navArrived: Boolean = false
191+
val navArrived: Boolean = false,
192+
val radarConnected: Boolean = false,
193+
val radarBatteryPercent: Int = -1,
194+
val radarTargets: List<RadarTargetWire> = emptyList()
154195
)
155196
}

app/src/main/java/com/eried/eucplanet/service/hud/HudServer.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import com.eried.eucplanet.ble.ConnectionState
1111
import com.eried.eucplanet.data.model.AppSettings
1212
import com.eried.eucplanet.data.model.arrowAngleDeg
1313
import com.eried.eucplanet.data.repository.ExternalGpsRepository
14+
import com.eried.eucplanet.data.repository.RadarRepository
1415
import com.eried.eucplanet.data.repository.SettingsRepository
1516
import com.eried.eucplanet.data.repository.TripRepository
1617
import com.eried.eucplanet.data.repository.WheelRepository
1718
import com.eried.eucplanet.hud.protocol.HudCommand
1819
import com.eried.eucplanet.hud.protocol.HudDebug
1920
import com.eried.eucplanet.hud.protocol.HudDiscovery
2021
import com.eried.eucplanet.hud.protocol.HudState
22+
import com.eried.eucplanet.hud.protocol.RadarTargetWire
2123
import com.eried.eucplanet.nav.NavigationEngine
2224
import com.eried.eucplanet.ui.theme.AccentOptions
2325
import com.eried.eucplanet.ui.theme.AccentTeal
@@ -75,6 +77,7 @@ class HudServer @Inject constructor(
7577
private val settingsRepository: SettingsRepository,
7678
private val externalGpsRepository: ExternalGpsRepository,
7779
private val tripRepository: TripRepository,
80+
private val radarRepository: RadarRepository,
7881
private val navigationEngine: NavigationEngine,
7982
private val commandSink: HudCommandSink,
8083
private val themeController: com.eried.eucplanet.ui.theme.ThemeController,
@@ -134,7 +137,13 @@ class HudServer @Inject constructor(
134137
private val lifecycleLock = Mutex()
135138

136139
private val http: OkHttpClient = OkHttpClient.Builder()
137-
.pingInterval(15, TimeUnit.SECONDS)
140+
// 5s, not 15s: a frozen/half-open HUD keeps the TCP socket open, so
141+
// outbound frame sends succeed into the OS buffer and never error --
142+
// only a missed pong reveals the dead peer. On the local hotspot link
143+
// (sub-10ms RTT) a 5s ping detects a dead HUD in ~5s and triggers the
144+
// dial-loop's rediscover+reconnect, instead of the rider staring at a
145+
// frozen HUD for 15s+. Cheap on a LAN.
146+
.pingInterval(5, TimeUnit.SECONDS)
138147
.connectTimeout(4, TimeUnit.SECONDS)
139148
.readTimeout(0, TimeUnit.MILLISECONDS)
140149
.retryOnConnectionFailure(true)
@@ -750,6 +759,12 @@ class HudServer @Inject constructor(
750759

751760
val d = if (demo.active) demo.frame else null
752761

762+
// Rear-view radar (Varia). In demo mode the synthetic source supplies
763+
// scripted cars; otherwise read the live frame. "connected" gates the
764+
// HUD radar widget between "idle / no radar" and "lane clear".
765+
val radarFrame = radarRepository.currentFrame.value
766+
val radarLive = radarRepository.connectionState.value == ConnectionState.CONNECTED
767+
753768
// Debug-only protocol-version overrides so a tester can drive
754769
// the version-mismatch UI on a single APK pair without rebuilding.
755770
// Set via:
@@ -841,6 +856,18 @@ class HudServer @Inject constructor(
841856
joystickDown = joystickLabel(s.hudActionDown),
842857
joystickLeft = joystickLabel(s.hudActionLeft),
843858
joystickRight = joystickLabel(s.hudActionRight),
859+
radarConnected = if (d != null) d.radarConnected else radarLive,
860+
radarBatteryPercent = if (d != null) d.radarBatteryPercent
861+
else (radarFrame?.batteryPercent ?: -1),
862+
radarTargets = if (d != null) d.radarTargets
863+
else radarFrame?.threats?.take(8)?.map {
864+
RadarTargetWire(
865+
id = it.id,
866+
distanceM = it.distanceM,
867+
approachSpeedKmh = it.approachSpeedKmh,
868+
level = it.threatLevel.ordinal
869+
)
870+
}.orEmpty(),
844871
timestampMs = System.currentTimeMillis()
845872
)
846873
}

app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioScreen.kt

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ fun OverlayStudioScreen(
141141
val trips by viewModel.trips.collectAsState()
142142
val wheelName by viewModel.wheelName.collectAsState()
143143
val connected by viewModel.connected.collectAsState()
144+
val radar by viewModel.radar.collectAsState()
144145
val history by viewModel.history.collectAsState()
145146
val folderAvailable by viewModel.folderAvailable.collectAsState()
146147
val savedPresets by viewModel.savedPresets.collectAsState()
@@ -938,7 +939,12 @@ fun OverlayStudioScreen(
938939
// this empty; the overlay falls back to wheelData /
939940
// history derived from the scrubbed trip row.
940941
liveGForceTrail = if (replayMode) emptyList() else liveGForceTrail,
941-
riderMarkerPhotoDataUrl = riderMarkerPhoto
942+
riderMarkerPhotoDataUrl = riderMarkerPhoto,
943+
// Radar has no trip-replay history (CSV doesn't store it),
944+
// so it only feeds the live preview; replay shows idle.
945+
radarConnected = if (replayMode) false else radar.connected,
946+
radarBatteryPercent = if (replayMode) -1 else radar.batteryPercent,
947+
radarTargets = if (replayMode) emptyList() else radar.targets
942948
),
943949
editable = editable,
944950
selectedId = selectedId,
@@ -1322,6 +1328,15 @@ fun OverlayStudioScreen(
13221328
val existingNames = remember(savedPresets) {
13231329
savedPresets.map { it.trim().lowercase() }.toSet()
13241330
}
1331+
// First free "base (n)" for the "Keep both" choice.
1332+
fun uniqueName(base: String): String {
1333+
var n = 1
1334+
var candidate = "$base ($n)"
1335+
while (candidate.trim().lowercase() in existingNames) {
1336+
n++; candidate = "$base ($n)"
1337+
}
1338+
return candidate
1339+
}
13251340
fun doSave(name: String) {
13261341
viewModel.savePresetAs(name) { result ->
13271342
val msg = when (result) {
@@ -1358,10 +1373,21 @@ fun OverlayStudioScreen(
13581373
title = { Text(stringResource(R.string.studio_overwrite_title)) },
13591374
text = { Text(stringResource(R.string.studio_overwrite_body, pending)) },
13601375
confirmButton = {
1361-
androidx.compose.material3.TextButton(onClick = {
1362-
pendingOverwriteName = null
1363-
doSave(pending)
1364-
}) { Text(stringResource(R.string.studio_overwrite_confirm)) }
1376+
androidx.compose.foundation.layout.Row(
1377+
horizontalArrangement = Arrangement.spacedBy(4.dp)
1378+
) {
1379+
// Keep both: save under the next free "name (n)"
1380+
// instead of replacing the existing preset.
1381+
androidx.compose.material3.TextButton(onClick = {
1382+
val keep = uniqueName(pending)
1383+
pendingOverwriteName = null
1384+
doSave(keep)
1385+
}) { Text(stringResource(R.string.studio_save_keep_both)) }
1386+
androidx.compose.material3.TextButton(onClick = {
1387+
pendingOverwriteName = null
1388+
doSave(pending)
1389+
}) { Text(stringResource(R.string.studio_overwrite_confirm)) }
1390+
}
13651391
},
13661392
dismissButton = {
13671393
androidx.compose.material3.TextButton(onClick = {
@@ -1557,6 +1583,22 @@ private fun newElement(
15571583
rotationDeg = ((360 - deviceRotation) % 360).toFloat()
15581584
)
15591585
}
1586+
if (type == OverlayElementType.RADAR) {
1587+
// Narrow by default: the LANE mode is a tall proximity bar. The rider
1588+
// widens it or switches to MIRROR/MINIMAL from the config sheet.
1589+
return OverlayElement(
1590+
type = type,
1591+
x = nx,
1592+
y = ny,
1593+
width = 0.2f,
1594+
radarMode = "LANE",
1595+
radarRangeM = 140f,
1596+
radarShowDistanceLabels = true,
1597+
foreground = 0xFFFFFFFFL, // lane lines / labels / rider marker
1598+
background = 0x66000000L,
1599+
rotationDeg = ((360 - deviceRotation) % 360).toFloat()
1600+
)
1601+
}
15601602
return OverlayElement(
15611603
type = type,
15621604
x = nx,

app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioViewModel.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import androidx.lifecycle.viewModelScope
55
import com.eried.eucplanet.ble.ConnectionState
66
import com.eried.eucplanet.hud.protocol.OverlayElement
77
import com.eried.eucplanet.hud.protocol.OverlayPreset
8+
import com.eried.eucplanet.hud.protocol.RadarTargetWire
89
import com.eried.eucplanet.hud.protocol.ViewportConfig
910
import com.eried.eucplanet.hud.protocol.ViewportLayout
1011
import com.eried.eucplanet.data.model.TripRecord
1112
import com.eried.eucplanet.data.model.WheelData
1213
import com.eried.eucplanet.data.repository.ExternalGpsRepository
1314
import com.eried.eucplanet.data.repository.PhoneSensorRepository
15+
import com.eried.eucplanet.data.repository.RadarRepository
1416
import com.eried.eucplanet.data.repository.SettingsRepository
1517
import com.eried.eucplanet.data.repository.TripRepository
1618
import com.eried.eucplanet.data.repository.WheelRepository
@@ -86,6 +88,13 @@ data class ReplayExportPrefs(
8688
/** Outcome of a "save preset" attempt, surfaced to the UI as a snackbar. */
8789
enum class PresetSaveResult { SAVED, NO_FOLDER, FAILED }
8890

91+
/** Snapshot of rear-view radar state handed to the Studio's RADAR element. */
92+
data class StudioRadarState(
93+
val connected: Boolean = false,
94+
val batteryPercent: Int = -1,
95+
val targets: List<RadarTargetWire> = emptyList()
96+
)
97+
8998
/**
9099
* Holds the working Overlay Studio layout and feeds the studio screen its live
91100
* telemetry. The layout is a single [OverlayPreset] mutated through the `update`
@@ -100,6 +109,7 @@ class OverlayStudioViewModel @Inject constructor(
100109
private val tripRepository: TripRepository,
101110
private val phoneSensorRepository: PhoneSensorRepository,
102111
private val externalGpsRepository: ExternalGpsRepository,
112+
private val radarRepository: RadarRepository,
103113
private val navMarkerStore: com.eried.eucplanet.data.store.NavMarkerStore
104114
) : ViewModel() {
105115

@@ -180,6 +190,31 @@ class OverlayStudioViewModel @Inject constructor(
180190
.map { it == ConnectionState.CONNECTED }
181191
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
182192

193+
/**
194+
* Live rear-view radar state for the RADAR overlay element's Studio
195+
* preview. Targets are mapped to the same compact [RadarTargetWire] the
196+
* HUD receives so the phone preview and the mirrored HUD render share one
197+
* renderer shape. Empty / not-connected when no radar is paired; the
198+
* widget then shows its idle "No radar" state.
199+
*/
200+
val radar: StateFlow<StudioRadarState> = combine(
201+
radarRepository.currentFrame,
202+
radarRepository.connectionState
203+
) { frame, conn ->
204+
StudioRadarState(
205+
connected = conn == ConnectionState.CONNECTED,
206+
batteryPercent = frame?.batteryPercent ?: -1,
207+
targets = frame?.threats?.take(8)?.map {
208+
RadarTargetWire(
209+
id = it.id,
210+
distanceM = it.distanceM,
211+
approachSpeedKmh = it.approachSpeedKmh,
212+
level = it.threatLevel.ordinal
213+
)
214+
} ?: emptyList()
215+
)
216+
}.stateIn(viewModelScope, SharingStarted.Eagerly, StudioRadarState())
217+
183218
/**
184219
* Base64 `data:image/png` URL of the rider's custom marker photo (set in
185220
* the Navigator) or null when none has been picked. Exposed here so the

0 commit comments

Comments
 (0)