diff --git a/firmware/src/idle.cpp b/firmware/src/idle.cpp new file mode 100644 index 0000000..ad7349f --- /dev/null +++ b/firmware/src/idle.cpp @@ -0,0 +1,114 @@ +#include +#include "idle.h" +#include "idle_cfg.h" +#include "display_cfg.h" // declares `extern Arduino_CO5300 *gfx;` +#include "power.h" + +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(); + + // 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) { + 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..2586704 --- /dev/null +++ b/firmware/src/idle_cfg.h @@ -0,0 +1,23 @@ +#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 + +// 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 65552cf..4ba85af 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,36 @@ static void touch_read() { } else { 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) ---- @@ -234,7 +266,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 +342,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 +361,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 +369,7 @@ static void handle_rotation_change(void) { void loop() { touch_read(); + idle_tick(); lv_timer_handler(); ui_tick_anim(); ble_tick(); @@ -343,25 +381,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(); + } } } 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