diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e5432..aa46586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.5.1 + +**Update the phone app.** The helpers don't change. + +- **No more false "went offline".** A phone waking from Doze often can't reach a computer for a few seconds while Tailscale reconnects, and one missed background check was enough to say the computer went offline. Now it checks twice more, 20 seconds apart, and says so only if the computer stayed unreachable. + ## 2.5.0 Assistants get the whole recovery layer over MCP, with a guide for connecting your own. diff --git a/android/app/src/main/java/net/odysseylabs/powerdeck/work/EventsWorker.kt b/android/app/src/main/java/net/odysseylabs/powerdeck/work/EventsWorker.kt index eec82a1..5c8f07f 100644 --- a/android/app/src/main/java/net/odysseylabs/powerdeck/work/EventsWorker.kt +++ b/android/app/src/main/java/net/odysseylabs/powerdeck/work/EventsWorker.kt @@ -31,6 +31,7 @@ import net.odysseylabs.powerdeck.ui.QuickActionActivity import net.odysseylabs.powerdeck.ui.RecoveryText import net.odysseylabs.powerdeck.ui.recoveryText import net.odysseylabs.powerdeck.ui.Shortcuts +import kotlinx.coroutines.delay import java.time.Instant import java.util.concurrent.TimeUnit @@ -60,7 +61,7 @@ class EventsWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx // On the first run only remember where the log is; don't replay history. if (seen != null) tell(ctx, d, all, quiet(fresh.filter { notable(it, d, helperNames) }).takeLast(3)) events.maxOfOrNull { it.at }?.let { deck.store.setSeenEvents(d.id, it.toString()) } - } else if (before == Power.On && st == DeviceState.Off) { + } else if (before == Power.On && st == DeviceState.Off && confirmedOff { deck.state(d) == DeviceState.Off }) { notify(ctx, d, "${d.name} went offline.") } } @@ -176,6 +177,19 @@ class EventsWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx * Drops what a recovery line already says: "looks frozen" when a reset is proposed for that * machine, and "answers again" when it's back after one. */ + /** + * Whether a device that just missed one check is really off: [probe] answers off [tries] more times, + * [pauseMs] apart. A phone waking from Doze often can't reach a peer for a few seconds while + * Tailscale reconnects, which on its own said "went offline" about a machine that never was. + */ + internal suspend fun confirmedOff(tries: Int = 2, pauseMs: Long = 20_000, probe: suspend () -> Boolean): Boolean { + repeat(tries) { + delay(pauseMs) + if (!probe()) return false + } + return true + } + internal fun quiet(events: List): List { fun machines(result: String) = events.filter { it.result == result }.mapNotNull(::recoveryMachine).map(String::lowercase).toSet() val proposed = machines(PROPOSED) diff --git a/android/app/src/test/java/net/odysseylabs/powerdeck/UiRulesTest.kt b/android/app/src/test/java/net/odysseylabs/powerdeck/UiRulesTest.kt index 6e2b78a..57a01ea 100644 --- a/android/app/src/test/java/net/odysseylabs/powerdeck/UiRulesTest.kt +++ b/android/app/src/test/java/net/odysseylabs/powerdeck/UiRulesTest.kt @@ -17,6 +17,7 @@ import net.odysseylabs.powerdeck.ui.hubNote import net.odysseylabs.powerdeck.ui.recoveryText import net.odysseylabs.powerdeck.ui.uninstallBlocker import net.odysseylabs.powerdeck.work.EventsWorker +import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -67,6 +68,18 @@ class UiRulesTest { assertFalse(EventsWorker.hubWatch(Event(at, "Atlas", "Workstation looks frozen", "ok"))) } + /** One missed background check isn't "went offline": the machine must stay unreachable twice more. */ + @Test fun offlineNeedsTwoMoreMisses() = runTest { + var calls = 0 + assertFalse(EventsWorker.confirmedOff { calls++; false }) // answers on the first re-check + assertEquals(1, calls) + val answers = ArrayDeque(listOf(true, false)) + assertFalse(EventsWorker.confirmedOff { answers.removeFirst() }) // answers on the second + calls = 0 + assertTrue(EventsWorker.confirmedOff { calls++; true }) + assertEquals(2, calls) + } + @Test fun parsesARecovery() { val m = HubMachine.from(JSONObject("""{"name":"workstation","state":"hung","led":true,"kvm":{"name":"kvm-2"}, "recovery":{"id":"r1","machine":"workstation","action":"reset","state":"proposed","reason":"Atlas can't reach it",