Files
wadamesh/src/ui-touch/TouchSleep.cpp
T
Kaj SchittecatandClaude Opus 5 5fc4608c37 touch: contacts cursor across a list rebuild (#467), idle-saver blocker breakdown (#465)
#467 (Buko84): leaving a page stashes the focused row so the cursor can go back
where it was, and it stashed it as a raw pointer. Reading a channel's unread
messages clears its badge, which rebuilds every thread row -- so on the way out
that pointer is either dead or, worse, recycled by the allocator onto a
DIFFERENT row, which is how the cursor lands on an unrelated channel. With no
unread messages nothing rebuilds and it works, which is exactly the split he
identified.

Identify the row by the slot it occupied instead. A list rebuilt in place keeps
its order, so the slot is reliable where the address is not; the pointer is only
consulted when the number of targets actually changed. The restore also scrolls
that row back into view now: the rest of the rebuild deliberately runs with
focus-scroll suppressed, which is right for the transient focus during
recollect, but it left the restored cursor off-screen whenever the rebuild had
also reset the list's scroll position.

#465 (jrote1): his follow-up screenshot shows the gate passing (178406 cycles,
last wake timer) but only 15% asleep. 178406 parks at the 50 ms throttle is
~2.5 h, which against 15% puts uptime near 16.5 h -- so every park runs its full
length and the parks are not the problem. The device simply was not ELIGIBLE
for the other 85%, and nothing reported which of the six gate conditions was
holding it shut.

So charge the wall time between checks to whichever condition was blocking at
that moment, and surface the biggest one ("held by mesh busy 62%") on the
Battery page and the live Lock-settings diag. gatePasses() becomes gateBlocker()
returning the condition rather than a bool. Gaps over 5 s are dropped rather
than blamed on a condition, and the clock is stamped AFTER a park so a completed
park is never mis-attributed to the next blocker.

Also: device_caps.h computed CAP_SOUND from HAS_UI_SOUND, which UITask.cpp does
not define until ~1400 lines AFTER that header is included -- so CAP_SOUND was 0
on every board. Nothing consumed it, so it was silent rather than broken, but
any future `#if CAP_SOUND` would have compiled notification sound out
everywhere. The board list moves into device_caps.h (every macro involved is a
-D on the command line, so all of them are genuinely visible there) and
HAS_UI_SOUND now derives from CAP_SOUND. Verified per board: on for T-Deck,
V4-TFT, V4-R8, M9, Pager and P4; off for RAK Tap, Wio L2 and Attaky, which is
the pre-existing behaviour, unchanged.

Reported-by: Buko84, jrote1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:28:34 +02:00

133 lines
5.7 KiB
C++

