From 6655b0f6d066198d97b93c2e891bcfa80902340e Mon Sep 17 00:00:00 2001 From: tobby168 Date: Tue, 19 May 2026 12:41:49 -0700 Subject: [PATCH 1/2] firmware: auto-sleep screen after 30 min idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AMOLED brightness fades to 0 after IDLE_TIMEOUT_MS without a physical button press; any of the three buttons (BTN_BACK / BTN_FWD / PWR) fades it back in. The first press from sleep is consumed for wake only — Space / Shift+Tab / cycle-screen only fire on the second press, so reaching for the device to glance at it doesn't accidentally send PTT to Claude. Touch never counts as activity and is fully swallowed while asleep: pets and sleeves can't wake it, and LVGL can't secretly toggle splash<->usage behind a black panel. Rotation handling defers while asleep so its blank+ramp doesn't fight the idle fade; a rotation that happens during sleep is detected and ramped in on wake. All tunables live in idle_cfg.h (timeout, fade durations, default brightness) — nothing is hard-coded in main.cpp. AMOLED brightness=0 is true 0 emission on these self-emissive panels, so no extra power-management plumbing (light-sleep, AXP rail gating) is needed to get the battery savings. --- firmware/src/idle.cpp | 103 ++++++++++++++++++++++++++++++++++++++++ firmware/src/idle.h | 16 +++++++ firmware/src/idle_cfg.h | 11 +++++ firmware/src/main.cpp | 52 ++++++++++++++++---- 4 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 firmware/src/idle.cpp create mode 100644 firmware/src/idle.h create mode 100644 firmware/src/idle_cfg.h diff --git a/firmware/src/idle.cpp b/firmware/src/idle.cpp new file mode 100644 index 0000000..c8d9192 --- /dev/null +++ b/firmware/src/idle.cpp @@ -0,0 +1,103 @@ +#include +#include "idle.h" +#include "idle_cfg.h" +#include "display_cfg.h" // declares `extern Arduino_CO5300 *gfx;` + +enum IdleState { + STATE_AWAKE, + STATE_FADING_OUT, + STATE_ASLEEP, + STATE_FADING_IN, +}; + +static IdleState state = STATE_AWAKE; +static uint32_t last_activity_ms = 0; +static uint32_t fade_started_ms = 0; +static uint32_t fade_last_step_ms = 0; +static uint8_t fade_from = DISPLAY_DEFAULT_BRIGHTNESS; +static uint8_t fade_to = 0; + +static void apply_brightness(uint8_t b) { + gfx->setBrightness(b); +} + +static void begin_fade(uint8_t to, uint32_t now) { + fade_from = (to == 0) ? DISPLAY_DEFAULT_BRIGHTNESS : 0; + fade_to = to; + fade_started_ms = now; + fade_last_step_ms = now; +} + +void idle_init(void) { + state = STATE_AWAKE; + last_activity_ms = millis(); + apply_brightness(DISPLAY_DEFAULT_BRIGHTNESS); +} + +void idle_note_activity(void) { + last_activity_ms = millis(); + if (state == STATE_FADING_IN) return; + if (state == STATE_AWAKE) return; + // Asleep/fading-out shouldn't reach here in normal flow (callers gate via + // idle_consume_wake_press first), but if it does: trigger a wake. + begin_fade(DISPLAY_DEFAULT_BRIGHTNESS, last_activity_ms); + state = STATE_FADING_IN; +} + +bool idle_consume_wake_press(void) { + if (state == STATE_ASLEEP || state == STATE_FADING_OUT) { + uint32_t now = millis(); + last_activity_ms = now; + begin_fade(DISPLAY_DEFAULT_BRIGHTNESS, now); + state = STATE_FADING_IN; + return true; + } + if (state == STATE_FADING_IN) { + // Mid-wake — still swallow this press; the user shouldn't get a + // half-wake half-action surprise. + last_activity_ms = millis(); + return true; + } + last_activity_ms = millis(); + return false; +} + +bool idle_is_asleep(void) { + return state == STATE_ASLEEP || state == STATE_FADING_OUT; +} + +void idle_tick(void) { + uint32_t now = millis(); + + switch (state) { + case STATE_AWAKE: + if (now - last_activity_ms >= IDLE_TIMEOUT_MS) { + begin_fade(0, now); + state = STATE_FADING_OUT; + } + break; + + case STATE_FADING_OUT: + case STATE_FADING_IN: { + if (now - fade_last_step_ms < IDLE_FADE_STEP_MS) break; + fade_last_step_ms = now; + uint32_t dur = (state == STATE_FADING_OUT) ? IDLE_FADE_OUT_MS : IDLE_FADE_IN_MS; + uint32_t elapsed = now - fade_started_ms; + if (elapsed >= dur) { + apply_brightness(fade_to); + state = (state == STATE_FADING_OUT) ? STATE_ASLEEP : STATE_AWAKE; + } else { + // Linear interpolation fade_from -> fade_to over dur ms. + int32_t span = (int32_t)fade_to - (int32_t)fade_from; + int32_t b = (int32_t)fade_from + (span * (int32_t)elapsed) / (int32_t)dur; + if (b < 0) b = 0; + if (b > 255) b = 255; + apply_brightness((uint8_t)b); + } + break; + } + + case STATE_ASLEEP: + break; + } +} diff --git a/firmware/src/idle.h b/firmware/src/idle.h new file mode 100644 index 0000000..0ce424c --- /dev/null +++ b/firmware/src/idle.h @@ -0,0 +1,16 @@ +#pragma once +#include + +void idle_init(void); +void idle_tick(void); +void idle_note_activity(void); + +// Returns true if this press was consumed as a wake-up (caller MUST skip the +// button's normal action). Returns false when already awake — also notes the +// activity, so callers don't need a separate idle_note_activity() call. +bool idle_consume_wake_press(void); + +// Touch should NOT count as activity (avoids accidental wakes from pets, +// sleeves, etc.). Callers use this to silently drop touch events while the +// panel is dark. +bool idle_is_asleep(void); diff --git a/firmware/src/idle_cfg.h b/firmware/src/idle_cfg.h new file mode 100644 index 0000000..33ce2ec --- /dev/null +++ b/firmware/src/idle_cfg.h @@ -0,0 +1,11 @@ +#pragma once + +// Auto-sleep / idle screen-off configuration. +// All tunables live here so nothing is hard-coded in main.cpp / idle.cpp. + +#define IDLE_TIMEOUT_MS (30UL * 60UL * 1000UL) // 30 min +#define IDLE_FADE_OUT_MS 400 // fade-to-black duration +#define IDLE_FADE_IN_MS 180 // wake fade-in (snappier) +#define IDLE_FADE_STEP_MS 20 // tick interval per fade step + +#define DISPLAY_DEFAULT_BRIGHTNESS 200 // active-screen brightness diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 65552cf..91b2b1e 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -9,6 +9,8 @@ #include "imu.h" #include "splash.h" #include "usage_rate.h" +#include "idle.h" +#include "idle_cfg.h" // Physical buttons (global, screen-independent): // BTN_BACK (GPIO 0) — left, send Space (Claude Code voice mode push-to-talk) @@ -52,6 +54,11 @@ static void touch_read() { } else { touch_pressed = false; } + + // Touch never counts as user activity and is fully swallowed while the + // panel is dark — avoids accidental wakes and prevents LVGL from secretly + // toggling splash<->usage behind a black screen. + if (idle_is_asleep()) touch_pressed = false; } // ---- LVGL draw buffers (PSRAM-backed, partial render) ---- @@ -234,7 +241,7 @@ void setup() { // Init display gfx->begin(); gfx->fillScreen(0x0000); - gfx->setBrightness(200); + idle_init(); // sets brightness to DISPLAY_DEFAULT_BRIGHTNESS and starts idle timer // Init PMU power_init(); @@ -310,6 +317,11 @@ static void handle_rotation_change(void) { static uint8_t ramp_step = 0; // 0=idle, 1-4=ramping static uint32_t ramp_last = 0; + // While asleep the rotation visual transition (blank + ramp) would fight + // the idle fade. Defer: a rotation that happens during sleep will be + // detected after wake and ramped in then. + if (idle_is_asleep()) return; + uint8_t rot = imu_get_rotation(); if (rot != last_rotation) { gfx->setBrightness(0); @@ -324,7 +336,7 @@ static void handle_rotation_change(void) { if (now - ramp_last < 25) return; ramp_last = now; - static const uint8_t levels[] = {60, 120, 170, 200}; + static const uint8_t levels[] = {60, 120, 170, DISPLAY_DEFAULT_BRIGHTNESS}; gfx->setBrightness(levels[ramp_step - 1]); if (ramp_step >= 4) ramp_step = 0; else ramp_step++; @@ -332,6 +344,7 @@ static void handle_rotation_change(void) { void loop() { touch_read(); + idle_tick(); lv_timer_handler(); ui_tick_anim(); ble_tick(); @@ -343,25 +356,48 @@ void loop() { // LEFT (GPIO 0) → Space (voice-mode push-to-talk; press & release tracked) // RIGHT (GPIO 18) → Shift+Tab (Claude Code mode toggle) // PWR (AXP) → cycle screens; on splash, cycle animations + // First press from sleep is consumed for wake only (idle_consume_wake_press + // returns true) — the normal action only fires from the second press. + // Activity bookkeeping happens inside idle_consume_wake_press, so no + // separate idle_note_activity() call is needed here. { static bool back_was = false, fwd_was = false; + static bool back_wake_swallowed = false, fwd_wake_swallowed = false; bool back_now = (digitalRead(BTN_BACK) == LOW); bool fwd_now = (digitalRead(BTN_FWD) == LOW); if (back_now != back_was) { - if (back_now) ble_keyboard_press(0x2C, 0); // HID Space, no mods - else ble_keyboard_release(); + if (back_now) { + if (idle_consume_wake_press()) { + back_wake_swallowed = true; + } else { + ble_keyboard_press(0x2C, 0); // HID Space, no mods + } + } else { + if (back_wake_swallowed) back_wake_swallowed = false; + else ble_keyboard_release(); + } back_was = back_now; } if (fwd_now != fwd_was) { - if (fwd_now) ble_keyboard_press(0x2B, 0x02); // HID Tab + LEFT_SHIFT - else ble_keyboard_release(); + if (fwd_now) { + if (idle_consume_wake_press()) { + fwd_wake_swallowed = true; + } else { + ble_keyboard_press(0x2B, 0x02); // HID Tab + LEFT_SHIFT + } + } else { + if (fwd_wake_swallowed) fwd_wake_swallowed = false; + else ble_keyboard_release(); + } fwd_was = fwd_now; } if (power_pwr_pressed()) { - if (ui_get_current_screen() == SCREEN_SPLASH) splash_next(); - else ui_cycle_screen(); + if (!idle_consume_wake_press()) { + if (ui_get_current_screen() == SCREEN_SPLASH) splash_next(); + else ui_cycle_screen(); + } } } From e0829cebdf3e575b4875fb440b06d50394a93ded Mon Sep 17 00:00:00 2001 From: tobby168 Date: Tue, 19 May 2026 20:39:03 -0700 Subject: [PATCH 2/2] idle: add IDLE_SLEEP_WHEN_CHARGING and IDLE_WAKE_ON_TOUCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #24: - New config IDLE_SLEEP_WHEN_CHARGING (default false): while USB is plugged in we don't enter sleep and we wake immediately if power comes back. Covers both "I like watching Clawd animations on my plugged-in desk device" and "alternative hardware without a battery is always on USB, sleep would be odd." - New config IDLE_WAKE_ON_TOUCH (default true): a touch on the dark panel wakes the device. The first touch is swallowed (mirrors the button wake-consumption), so tapping a dark screen wakes without also toggling splash<->usage. Set false to keep the original pets/sleeves-safe behaviour where touch is fully ignored while asleep. power.{h,cpp} grow a power_is_vbus_in() helper backed by pmu.isVbusIn() — distinct from isCharging() so the no-battery case ("plugged in, nothing to charge") is detected correctly. --- firmware/src/idle.cpp | 11 +++++++++++ firmware/src/idle_cfg.h | 12 ++++++++++++ firmware/src/main.cpp | 33 +++++++++++++++++++++++++++++---- firmware/src/power.cpp | 7 +++++++ firmware/src/power.h | 1 + 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/firmware/src/idle.cpp b/firmware/src/idle.cpp index c8d9192..ad7349f 100644 --- a/firmware/src/idle.cpp +++ b/firmware/src/idle.cpp @@ -2,6 +2,7 @@ #include "idle.h" #include "idle_cfg.h" #include "display_cfg.h" // declares `extern Arduino_CO5300 *gfx;` +#include "power.h" enum IdleState { STATE_AWAKE, @@ -69,6 +70,16 @@ bool idle_is_asleep(void) { void idle_tick(void) { uint32_t now = millis(); + // While on USB power (if configured), don't sleep — and wake from sleep + // when power comes back. Treats USB-in as continuous activity. + if (!IDLE_SLEEP_WHEN_CHARGING && power_is_vbus_in()) { + last_activity_ms = now; + if (state == STATE_ASLEEP || state == STATE_FADING_OUT) { + begin_fade(DISPLAY_DEFAULT_BRIGHTNESS, now); + state = STATE_FADING_IN; + } + } + switch (state) { case STATE_AWAKE: if (now - last_activity_ms >= IDLE_TIMEOUT_MS) { diff --git a/firmware/src/idle_cfg.h b/firmware/src/idle_cfg.h index 33ce2ec..2586704 100644 --- a/firmware/src/idle_cfg.h +++ b/firmware/src/idle_cfg.h @@ -9,3 +9,15 @@ #define IDLE_FADE_STEP_MS 20 // tick interval per fade step #define DISPLAY_DEFAULT_BRIGHTNESS 200 // active-screen brightness + +// When false, the device never enters sleep while USB power is present (also +// wakes from sleep when USB is plugged back in). Useful when sitting on a +// desk plugged in — also covers battery-less hardware that's always on USB. +// Set true to sleep regardless of power source. +#define IDLE_SLEEP_WHEN_CHARGING false + +// When true, a touch on the dark panel wakes the device (first touch is +// consumed for wake only, second touch acts normally). When false, touch is +// fully ignored during sleep — useful if cats/sleeves brushing the panel +// overnight would be a problem. +#define IDLE_WAKE_ON_TOUCH true diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 91b2b1e..4ba85af 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -55,10 +55,35 @@ static void touch_read() { touch_pressed = false; } - // Touch never counts as user activity and is fully swallowed while the - // panel is dark — avoids accidental wakes and prevents LVGL from secretly - // toggling splash<->usage behind a black screen. - if (idle_is_asleep()) touch_pressed = false; + // Touch policy is driven by IDLE_WAKE_ON_TOUCH: + // true → a press edge while asleep wakes the device and the first + // touch is swallowed (mirrors the button wake-consumption); a + // press while awake counts as activity. + // false → touch never counts as activity and is fully swallowed while + // the panel is dark, so pets/sleeves can't wake it overnight + // and LVGL can't quietly toggle splash<->usage on a black panel. + if (IDLE_WAKE_ON_TOUCH) { + static bool touch_was = false; + static bool touch_wake_swallowed = false; + bool touch_now = touch_pressed; + if (touch_now && !touch_was) { + if (idle_consume_wake_press()) { + touch_wake_swallowed = true; + touch_pressed = false; // hide this press from LVGL + } + } else if (!touch_now && touch_was) { + if (touch_wake_swallowed) { + touch_wake_swallowed = false; + touch_pressed = false; // also hide the corresponding release + } + } else if (touch_now && touch_wake_swallowed) { + // Held finger through wake — keep hiding until release. + touch_pressed = false; + } + touch_was = touch_now; + } else { + if (idle_is_asleep()) touch_pressed = false; + } } // ---- LVGL draw buffers (PSRAM-backed, partial render) ---- diff --git a/firmware/src/power.cpp b/firmware/src/power.cpp index 23d711d..d131dea 100644 --- a/firmware/src/power.cpp +++ b/firmware/src/power.cpp @@ -8,6 +8,7 @@ static int cached_pct = -1; static bool cached_charging = false; +static bool cached_vbus = false; static bool pwr_pressed_flag = false; static uint32_t last_battery_ms = 0; static uint32_t last_charging_ms = 0; @@ -30,6 +31,7 @@ void power_init(void) { pmu.enableIRQ(XPOWERS_AXP2101_PKEY_SHORT_IRQ); cached_charging = pmu.isCharging(); + cached_vbus = pmu.isVbusIn(); cached_pct = pmu.getBatteryPercent(); } @@ -39,6 +41,7 @@ void power_tick(void) { if (now - last_charging_ms >= CHARGING_POLL_MS) { last_charging_ms = now; cached_charging = pmu.isCharging(); + cached_vbus = pmu.isVbusIn(); } if (now - last_battery_ms >= BATTERY_POLL_MS) { @@ -65,6 +68,10 @@ bool power_is_charging(void) { return cached_charging; } +bool power_is_vbus_in(void) { + return cached_vbus; +} + bool power_pwr_pressed(void) { if (pwr_pressed_flag) { pwr_pressed_flag = false; diff --git a/firmware/src/power.h b/firmware/src/power.h index 81736b4..5008c6c 100644 --- a/firmware/src/power.h +++ b/firmware/src/power.h @@ -4,4 +4,5 @@ void power_init(void); void power_tick(void); int power_battery_pct(void); // 0-100, or -1 if no battery bool power_is_charging(void); +bool power_is_vbus_in(void); // USB cable present (true even with no battery) bool power_pwr_pressed(void); // true once per AXP2101 PWR button short-press