Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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
)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,26 @@ class OverlayPresetStore @Inject constructor(
.sortedBy { it.lowercase() }
}

/** Write [preset] as `<name>.json`, overwriting any file with that name. */
/** Write [preset] as `<name>.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
Expand Down
45 changes: 43 additions & 2 deletions app/src/main/java/com/eried/eucplanet/service/hud/HudDemoSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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<RadarTargetWire> {
val targets = mutableListOf<RadarTargetWire>()
// 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,
Expand All @@ -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<RadarTargetWire> = emptyList()
)
}
29 changes: 28 additions & 1 deletion app/src/main/java/com/eried/eucplanet/service/hud/HudServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ 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
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<RadarTargetWire> = 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`
Expand All @@ -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() {

Expand Down Expand Up @@ -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<StudioRadarState> = 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
Expand Down
Loading
Loading