diff --git a/firmware/src/ble.cpp b/firmware/src/ble.cpp index afe0fe4..14d0a0f 100644 --- a/firmware/src/ble.cpp +++ b/firmware/src/ble.cpp @@ -236,6 +236,10 @@ void ble_clear_bonds(void) { need_advertise = true; } +bool ble_has_bonds(void) { + return NimBLEDevice::getNumBonds() > 0; +} + bool ble_has_data(void) { return data_ready; } diff --git a/firmware/src/ble.h b/firmware/src/ble.h index 675fc88..55c0e97 100644 --- a/firmware/src/ble.h +++ b/firmware/src/ble.h @@ -14,6 +14,7 @@ ble_state_t ble_get_state(void); const char* ble_get_device_name(void); const char* ble_get_mac_address(void); void ble_clear_bonds(void); +bool ble_has_bonds(void); bool ble_has_data(void); const char* ble_get_data(void); void ble_send_ack(void); diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 08a5cb2..1207553 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -11,6 +11,7 @@ #include "usage_rate.h" #include "idle.h" #include "idle_cfg.h" +#include "brightness.h" #include "hal/board_caps.h" #include "hal/display_hal.h" @@ -185,7 +186,8 @@ void setup() { display_hal_init(); display_hal_begin(); - idle_init(); // takes over brightness (DISPLAY_DEFAULT_BRIGHTNESS) and starts the idle timer + idle_init(); // takes over panel brightness and starts the idle timer + brightness_init(); // load the user's saved brightness level and apply via idle power_hal_init(); imu_hal_init(); @@ -226,6 +228,52 @@ void setup() { static ble_state_t last_ble_state = BLE_STATE_INIT; +// Hold-to-pair gesture: hold the PWR button ~3s, then RELEASE → clear all BLE +// bonds and re-advertise. Clearing on *release* (not while held) is deliberate: +// holding to power the device OFF (AXP hardware shutdown at 8s) must not wipe +// the bond — a power-off hold never releases before shutdown. To stop a +// "chicken-out" release just before 8s from pairing, the gesture disarms at 6s. +// +// ~1.5s long-press edge → PENDING +// 3.0s (+1500) → ARMED (release from here clears bonds) +// 6.0s (+4500) → DISARMED (no clear; AXP powers off at 8s) +#define PAIR_ARM_AFTER_LONG_MS 1500 // 3.0s total +#define PAIR_DISARM_AFTER_LONG_MS 4500 // 6.0s total +enum pair_state_t { PAIR_IDLE, PAIR_PENDING, PAIR_ARMED }; +static pair_state_t pair_state = PAIR_IDLE; +static uint32_t pair_long_seen_ms = 0; + +static void pair_tick(void) { + if (pair_state == PAIR_IDLE && power_hal_pwr_long_pressed()) { + pair_state = PAIR_PENDING; + pair_long_seen_ms = millis(); + (void)power_hal_pwr_released(); // drain any stale release edge + Serial.println("PWR long-press: hold to ~3s then release to pair"); + return; + } + if (pair_state == PAIR_IDLE) return; + + if (power_hal_pwr_released()) { + if (pair_state == PAIR_ARMED) { + Serial.println("Pair: released in window — clearing bonds, advertising"); + ble_clear_bonds(); + } else { + Serial.println("Pair: released too early — cancelled"); + } + pair_state = PAIR_IDLE; + return; + } + + uint32_t held = millis() - pair_long_seen_ms; + if (pair_state == PAIR_PENDING && held >= PAIR_ARM_AFTER_LONG_MS) { + pair_state = PAIR_ARMED; + Serial.println("Pair: armed — release to pair"); + } else if (pair_state == PAIR_ARMED && held >= PAIR_DISARM_AFTER_LONG_MS) { + pair_state = PAIR_IDLE; // power-off territory; don't pair + Serial.println("Pair: disarmed (holding toward power-off)"); + } +} + void loop() { idle_tick(); lv_timer_handler(); @@ -242,7 +290,8 @@ void loop() { // ---- Physical buttons ---- // PRIMARY → HID Space (Claude Code voice-mode PTT) // SECONDARY → HID Shift+Tab (mode toggle; only if the board has one) - // PWR → cycle screens; on splash, cycle animations + // PWR → on splash: cycle animations; on usage: cycle brightness; + // hold ~3s + release: pairing mode // First press from sleep is consumed as a wake-only event by // idle_consume_wake_press(); the normal action fires from the second // press. Activity bookkeeping happens inside idle_consume_wake_press @@ -280,10 +329,14 @@ void loop() { if (power_hal_pwr_pressed()) { if (!idle_consume_wake_press()) { + // On splash: cycle animations. On the usage view: cycle + // screen brightness (single non-splash view, no more screens). if (ui_get_current_screen() == SCREEN_SPLASH) splash_next(); - else ui_cycle_screen(); + else brightness_cycle(); } } + + pair_tick(); } ble_state_t bs = ble_get_state(); diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 6e12188..395b936 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -99,9 +99,11 @@ static void compute_layout(const BoardCaps& c) { #define COL_RED THEME_RED #define COL_BAR_BG THEME_BAR_BG -// ---- Usage screen widgets ---- +// ---- Usage screen widgets (single non-splash view) ---- static lv_obj_t* usage_container; static lv_obj_t* lbl_title; +static lv_obj_t* usage_group; // the two usage panels — shown when connected +static lv_obj_t* pair_group; // pairing hint — shown when disconnected static lv_obj_t* bar_session; static lv_obj_t* lbl_session_pct; static lv_obj_t* lbl_session_label; @@ -110,13 +112,7 @@ static lv_obj_t* bar_weekly; static lv_obj_t* lbl_weekly_pct; static lv_obj_t* lbl_weekly_label; static lv_obj_t* lbl_weekly_reset; -static lv_obj_t* lbl_anim; - -// ---- Bluetooth screen widgets ---- -static lv_obj_t* ble_container; -static lv_obj_t* lbl_ble_status; -static lv_obj_t* lbl_ble_device; -static lv_obj_t* lbl_ble_mac; +static lv_obj_t* lbl_anim; // status line: connection state + whimsical idle // ---- Battery indicator (shared, on top) ---- static lv_obj_t* battery_img; @@ -126,6 +122,8 @@ static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging // ---- Shared ---- static lv_image_dsc_t logo_dsc; static screen_t current_screen = SCREEN_USAGE; +static bool s_ble_connected = false; // cached BLE connection state +static uint32_t connected_at_ms = 0; // when we last entered CONNECTED ("Connected" dwell) // Animation state static uint32_t anim_last_ms = 0; @@ -201,7 +199,6 @@ static void format_reset_time(int mins, char* buf, size_t len) { // Forward decls — callbacks defined near ui_show_screen below static void global_click_cb(lv_event_t* e); -static void ble_reset_click_cb(lv_event_t* e); static lv_obj_t* make_panel(lv_obj_t* parent, int x, int y, int w, int h) { lv_obj_t* panel = lv_obj_create(parent); @@ -235,15 +232,6 @@ static lv_obj_t* make_bar(lv_obj_t* parent, int x, int y, int w, int h) { return bar; } -static void init_icon_dsc(lv_image_dsc_t* dsc, int w, int h, const uint16_t* data) { - dsc->header.w = w; - dsc->header.h = h; - dsc->header.cf = LV_COLOR_FORMAT_RGB565; - dsc->header.stride = w * 2; - dsc->data = (const uint8_t*)data; - dsc->data_size = w * h * 2; -} - static void init_icon_dsc_rgb565a8(lv_image_dsc_t* dsc, int w, int h, const uint8_t* data) { dsc->header.w = w; dsc->header.h = h; @@ -301,6 +289,39 @@ static void make_usage_panel(lv_obj_t* parent, int y, const char* pill_text, lv_obj_set_pos(*out_reset, 0, L.usage_reset_y); } +// Pairing hint — shown when disconnected so the screen isn't empty and the +// user knows how to (re)pair. Wording matches the 3-second release gesture. +static void build_pair_group(lv_obj_t* parent) { + pair_group = lv_obj_create(parent); + lv_obj_set_size(pair_group, L.scr_w, L.scr_h - L.content_y); + lv_obj_set_pos(pair_group, 0, L.content_y); + lv_obj_set_style_bg_opa(pair_group, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(pair_group, 0, 0); + lv_obj_set_style_pad_all(pair_group, 0, 0); + lv_obj_clear_flag(pair_group, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(pair_group, LV_OBJ_FLAG_EVENT_BUBBLE); + + lv_obj_t* l1 = lv_label_create(pair_group); + lv_label_set_text(l1, "To pair"); + lv_obj_set_style_text_font(l1, L.bt_status_font, 0); + lv_obj_set_style_text_color(l1, COL_TEXT, 0); + lv_obj_align(l1, LV_ALIGN_TOP_MID, 0, 40); + + lv_obj_t* l2 = lv_label_create(pair_group); + lv_label_set_text(l2, "hold the power button"); + lv_obj_set_style_text_font(l2, L.bt_device_font, 0); + lv_obj_set_style_text_color(l2, COL_DIM, 0); + lv_obj_align(l2, LV_ALIGN_TOP_MID, 0, 120); + + lv_obj_t* l3 = lv_label_create(pair_group); + lv_label_set_text(l3, "for 3 seconds, then release"); + lv_obj_set_style_text_font(l3, L.bt_device_font, 0); + lv_obj_set_style_text_color(l3, COL_DIM, 0); + lv_obj_align(l3, LV_ALIGN_TOP_MID, 0, 160); + + lv_obj_add_flag(pair_group, LV_OBJ_FLAG_HIDDEN); // ui_update_ble_status decides +} + static void init_usage_screen(lv_obj_t* scr) { usage_container = lv_obj_create(scr); lv_obj_set_size(usage_container, L.scr_w, L.scr_h); @@ -317,14 +338,28 @@ static void init_usage_screen(lv_obj_t* scr) { lv_obj_set_style_text_color(lbl_title, COL_TEXT, 0); lv_obj_align(lbl_title, LV_ALIGN_TOP_MID, 16, L.title_y); - make_usage_panel(usage_container, L.content_y, "Current", + // Usage panels (shown when connected) live in a transparent full-size group + // so they can be toggled against the pairing hint as one unit. + usage_group = lv_obj_create(usage_container); + lv_obj_set_size(usage_group, L.scr_w, L.scr_h); + lv_obj_set_pos(usage_group, 0, 0); + lv_obj_set_style_bg_opa(usage_group, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(usage_group, 0, 0); + lv_obj_set_style_pad_all(usage_group, 0, 0); + lv_obj_clear_flag(usage_group, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(usage_group, LV_OBJ_FLAG_EVENT_BUBBLE); + + make_usage_panel(usage_group, L.content_y, "Current", &lbl_session_pct, &lbl_session_label, &bar_session, &lbl_session_reset); - make_usage_panel(usage_container, + make_usage_panel(usage_group, L.content_y + L.usage_panel_h + L.usage_panel_gap, "Weekly", &lbl_weekly_pct, &lbl_weekly_label, &bar_weekly, &lbl_weekly_reset); + build_pair_group(usage_container); + + // Status line — always visible on the usage view. Driven by ui_tick_anim(). lbl_anim = lv_label_create(usage_container); lv_label_set_text(lbl_anim, ""); lv_obj_set_style_text_font(lbl_anim, &font_mono_32, 0); @@ -332,91 +367,6 @@ static void init_usage_screen(lv_obj_t* scr) { lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15); } -// ======== Bluetooth Screen ======== - -static void init_bluetooth_screen(lv_obj_t* scr) { - ble_container = lv_obj_create(scr); - lv_obj_set_size(ble_container, L.scr_w, L.scr_h); - lv_obj_set_pos(ble_container, 0, 0); - lv_obj_set_style_bg_opa(ble_container, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(ble_container, 0, 0); - lv_obj_set_style_pad_all(ble_container, 0, 0); - lv_obj_clear_flag(ble_container, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb(ble_container, global_click_cb, LV_EVENT_CLICKED, NULL); - - lv_obj_t* lbl_ble_title = lv_label_create(ble_container); - lv_label_set_text(lbl_ble_title, "Bluetooth"); - lv_obj_set_style_text_font(lbl_ble_title, L.bt_title_font, 0); - lv_obj_set_style_text_color(lbl_ble_title, COL_TEXT, 0); - lv_obj_align(lbl_ble_title, LV_ALIGN_TOP_MID, 16, L.title_y); - - lv_obj_t* p_info = make_panel(ble_container, L.margin, L.content_y, - L.content_w, L.bt_info_panel_h); - - static lv_image_dsc_t icon_bt_dsc; - init_icon_dsc(&icon_bt_dsc, ICON_BLUETOOTH_W, ICON_BLUETOOTH_H, icon_bluetooth_data); - - lv_obj_t* bt_img = lv_image_create(p_info); - lv_image_set_src(bt_img, &icon_bt_dsc); - lv_obj_set_pos(bt_img, 0, 0); - - lbl_ble_status = lv_label_create(p_info); - lv_label_set_text(lbl_ble_status, "Initializing..."); - lv_obj_set_style_text_font(lbl_ble_status, L.bt_status_font, 0); - lv_obj_set_style_text_color(lbl_ble_status, COL_DIM, 0); - lv_obj_set_pos(lbl_ble_status, 56, 2); - - lbl_ble_device = lv_label_create(p_info); - lv_label_set_text(lbl_ble_device, "Device: ---"); - lv_obj_set_style_text_font(lbl_ble_device, L.bt_device_font, 0); - lv_obj_set_style_text_color(lbl_ble_device, COL_DIM, 0); - lv_obj_set_pos(lbl_ble_device, 0, 64); - - lbl_ble_mac = lv_label_create(p_info); - lv_label_set_text(lbl_ble_mac, "Address: ---"); - lv_obj_set_style_text_font(lbl_ble_mac, L.bt_device_font, 0); - lv_obj_set_style_text_color(lbl_ble_mac, COL_DIM, 0); - lv_obj_set_pos(lbl_ble_mac, 0, 100); - - int reset_y = L.content_y + L.bt_info_panel_h + 16; - lv_obj_t* reset_zone = lv_obj_create(ble_container); - lv_obj_set_pos(reset_zone, L.margin, reset_y); - lv_obj_set_size(reset_zone, L.content_w, L.bt_reset_zone_h); - lv_obj_set_style_bg_color(reset_zone, COL_PANEL, 0); - lv_obj_set_style_bg_opa(reset_zone, LV_OPA_COVER, 0); - lv_obj_set_style_radius(reset_zone, 8, 0); - lv_obj_set_style_border_width(reset_zone, 0, 0); - lv_obj_set_style_pad_column(reset_zone, 14, 0); - lv_obj_set_flex_flow(reset_zone, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(reset_zone, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_clear_flag(reset_zone, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb(reset_zone, ble_reset_click_cb, LV_EVENT_CLICKED, NULL); - - static lv_image_dsc_t icon_trash_dsc; - init_icon_dsc(&icon_trash_dsc, ICON_TRASH2_W, ICON_TRASH2_H, icon_trash2_data); - lv_obj_t* trash_img = lv_image_create(reset_zone); - lv_image_set_src(trash_img, &icon_trash_dsc); - - lv_obj_t* reset_lbl = lv_label_create(reset_zone); - lv_label_set_text(reset_lbl, "Reset Bluetooth"); - lv_obj_set_style_text_font(reset_lbl, L.bt_device_font, 0); - lv_obj_set_style_text_color(reset_lbl, COL_DIM, 0); - - lv_obj_t* lbl_credit = lv_label_create(ble_container); - lv_label_set_text(lbl_credit, "Built by @hermannbjorgvin"); - lv_obj_set_style_text_font(lbl_credit, L.bt_credit_1_font, 0); - lv_obj_set_style_text_color(lbl_credit, COL_DIM, 0); - lv_obj_align(lbl_credit, LV_ALIGN_BOTTOM_MID, 0, -46); - - lv_obj_t* lbl_credit2 = lv_label_create(ble_container); - lv_label_set_text(lbl_credit2, "Clawd animation by @amaanbuilds"); - lv_obj_set_style_text_font(lbl_credit2, L.bt_credit_2_font, 0); - lv_obj_set_style_text_color(lbl_credit2, COL_DIM, 0); - lv_obj_align(lbl_credit2, LV_ALIGN_BOTTOM_MID, 0, -20); - - lv_obj_add_flag(ble_container, LV_OBJ_FLAG_HIDDEN); -} - // ======== Public API ======== void ui_init(void) { @@ -430,7 +380,6 @@ void ui_init(void) { init_battery_icons(); init_usage_screen(scr); - init_bluetooth_screen(scr); splash_init(scr); if (splash_get_root()) { @@ -478,18 +427,27 @@ void ui_tick_anim(void) { anim_msg_start = now; } - if (now - anim_last_ms >= spinner_ms[anim_spinner_idx]) { - anim_last_ms = now; - anim_phase = (anim_phase + 1) % SPINNER_PHASES; - anim_spinner_idx = (anim_phase < SPINNER_COUNT) ? anim_phase - : (SPINNER_PHASES - anim_phase); + if (now - anim_last_ms < spinner_ms[anim_spinner_idx]) return; + anim_last_ms = now; + anim_phase = (anim_phase + 1) % SPINNER_PHASES; + anim_spinner_idx = (anim_phase < SPINNER_COUNT) ? anim_phase + : (SPINNER_PHASES - anim_phase); - static char buf[80]; - snprintf(buf, sizeof(buf), "%s %s\xE2\x80\xA6", - spinner_frames[anim_spinner_idx], - anim_messages[anim_msg_idx]); - lv_label_set_text(lbl_anim, buf); + // Status text by priority. Whimsical messages only when connected & settled. + const char* text; + if (!s_ble_connected) { + text = ble_has_bonds() ? "Disconnected" : "Pairing"; + } else if (now - connected_at_ms < 5000) { + text = "Connected"; + } else { + text = anim_messages[anim_msg_idx]; } + + // All states share the whimsical style: " …" + static char buf[80]; + snprintf(buf, sizeof(buf), "%s %s\xE2\x80\xA6", + spinner_frames[anim_spinner_idx], text); + lv_label_set_text(lbl_anim, buf); } static screen_t prev_non_splash_screen = SCREEN_USAGE; @@ -505,20 +463,13 @@ static void global_click_cb(lv_event_t* e) { else ui_show_screen(SCREEN_SPLASH); } -static void ble_reset_click_cb(lv_event_t* e) { - (void)e; - ble_clear_bonds(); -} - void ui_show_screen(screen_t screen) { lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(ble_container, LV_OBJ_FLAG_HIDDEN); splash_hide(); switch (screen) { - case SCREEN_SPLASH: splash_show(); break; - case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break; - case SCREEN_BLUETOOTH: lv_obj_clear_flag(ble_container, LV_OBJ_FLAG_HIDDEN); break; + case SCREEN_SPLASH: splash_show(); break; + case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break; default: break; } @@ -532,16 +483,6 @@ void ui_show_screen(screen_t screen) { apply_battery_visibility(); } -void ui_cycle_screen(void) { - screen_t next; - switch (current_screen) { - case SCREEN_USAGE: next = SCREEN_BLUETOOTH; break; - case SCREEN_BLUETOOTH: next = SCREEN_USAGE; break; - default: next = SCREEN_USAGE; break; - } - ui_show_screen(next); -} - void ui_toggle_splash(void) { if (current_screen == SCREEN_SPLASH) ui_show_screen(prev_non_splash_screen); else ui_show_screen(SCREEN_SPLASH); @@ -552,35 +493,23 @@ screen_t ui_get_current_screen(void) { } void ui_update_ble_status(ble_state_t state, const char* name, const char* mac) { - switch (state) { - case BLE_STATE_CONNECTED: - lv_label_set_text(lbl_ble_status, "Connected"); - lv_obj_set_style_text_color(lbl_ble_status, COL_GREEN, 0); - break; - case BLE_STATE_ADVERTISING: - lv_label_set_text(lbl_ble_status, "Advertising..."); - lv_obj_set_style_text_color(lbl_ble_status, COL_AMBER, 0); - break; - case BLE_STATE_DISCONNECTED: - lv_label_set_text(lbl_ble_status, "Disconnected"); - lv_obj_set_style_text_color(lbl_ble_status, COL_RED, 0); - break; - default: - lv_label_set_text(lbl_ble_status, "Initializing..."); - lv_obj_set_style_text_color(lbl_ble_status, COL_DIM, 0); - break; + (void)name; (void)mac; + bool was_connected = s_ble_connected; + s_ble_connected = (state == BLE_STATE_CONNECTED); + + // Connected → usage panels; otherwise → pairing hint. The bottom status + // line carries the live state word (Connected / Disconnected / Pairing). + if (usage_group && pair_group) { + if (s_ble_connected) { + lv_obj_clear_flag(usage_group, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(pair_group, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(usage_group, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(pair_group, LV_OBJ_FLAG_HIDDEN); + } } - if (name) { - static char nbuf[48]; - snprintf(nbuf, sizeof(nbuf), "Device: %s", name); - lv_label_set_text(lbl_ble_device, nbuf); - } - if (mac) { - static char mbuf[48]; - snprintf(mbuf, sizeof(mbuf), "Address: %s", mac); - lv_label_set_text(lbl_ble_mac, mbuf); - } + if (s_ble_connected && !was_connected) connected_at_ms = lv_tick_get(); } void ui_update_battery(int percent, bool charging) { diff --git a/firmware/src/ui.h b/firmware/src/ui.h index b18068c..60d8155 100644 --- a/firmware/src/ui.h +++ b/firmware/src/ui.h @@ -5,7 +5,6 @@ enum screen_t { SCREEN_SPLASH, SCREEN_USAGE, - SCREEN_BLUETOOTH, SCREEN_COUNT, }; @@ -13,7 +12,6 @@ void ui_init(void); void ui_update(const UsageData* data); void ui_tick_anim(void); void ui_show_screen(screen_t screen); -void ui_cycle_screen(void); void ui_toggle_splash(void); screen_t ui_get_current_screen(void); void ui_update_ble_status(ble_state_t state, const char* name, const char* mac);