Skip to content

Commit f3c76c3

Browse files
eriedclaude
andcommitted
feat(hud): phone-Wi-Fi interference advisory + faster off-air recovery (0.1.9)
Two improvements driven by the 2026-06-30 field logs (HUD 0.1.8 confirmed the self-heal works: on arriving home it logged "recovered after 33.8s off-air (restart x1 reassoc x4 toggle x3)" and came back with no reboot; the toggle running proves the Motoeye E6 is API<=28, so the full toolkit is available). 1) Faster severe-case recovery. The 33.8s recovery was slow because the ladder reached the WiFi toggle (the rung that actually clears it on this device) only on the 3rd action and ticked every 3s. Now: - recoveryStepFor front-loads the toggle (RESTART -> TOGGLE -> REASSOC -> ...) since the field log showed reassociate alone did not clear the off-air state. - RECOVERY_INTERVAL_MS 3s -> 1.5s, OFF_AIR_GRACE_TICKS 1 -> 0, toggle settle 1.5s -> 1.0s. Expected ~8-12s instead of ~34s. (LinkWatchdogTest updated.) 2) "Phone Wi-Fi is interrupting the HUD" rider advisory. Root cause confirmed by the rider observing the HUD drop the instant the phone joined home Wi-Fi on arrival: single-radio STA+AP channel-follow -- any phone Wi-Fi STA transition re-tunes the shared hotspot radio and drops the link. The HUD can't see this (it only knows the hotspot), so the PHONE detects it and tells the HUD: - hud-protocol WifiInterferenceDetector (pure, unit-tested): flags interference only when an established-link HUD drop lands within ~20s of one of the phone's OWN Wi-Fi STA transitions, >=2 times; auto-clears after stable. - app HudServer feeds it (STA transitions from the NetworkCallback incl. a new onLost; established-link drops via a wasOpen-gated onFailure) and publishes HudState.phoneWifiInterfering (wire proto minor 9 -> 10, additive). - HUD shows a BottomStart advisory badge in the SAME chrome family as the existing version/disconnect badges (0xE6111111 fill, 1.dp stroke, info-yellow, icon + stacked title/detail) reading "Phone Wi-Fi interrupting HUD / Turn off phone Wi-Fi for a stable link". HUD bumped to 0.1.9 (300010). Needs the matching phone app (proto 10) for the advisory; older phones simply never set the flag. Verified: hud-protocol unit tests 39/39 (incl. 5 new detector + updated ladder); :hud and :app compile clean; both debug APKs assemble; installed on emulator-5554 (API 36) and launched -- neither app crashes, watchdog logs verdict=HEALTHY at idle (peerKnown=false correctly NOT treated as off-air). On-device radio recovery still needs the tester to confirm the faster timing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fpo6EKJ9QYwyb9FkKQWrTn
1 parent 74b3416 commit f3c76c3

10 files changed

Lines changed: 280 additions & 24 deletions