#include "TouchSleep.h"
#include <esp_timer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
namespace touchSleep {
namespace {
// When the gate passes (device parked: screen off, no companion client, WiFi+BLE
// off, on battery, mesh idle) we THROTTLE the main loop with vTaskDelay instead of
// entering esp_light_sleep.
//
// Why not real light sleep: manual esp_light_sleep_start() from the Arduino loop
// task trips the Interrupt Watchdog in this build (dual-core, no PM framework,
// INT_WDT on both cores @ 300 ms, always-on companion/LVGL/USB tasks) — reliably,
// regardless of duration or wake-source handling. vTaskDelay is a standard scheduler
// yield: the FreeRTOS idle task halts the CPU (WFI) between 1 ms ticks, trimming the
// idle busy-spin draw, with zero watchdog/hang risk. The radio stays in RX; an
// incoming packet is serviced on the next loop iteration (<= THROTTLE_MS late).
constexpr uint32_t THROTTLE_MS = 50;
uint64_t g_acc_idle_us = 0; // cumulative time spent parked/throttled
// Per-condition attribution of the time the gate was SHUT. "asleep 15%" on its own
// cannot distinguish "parks are too short" from "the device was hardly ever
// eligible", which is the whole question when someone reports the saver doing
// nothing (#465). Charge the wall time between checks to whichever condition was
// blocking at that moment.
uint64_t g_blocked_us[(int)Blocker::Count] = { 0 };
uint64_t g_measured_us = 0; // total time actually attributed (blocked + eligible)
uint64_t g_last_check_us = 0; // when loopEnd last finished
// A gap longer than this means the loop was stalled somewhere unrelated (a long
// flash write, a modal, a boot pause); charging it to a condition would be a lie.
constexpr uint64_t MAX_ATTRIBUTABLE_US = 5ULL * 1000000ULL;
Hooks g_hooks = {};
TransitionCb g_transition = nullptr;
bool g_enabled = false; // default OFF; driven from NVS via touchPrefsGetSleepIdle() at init
bool g_asleep_regime = false;
uint32_t g_cycle_count = 0; // throttle cycles while parked (liveness counter)
WakeReason g_last_reason = WakeReason::None;
// -1 = the gate passes; otherwise the (int)Blocker holding it shut.
int gateBlocker() {
if (!g_enabled) return (int)Blocker::Disabled;
if (!g_hooks.screenOff || !g_hooks.screenOff()) return (int)Blocker::ScreenOn;
if (!g_hooks.noClient || !g_hooks.noClient()) return (int)Blocker::ClientConnected;
if (!g_hooks.wifiOff || !g_hooks.wifiOff()) return (int)Blocker::WifiOn;
if (!g_hooks.bleOff || !g_hooks.bleOff()) return (int)Blocker::BleOn;
if (!g_hooks.onBattery || !g_hooks.onBattery()) return (int)Blocker::UsbPower;
if (!g_hooks.meshIdle || !g_hooks.meshIdle()) return (int)Blocker::MeshBusy;
return -1;
}
void emitTransition(bool entering) {
if (g_transition && g_hooks.epochNow) g_transition(g_hooks.epochNow(), entering);
}
} // namespace
void begin(const Hooks& hooks) { g_hooks = hooks; }
void onTransition(TransitionCb cb) { g_transition = cb; }
void setEnabled(bool on) { g_enabled = on; }
bool enabled() { return g_enabled; }
void loopEnd(uint32_t now_ms) {
const uint64_t t_enter = esp_timer_get_time();
const int blocker = gateBlocker();
// Charge the time since the last check. g_last_check_us is stamped AFTER the park
// below, so a completed park is never mis-attributed to the next blocker.
if (g_last_check_us) {
const uint64_t dt = t_enter - g_last_check_us;
if (dt < MAX_ATTRIBUTABLE_US) {
g_measured_us += dt;
if (blocker >= 0) g_blocked_us[blocker] += dt;
}
}
if (blocker >= 0) {
if (g_asleep_regime) { g_asleep_regime = false; emitTransition(false); } // sun — resumed activity
g_last_check_us = esp_timer_get_time();
return;
}
if (!g_asleep_regime) { g_asleep_regime = true; emitTransition(true); } // moon — parked
// Yield the CPU to the idle task, capped by the earliest retry/UI deadline.
// This is the wake timer for these builds: unlike manual esp_light_sleep_start,
// a timed FreeRTOS block remains watchdog-safe and resumes the loop on schedule.
uint32_t park_ms = THROTTLE_MS;
if (g_hooks.nextWakeForcingDueMs) {
const uint32_t wake_due_ms = g_hooks.nextWakeForcingDueMs(now_ms);
if (wake_due_ms < park_ms) park_ms = wake_due_ms;
}
if (park_ms == 0) {
g_last_reason = WakeReason::Timer;
g_last_check_us = esp_timer_get_time();
return;
}
const uint64_t t0 = esp_timer_get_time();
TickType_t park_ticks = pdMS_TO_TICKS(park_ms);
if (park_ticks == 0) park_ticks = 1;
vTaskDelay(park_ticks);
g_acc_idle_us += (uint64_t)(esp_timer_get_time() - t0);
g_cycle_count++;
g_last_reason = WakeReason::Timer; // a throttle is a timed yield
g_last_check_us = esp_timer_get_time();
}
bool isSleeping() { return g_asleep_regime; } // true while parked/throttling
uint32_t wakeCount() { return g_cycle_count; } // throttle cycles while parked
WakeReason lastWakeReason() { return g_last_reason; }
uint8_t blockedPct(Blocker b) {
const int i = (int)b;
if (i < 0 || i >= (int)Blocker::Count || g_measured_us == 0) return 0;
const uint64_t pct = (g_blocked_us[i] * 100ULL) / g_measured_us;
return pct > 100 ? 100 : (uint8_t)pct;
}
Blocker topBlocker() {
int best = -1;
uint64_t best_us = 0;
for (int i = 0; i < (int)Blocker::Count; ++i)
if (g_blocked_us[i] > best_us) { best_us = g_blocked_us[i]; best = i; }
return best < 0 ? Blocker::Count : (Blocker)best;
}
uint8_t pctAsleep() {
const uint64_t up = (uint64_t)esp_timer_get_time();
if (up == 0) return 0;
uint64_t pct = (g_acc_idle_us * 100ULL) / up;
return pct > 100 ? 100 : (uint8_t)pct;
}
} // namespace touchSleep