Collapse UX to single Usage view + hold-to-pair + status line

Removes the Bluetooth screen — the device now has just splash + Usage. Keeps
main's responsive Layout intact.

- ui: drop the Bluetooth screen, ui_cycle_screen, and SCREEN_BLUETOOTH. The
  bottom spinner becomes a status line — Connected / Disconnected / Pairing…
  (via ble_has_bonds()) plus the whimsical messages when connected and idle.
  A "To pair / hold the power button / for 3 seconds, then release" hint shows
  while disconnected so the screen isn't empty.
- main: hold-to-pair gesture (pair_tick) — hold PWR ~3s then RELEASE clears
  bonds and re-advertises. Clearing on release (not while held) means holding
  to power off (8s hardware shutdown) leaves the bond intact; disarms at 6s.
  PWR short-press now cycles brightness on the Usage view (animations on splash).
- ble: add ble_has_bonds() for the status line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hermann Björgvin Haraldsson
2026-06-02 14:01:57 +00:00
co-authored by Claude Opus 4.7
parent ddb812ab5d
commit 2b4a0802fa
5 changed files with 152 additions and 167 deletions
+4
View File
@@ -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;
}
+1
View File
@@ -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);
+56 -3
View File
@@ -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();
+83 -154
View File
@@ -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]) {
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);
// 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: "<glyph> <Title-case word>…"
static char buf[80];
snprintf(buf, sizeof(buf), "%s %s\xE2\x80\xA6",
spinner_frames[anim_spinner_idx],
anim_messages[anim_msg_idx]);
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;
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) {
-2
View File
@@ -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);