File tree

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.eried.eucplanet.hud.protocol.HudDebug
2020
import com.eried.eucplanet.hud.protocol.HudDiscovery
2121
import com.eried.eucplanet.hud.protocol.HudState
2222
import com.eried.eucplanet.hud.protocol.RadarTargetWire
23+
import com.eried.eucplanet.hud.protocol.WifiInterferenceDetector
2324
import com.eried.eucplanet.nav.NavigationEngine
2425
import com.eried.eucplanet.ui.theme.AccentOptions
2526
import com.eried.eucplanet.ui.theme.AccentTeal
@@ -169,6 +170,13 @@ class HudServer @Inject constructor(
169170
/** Currently-registered ConnectivityManager callback; cleared on doStop. */
170171
@Volatile private var netCallback: ConnectivityManager.NetworkCallback? = null
171172

173+
/** Detects when the phone's OWN home/other Wi-Fi keeps interrupting the HUD
174+
* link (single-radio channel-follow): correlates established-link drops
175+
* with the phone's Wi-Fi STA transitions. When it fires we set
176+
* [HudState.phoneWifiInterfering] so the HUD advises the rider to turn the
177+
* phone's Wi-Fi off. */
178+
private val wifiInterference = WifiInterferenceDetector()
179+
172180
init {
173181
// Watch the toggle ourselves so the link starts the moment the
174182
// rider flips `Enable data link` -- no dependency on WheelService
@@ -339,8 +347,17 @@ class HudServer @Inject constructor(
339347
override fun onAvailable(network: Network) {
340348
Log.i(TAG, "network onAvailable: kicking dial loop")
341349
log("Network came up, retrying immediately")
350+
// The phone's own home/other Wi-Fi just (re)joined: on a single
351+
// radio this re-tunes the hotspot and tends to drop the HUD a
352+
// few seconds later. Feed the detector so a correlated drop is
353+
// recognised as channel-follow, not a plain out-of-range loss.
354+
wifiInterference.onStaTransition(System.currentTimeMillis())
342355
reconnectKick.trySend(Unit)
343356
}
357+
override fun onLost(network: Network) {
358+
Log.i(TAG, "network onLost: home WiFi gone")
359+
wifiInterference.onStaTransition(System.currentTimeMillis())
360+
}
344361
override fun onLosing(network: Network, maxMsToLive: Int) {
345362
// DO NOT close the WS here. This callback filters for
346363
// TRANSPORT_WIFI + NET_CAPABILITY_INTERNET, so the only network
@@ -357,6 +374,7 @@ class HudServer @Inject constructor(
357374
Log.i(TAG, "network onLosing (${maxMsToLive}ms): home WiFi leaving; " +
358375
"leaving hotspot WS intact")
359376
log("Home WiFi dropping in ${maxMsToLive}ms (hotspot link unaffected)")
377+
wifiInterference.onStaTransition(System.currentTimeMillis())
360378
}
361379
}
362380
try {
@@ -676,9 +694,14 @@ class HudServer @Inject constructor(
676694
.build()
677695
val listener = object : WebSocketListener() {
678696
private var sendJob: Job? = null
697+
/** True once this socket actually opened, so onFailure can tell an
698+
* ESTABLISHED-link drop (a real interruption worth correlating with
699+
* Wi-Fi) from a plain "couldn't find/connect to the HUD" dial miss. */
700+
@Volatile private var wasOpen = false
679701
override fun onOpen(webSocket: WebSocket, response: Response) {
680702
Log.i(TAG, "HUD link open: $peer")
681703
log("Connected to $peer")
704+
wasOpen = true
682705
ws = webSocket
683706
// Push a frame every PUBLISH_INTERVAL_MS off the snapshot
684707
// buffer. We don't dedupe: even when no field changed, the
@@ -733,6 +756,12 @@ class HudServer @Inject constructor(
733756
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
734757
Log.w(TAG, "HUD link failure: ${t.message} (${response?.code})")
735758
log("Not found: ${t.message ?: t::class.simpleName}")
759+
// Only an ESTABLISHED link dropping is a candidate channel-follow
760+
// event; a failed dial (never opened) is just discovery missing.
761+
if (wasOpen) {
762+
val correlated = wifiInterference.onHudDrop(System.currentTimeMillis())
763+
if (correlated) log("Drop correlated with a phone Wi-Fi change")
764+
}
736765
sendJob?.cancel()
737766
ws = null
738767
commandSink.onHudDisconnected()
@@ -749,6 +778,9 @@ class HudServer @Inject constructor(
749778
}
750779

751780
private suspend fun snapshot(): HudState {
781+
// Let the Wi-Fi-interference advisory decay once the link has been
782+
// stable for a while (this runs every publish tick, ~5 Hz).
783+
wifiInterference.onStableTick(System.currentTimeMillis())
752784
val s = settingsRepository.get()
753785
val wd = wheelRepository.wheelData.value
754786
val state = wheelRepository.connectionState.value
@@ -885,7 +917,8 @@ class HudServer @Inject constructor(
885917
level = it.threatLevel.ordinal
886918
)
887919
}.orEmpty(),
888-
timestampMs = System.currentTimeMillis()
920+
timestampMs = System.currentTimeMillis(),
921+
phoneWifiInterfering = wifiInterference.advisoryActive
889922
)
890923
}
891924

hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/HudWire.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,16 @@ data class HudState(
189189
* and lets the dedup-by-content snapshot pipeline force a re-emit by
190190
* just bumping the timestamp when no other field changed.
191191
*/
192-
val timestampMs: Long = 0L
192+
val timestampMs: Long = 0L,
193+
194+
/**
195+
* True when the phone has detected that its OWN home/other Wi-Fi keeps
196+
* interrupting the HUD link (single-radio STA+AP channel-follow): repeated
197+
* HUD drops each landing right after one of the phone's Wi-Fi transitions.
198+
* The HUD shows a rider-facing advisory ("turn phone Wi-Fi off for a stable
199+
* link") when set. Added at PROTOCOL_MINOR 10; older HUDs ignore it.
200+
*/
201+
val phoneWifiInterfering: Boolean = false
193202
) {
194203
companion object {
195204
/**
@@ -217,8 +226,11 @@ data class HudState(
217226
* 9: added [HudCommand.Pair.recoveryNote] so the HUD can report its
218227
* WiFi self-heal story back into the phone's shareable diagnostics.
219228
* Older phones ignore the field; nothing else on the wire changed.
229+
* 10: added [HudState.phoneWifiInterfering] so the phone can tell the
230+
* HUD to advise the rider that the phone's own Wi-Fi is interrupting
231+
* the link. Older HUDs ignore it.
220232
*/
221-
const val PROTOCOL_MINOR: Int = 9
233+
const val PROTOCOL_MINOR: Int = 10
222234

223235
/** Legacy alias. New code should read [PROTOCOL_MAJOR] / [PROTOCOL_MINOR]. */
224236
@Deprecated(

hud-protocol/src/main/java/com/eried/eucplanet/hud/protocol/LinkWatchdog.kt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,24 @@ object LinkWatchdog {
4444

4545
/**
4646
* The recovery action to take for the Nth consecutive off-air recovery
47-
* attempt. Escalates cheap -> aggressive and then alternates forever rather
47+
* attempt. Escalates cheap -> decisive and then alternates forever rather
4848
* than ever surrendering -- the only alternative to "keep trying" is the
4949
* rider rebooting the HUD, which is the failure we are removing.
5050
*
5151
* 0 -> [RecoveryStep.RESTART_SOCKETS] (re-resolve IP, restart
5252
* beacon/mDNS/finder, bounce the WiFi lock; no radio state change)
53-
* odd -> [RecoveryStep.REASSOCIATE] (WifiManager.reconnect/reassociate)
54-
* even>0 -> [RecoveryStep.TOGGLE_WIFI] (off/on; only effective pre-API29,
55-
* the caller falls back to REASSOCIATE where it is a no-op)
53+
* odd -> [RecoveryStep.TOGGLE_WIFI] (off/on; the DECISIVE rung,
54+
* front-loaded because a Motoeye E6 field log showed reassociate
55+
* alone did not clear the off-air state -- the toggle did. Only
56+
* effective pre-API29; the caller falls back to REASSOCIATE where
57+
* it is a no-op)
58+
* even>0 -> [RecoveryStep.REASSOCIATE] (WifiManager.reconnect/reassociate;
59+
* cheap, fills the gaps, and the only effective path on API29+)
5660
*/
5761
fun recoveryStepFor(attempt: Int): RecoveryStep = when {
5862
attempt <= 0 -> RecoveryStep.RESTART_SOCKETS
59-
attempt % 2 == 1 -> RecoveryStep.REASSOCIATE
60-
else -> RecoveryStep.TOGGLE_WIFI
63+
attempt % 2 == 1 -> RecoveryStep.TOGGLE_WIFI
64+
else -> RecoveryStep.REASSOCIATE
6165
}
6266
}
6367

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.eried.eucplanet.hud.protocol
2+
3+
/**
4+
* Phone-side detector for "my own home/other Wi-Fi is interrupting the HUD".
5+
*
6+
* On a single-radio phone, every time the home-Wi-Fi STA changes state while
7+
* the hotspot is up, the shared radio re-tunes and the live HUD link drops.
8+
* The HUD only knows the hotspot, so the PHONE must detect this and tell it.
9+
*
10+
* The fingerprint: a HUD link drop that lands within [correlationWindowMs] of
11+
* one of the phone's OWN Wi-Fi STA transitions (join / weaken / leave). A drop
12+
* with no nearby Wi-Fi event (rode out of hotspot range, HUD glitch) is NOT
13+
* counted, which is what keeps the advisory from false-firing. We require
14+
* [minCorrelatedDrops] within [rollingWindowMs] before raising the advisory,
15+
* and auto-clear it once the link has been stable for [clearAfterStableMs].
16+
*
17+
* Pure: the caller passes the clock ([nowMs]) so this is unit-testable and has
18+
* no Android dependency. Not thread-safe; call from a single coroutine/thread.
19+
*/
20+
class WifiInterferenceDetector(
21+
private val correlationWindowMs: Long = 20_000L,
22+
private val minCorrelatedDrops: Int = 2,
23+
private val rollingWindowMs: Long = 5 * 60_000L,
24+
private val clearAfterStableMs: Long = 3 * 60_000L,
25+
) {
26+
private var lastStaTransitionMs: Long = 0L
27+
private val correlatedDropTimes = ArrayDeque<Long>()
28+
29+
/** True while we believe the phone's Wi-Fi is the thing dropping the HUD. */
30+
var advisoryActive: Boolean = false
31+
private set
32+
33+
/** The phone's home/other Wi-Fi STA just changed state (onAvailable /
34+
* onLosing / onLost of an internet-capable Wi-Fi network). */
35+
fun onStaTransition(nowMs: Long) {
36+
lastStaTransitionMs = nowMs
37+
}
38+
39+
/** Record an established-link HUD drop. Returns true if it correlated with a
40+
* recent STA transition (i.e. it looks like channel-follow, not a plain
41+
* out-of-range drop). */
42+
fun onHudDrop(nowMs: Long): Boolean {
43+
val correlated = lastStaTransitionMs != 0L &&
44+
nowMs - lastStaTransitionMs in 0..correlationWindowMs
45+
if (correlated) {
46+
correlatedDropTimes.addLast(nowMs)
47+
prune(nowMs)
48+
if (correlatedDropTimes.size >= minCorrelatedDrops) advisoryActive = true
49+
}
50+
return correlated
51+
}
52+
53+
/** Call on a healthy/stable tick. Clears the advisory once the link has gone
54+
* [clearAfterStableMs] with no fresh correlated drop. */
55+
fun onStableTick(nowMs: Long) {
56+
prune(nowMs)
57+
val lastDrop = correlatedDropTimes.lastOrNull()
58+
if (advisoryActive && (lastDrop == null || nowMs - lastDrop >= clearAfterStableMs)) {
59+
advisoryActive = false
60+
correlatedDropTimes.clear()
61+
}
62+
}
63+
64+
private fun prune(nowMs: Long) {
65+
while (correlatedDropTimes.isNotEmpty() &&
66+
nowMs - correlatedDropTimes.first() > rollingWindowMs) {
67+
correlatedDropTimes.removeFirst()
68+
}
69+
}
70+
}

hud-protocol/src/test/java/com/eried/eucplanet/hud/protocol/LinkWatchdogTest.kt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,19 @@ class LinkWatchdogTest {
7070

7171
// ---- recovery ladder ------------------------------------------------
7272

73-
@Test fun ladder_starts_cheap_then_escalates_and_never_gives_up() {
73+
@Test fun ladder_tries_cheap_then_front_loads_the_decisive_toggle() {
74+
// One cheap socket restart first, then the WiFi toggle -- field logs
75+
// (Motoeye E6, Android 7/8) showed reassociate alone did NOT clear the
76+
// off-air state; the toggle is what recovered it, so it is reached on
77+
// the very next rung instead of third. Reassociate fills the gaps
78+
// (cheaper, and the effective path on API29+ where toggle is a no-op).
7479
assertEquals(RecoveryStep.RESTART_SOCKETS, LinkWatchdog.recoveryStepFor(0))
75-
assertEquals(RecoveryStep.REASSOCIATE, LinkWatchdog.recoveryStepFor(1))
76-
assertEquals(RecoveryStep.TOGGLE_WIFI, LinkWatchdog.recoveryStepFor(2))
77-
// Past the toggle we keep alternating reassociate/toggle forever rather
78-
// than surrendering -- the alternative is the rider rebooting anyway.
79-
assertEquals(RecoveryStep.REASSOCIATE, LinkWatchdog.recoveryStepFor(3))
80-
assertEquals(RecoveryStep.TOGGLE_WIFI, LinkWatchdog.recoveryStepFor(4))
80+
assertEquals(RecoveryStep.TOGGLE_WIFI, LinkWatchdog.recoveryStepFor(1))
81+
assertEquals(RecoveryStep.REASSOCIATE, LinkWatchdog.recoveryStepFor(2))
82+
// Keep alternating toggle/reassociate forever rather than surrendering
83+
// -- the alternative is the rider rebooting anyway.
84+
assertEquals(RecoveryStep.TOGGLE_WIFI, LinkWatchdog.recoveryStepFor(3))
85+
assertEquals(RecoveryStep.REASSOCIATE, LinkWatchdog.recoveryStepFor(4))
8186
}
8287

8388
@Test fun ladder_clamps_negative_to_first_step() {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.eried.eucplanet.hud.protocol
2+
3+
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
/**
8+
* The phone-side "is my own Wi-Fi interrupting the HUD?" detector.
9+
*
10+
* Background: on a single-radio phone, every time the home-Wi-Fi STA changes
11+
* state while the hotspot is up, the shared radio re-tunes and the live HUD
12+
* link drops. The HUD can't see this (it only knows the hotspot), so the PHONE
13+
* has to detect it and tell the HUD. The fingerprint is a HUD link drop that
14+
* lands within a few seconds of one of the phone's OWN Wi-Fi transitions; a
15+
* drop with no nearby Wi-Fi event (rode out of hotspot range, HUD glitch) is
16+
* NOT this. We require it twice before speaking up, and auto-clear once stable.
17+
*
18+
* Pure (caller passes the clock) so it is unit-testable.
19+
*/
20+
class WifiInterferenceDetectorTest {
21+
22+
private fun det() = WifiInterferenceDetector(
23+
correlationWindowMs = 20_000L,
24+
minCorrelatedDrops = 2,
25+
rollingWindowMs = 300_000L,
26+
clearAfterStableMs = 60_000L,
27+
)
28+
29+
@Test fun single_correlated_drop_does_not_trigger() {
30+
val d = det()
31+
d.onStaTransition(1_000L)
32+
assertTrue("drop 4s after STA event is correlated", d.onHudDrop(5_000L))
33+
assertFalse("one correlated drop must not raise the advisory", d.advisoryActive)
34+
}
35+
36+
@Test fun two_correlated_drops_raise_the_advisory() {
37+
val d = det()
38+
d.onStaTransition(1_000L); d.onHudDrop(5_000L)
39+
d.onStaTransition(30_000L); d.onHudDrop(40_000L)
40+
assertTrue(d.advisoryActive)
41+
}
42+
43+
@Test fun drop_without_a_recent_sta_transition_is_not_correlated() {
44+
val d = det()
45+
d.onStaTransition(1_000L)
46+
assertFalse("drop 25s later is outside the 20s window", d.onHudDrop(26_000L))
47+
assertFalse(d.onHudDrop(60_000L))
48+
assertFalse(d.advisoryActive)
49+
}
50+
51+
@Test fun advisory_clears_after_a_stable_period() {
52+
val d = det()
53+
d.onStaTransition(1_000L); d.onHudDrop(5_000L)
54+
d.onStaTransition(10_000L); d.onHudDrop(15_000L)
55+
assertTrue(d.advisoryActive)
56+
d.onStableTick(15_000L + 60_000L + 1L)
57+
assertFalse("stable for longer than clearAfterStableMs clears it", d.advisoryActive)
58+
}
59+
60+
@Test fun advisory_persists_while_drops_keep_coming() {
61+
val d = det()
62+
d.onStaTransition(1_000L); d.onHudDrop(5_000L)
63+
d.onStaTransition(10_000L); d.onHudDrop(15_000L)
64+
assertTrue(d.advisoryActive)
65+
d.onStableTick(20_000L) // only 5s since last drop -> not yet stable
66+
assertTrue(d.advisoryActive)
67+
}
68+
}

hud/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ android {
3838
// Offset by 300000 so the HUD APK's version line never collides with
3939
// the phone (1..99999) or the wear companion (100000-prefixed) when
4040
// both are visible in the same release notes.
41-
versionCode = 300009
42-
versionName = "0.1.8"
41+
versionCode = 300010
42+
versionName = "0.1.9"
4343
}
4444

4545
signingConfigs {

hud/src/main/java/com/eried/eucplanet/hud/net/HudServer.kt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,20 @@ class HudServer(private val context: Context) {
8989
private const val WAKE_LOCK_TAG = "eucplanet-hud:server"
9090
/** Faster watchdog cadence once the link looks unhealthy, so an
9191
* off-air radio is detected and the recovery ladder escalates within
92-
* seconds instead of the lazy 10 s healthy interval. */
93-
private const val RECOVERY_INTERVAL_MS: Long = 3_000L
92+
* seconds instead of the lazy 10 s healthy interval. Tightened from 3 s
93+
* to 1.5 s after a field log showed a real off-air recovery take ~34 s;
94+
* combined with the front-loaded toggle this brings a severe drop back
95+
* in ~8-12 s. */
96+
private const val RECOVERY_INTERVAL_MS: Long = 1_500L
9497
/** Timeout for the "can I still reach the phone I was talking to"
9598
* reachability probe. Short: the hotspot link is sub-10 ms when up. */
9699
private const val PEER_PROBE_TIMEOUT_MS: Int = 1_200
97100
/** Consecutive off-air ticks tolerated before the recovery ladder
98-
* starts. One grace tick rides out a single transient peer-probe miss
99-
* without kicking the radio. */
100-
private const val OFF_AIR_GRACE_TICKS: Int = 1
101+
* starts. 0 = act on the first confirmed off-air tick: the first rung
102+
* ([RecoveryStep.RESTART_SOCKETS]) is harmless and the radio-touching
103+
* rungs still need the off-air state to persist across ticks, so there
104+
* is no churn risk -- but recovery starts a tick sooner. */
105+
private const val OFF_AIR_GRACE_TICKS: Int = 0
101106
}
102107

103108
/** Connection state surfaced to the UI status banner. */
@@ -683,7 +688,7 @@ class HudServer(private val context: Context) {
683688
releaseWifiLock()
684689
@Suppress("DEPRECATION")
685690
runCatching { wifi?.isWifiEnabled = false }
686-
delay(1_500L)
691+
delay(1_000L)
687692
@Suppress("DEPRECATION")
688693
runCatching { wifi?.isWifiEnabled = true }
689694
acquireWifiLock()

0 commit comments

Comments
 (0)