Files
clawdmeter/firmware/src/ui.cpp
T
wenilandClaude Opus 4.8 578bc04248 v2 firmware: HA command channel, battery screen, dynamic Home buttons, tilt-dimmer
Watch-side features developed on the AMOLED-2.06 and shared across all boards:

- BLE command channel (…0005, notify watch→PC) via ble_send_command(); the
  watch stays "dumb" and the daemon maps commands to Home Assistant.
- Battery detail screen + protective low-voltage cutoff; power HAL gains
  battery_mv() / shutdown(), with battery_est.{h,cpp} for the time-left anchor.
- Home screen: dynamic 2-column button grid driven by the desktop config
  (RX "btns" labels); tap notifies {"cmd":"btn","i":N}.
- Dimmer screen: IMU tilt-joystick over the light's brightness / color temp.
  imu_hal_read_accel() added to the HAL (real on 2.06/2.16, no-op elsewhere);
  streams absolute {"cmd":"bri"|"ct","v":..}; daemon seeds the dial via "dim".
  Per-board tilt axis is calibrated with the DIM_CALIB overlay (off by default).

Multi-board fix: enable LV_FONT_MONTSERRAT_20/28 on the 2.16, 1.8 and C6 envs.
Shared ui.cpp uses these glyphs for the launcher tiles and back chevron, so
those three boards had failed to compile since the Phase-2 UI landed. All four
envs now build clean. README: correct the Windows pairing name to "Clawdmeter".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:20:04 +03:00

1656 lines
71 KiB
C++

#include "ui.h"
#include "splash.h"
#include <lvgl.h>
#include <string.h>
#include <math.h>
#include "logo.h"
#include "icons.h"
#include "hal/board_caps.h"
#include "hal/imu_hal.h"
// Custom fonts (scaled for 314 PPI, ~1.9x from original 165 PPI)
LV_FONT_DECLARE(font_tiempos_56);
LV_FONT_DECLARE(font_tiempos_34);
LV_FONT_DECLARE(font_styrene_48);
LV_FONT_DECLARE(font_styrene_28);
LV_FONT_DECLARE(font_styrene_24);
LV_FONT_DECLARE(font_styrene_20);
LV_FONT_DECLARE(font_styrene_16);
LV_FONT_DECLARE(font_styrene_14);
// Composite Styrene (Latin) + Montserrat (Cyrillic) — used where user content
// (media titles) can be non-Latin; brand Styrene has no Cyrillic glyphs.
LV_FONT_DECLARE(font_styrene_cyr_28);
LV_FONT_DECLARE(font_styrene_cyr_20);
LV_FONT_DECLARE(font_mono_32);
// Layout values computed from the active board's geometry. Populated once
// in ui_init() and treated as const for the rest of the program. Adding a
// new display size means extending compute_layout() with another
// breakpoint — never editing the screen-builder functions below.
struct Layout {
int16_t scr_w, scr_h;
int16_t margin;
int16_t title_y;
int16_t content_y;
int16_t content_w;
// Usage screen
int16_t usage_panel_h;
int16_t usage_panel_gap;
int16_t usage_bar_y;
int16_t usage_reset_y;
// Bluetooth screen
int16_t bt_info_panel_h;
int16_t bt_reset_zone_h;
const lv_font_t* bt_title_font;
const lv_font_t* bt_status_font;
const lv_font_t* bt_device_font;
const lv_font_t* bt_credit_1_font;
const lv_font_t* bt_credit_2_font;
};
static Layout L = {};
// Pick layout values from the active board's pixel dimensions. The two
// existing boards happen to land on the two breakpoints below; new ports
// inherit the closer one — visually OK, may need a polish pass for
// pixel-perfect alignment but never blocks the port from booting.
static void compute_layout(const BoardCaps& c) {
L.scr_w = c.width;
L.scr_h = c.height;
L.margin = 20;
L.title_y = 30;
if (c.height >= 460) {
// Large layout — tuned for 480x480 (AMOLED-2.16).
L.content_y = 100;
L.usage_panel_h = 150;
L.usage_panel_gap = 16;
L.usage_bar_y = 56;
L.usage_reset_y = 94;
L.bt_info_panel_h = 160;
L.bt_reset_zone_h = 110;
L.bt_title_font = &font_tiempos_56;
L.bt_status_font = &font_styrene_48;
L.bt_device_font = &font_styrene_28;
L.bt_credit_1_font = &font_styrene_24;
L.bt_credit_2_font = &font_styrene_20;
} else {
// Compact layout — tuned for 368x448 (AMOLED-1.8).
L.content_y = 85;
L.usage_panel_h = 130;
L.usage_panel_gap = 12;
L.usage_bar_y = 48;
L.usage_reset_y = 78;
L.bt_info_panel_h = 140;
L.bt_reset_zone_h = 90;
L.bt_title_font = &font_tiempos_34;
L.bt_status_font = &font_styrene_28;
L.bt_device_font = &font_styrene_20;
L.bt_credit_1_font = &font_styrene_16;
L.bt_credit_2_font = &font_styrene_14;
}
L.content_w = L.scr_w - 2 * L.margin;
}
// Anthropic brand palette — design tokens live in theme.h
#include "theme.h"
#define COL_BG THEME_BG
#define COL_PANEL THEME_PANEL
#define COL_TEXT THEME_TEXT
#define COL_DIM THEME_DIM
#define COL_ACCENT THEME_ACCENT
#define COL_GREEN THEME_GREEN
#define COL_AMBER THEME_AMBER
#define COL_RED THEME_RED
#define COL_BAR_BG THEME_BAR_BG
// ---- 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;
static lv_obj_t* lbl_session_reset;
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; // status line: connection state + whimsical idle
// ---- Battery indicator (shared, on top) ----
static lv_obj_t* battery_img;
static lv_obj_t* logo_img;
static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging
// Latest battery telemetry, pushed by ui_update_battery() and read by the
// battery detail screen. minutes_left: >=0 minutes, -1 estimating, -2 charging.
static int g_bat_pct = -1;
static int g_bat_mv = 0;
static int g_bat_min = -2;
static bool g_bat_charging = false;
// ---- v2 navigation: launcher, stub & bluetooth screens ----
static lv_obj_t* nav_back_btn; // top-left back chevron (shown off the home screen)
static lv_obj_t* menu_container; // app launcher (scrollable tile grid)
static lv_obj_t* bt_container; // BLE connection info
static lv_obj_t* bt_status_lbl;
static lv_obj_t* bt_name_lbl;
static lv_obj_t* bt_mac_lbl;
static lv_obj_t* session_container; // today's tokens / cost
static lv_obj_t* sess_cost_lbl;
static lv_obj_t* sess_tokens_lbl;
static lv_obj_t* sess_gen_lbl;
static lv_obj_t* sess_msgs_lbl;
static lv_obj_t* nowplaying_container; // media now-playing (Phase 5)
static lv_obj_t* np_icon_lbl; // play / pause / music glyph
static lv_obj_t* np_title_lbl; // track title (scrolls if long)
static lv_obj_t* np_artist_lbl; // track artist
static lv_obj_t* np_status_lbl; // "Playing" / "Paused"
static lv_obj_t* battery_container; // battery detail (voltage + time left)
static lv_obj_t* bat_pct_lbl; // big percentage hero
static lv_obj_t* bat_bar; // horizontal charge bar
static lv_obj_t* bat_volt_lbl; // "3.92 V"
static lv_obj_t* bat_time_lbl; // "~2h 15m left" / "Charging" / "Estimating…"
static lv_obj_t* bat_note_lbl; // protective-cutoff note
static lv_obj_t* homeassist_container; // Home control: config-driven buttons (Phase 7 M2)
static lv_obj_t* ha_status_lbl; // bottom feedback line ("Sent: <label>")
static lv_obj_t* ha_empty_lbl; // hint shown when no buttons are configured
static lv_obj_t* ha_btn_grid; // flex grid holding the button tiles
static lv_obj_t* ha_btns[UI_MAX_BUTTONS]; // pre-created tiles (shown/hidden by count)
static lv_obj_t* ha_btn_lbls[UI_MAX_BUTTONS]; // each tile's text label
static char ha_btn_text[UI_MAX_BUTTONS][20]; // copied labels (Cyrillic-safe, NUL-terminated)
static int ha_btn_count = 0; // how many tiles are currently shown
static lv_obj_t* dimmer_container; // tilt-to-dim light control (Phase 6 step 3 / M3)
static lv_obj_t* dim_arc; // value dial (brightness % or color-temp K)
static lv_obj_t* dim_value_lbl; // big number in the dial center
static lv_obj_t* dim_param_lbl; // "Brightness" / "Temp"
static lv_obj_t* dim_status_lbl; // hint / armed state line
// App registry — the launcher renders one tile per entry, so adding a screen is
// one line here plus its builder. Order = tile order. "Animations" reuses the
// splash; the four future apps open the shared stub until their phase lands.
struct AppEntry {
screen_t screen;
const char* label;
const char* symbol; // LVGL/Montserrat symbol glyph
};
static const AppEntry APPS[] = {
{ SCREEN_USAGE, "Usage", LV_SYMBOL_CHARGE },
{ SCREEN_SPLASH, "Animations", LV_SYMBOL_IMAGE },
{ SCREEN_SOUNDPAD, "Soundpad", LV_SYMBOL_AUDIO },
{ SCREEN_SESSION, "Session", LV_SYMBOL_LIST },
{ SCREEN_NOWPLAYING, "Now Playing", LV_SYMBOL_PLAY },
{ SCREEN_HOMEASSIST, "Home", LV_SYMBOL_HOME },
{ SCREEN_DIMMER, "Dimmer", LV_SYMBOL_SETTINGS },
{ SCREEN_BLUETOOTH, "Bluetooth", LV_SYMBOL_BLUETOOTH },
};
#define APP_COUNT (sizeof(APPS) / sizeof(APPS[0]))
// ---- Live-data freshness → which usage sub-view to show ----
// usage panels when data is flowing, an idle "Zzz" screen when the host is
// connected but no usage update landed within DATA_FRESH_MS, the pairing hint
// when BLE is down. Re-evaluated every loop in ui_tick_anim().
static lv_obj_t* idle_group; // the "Zzz" idle screen
static uint32_t last_data_ms = 0; // lv_tick when the last valid usage update landed
static bool data_received = false; // any valid update since boot
static int view_state = -1; // -1 unknown / 0 pair / 1 idle / 2 usage
static const uint32_t DATA_FRESH_MS = 90000; // usage counts as "live" within this window (daemon sends ~60s)
// ---- 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;
static uint8_t anim_spinner_idx = 0;
static uint8_t anim_phase = 0;
static uint8_t anim_msg_idx = 0;
static uint32_t anim_msg_start = 0;
#define ANIM_MSG_MS 4000
static const char* const spinner_frames[] = {
"\xC2\xB7", "\xE2\x9C\xBB", "\xE2\x9C\xBD",
"\xE2\x9C\xB6", "\xE2\x9C\xB3", "\xE2\x9C\xA2",
};
#define SPINNER_COUNT 6
#define SPINNER_PHASES (2 * (SPINNER_COUNT - 1)) // 10: ping-pong 0..5..0
static const uint16_t spinner_ms[SPINNER_COUNT] = {
260, 130, 130, 130, 130, 260,
};
static const char* const anim_messages[] = {
"Accomplishing", "Elucidating", "Perusing",
"Actioning", "Enchanting", "Philosophising",
"Actualizing", "Envisioning", "Pondering",
"Baking", "Finagling", "Pontificating",
"Booping", "Flibbertigibbeting", "Processing",
"Brewing", "Forging", "Puttering",
"Calculating", "Forming", "Puzzling",
"Cerebrating", "Frolicking", "Reticulating",
"Channelling", "Generating", "Ruminating",
"Churning", "Germinating", "Scheming",
"Clauding", "Hatching", "Schlepping",
"Coalescing", "Herding", "Shimmying",
"Cogitating", "Honking", "Shucking",
"Combobulating", "Hustling", "Simmering",
"Computing", "Ideating", "Smooshing",
"Concocting", "Imagining", "Spelunking",
"Conjuring", "Incubating", "Spinning",
"Considering", "Inferring", "Stewing",
"Contemplating", "Jiving", "Sussing",
"Cooking", "Manifesting", "Synthesizing",
"Crafting", "Marinating", "Thinking",
"Creating", "Meandering", "Tinkering",
"Crunching", "Moseying", "Transmuting",
"Deciphering", "Mulling", "Unfurling",
"Deliberating", "Mustering", "Unravelling",
"Determining", "Musing", "Vibing",
"Discombobulating", "Noodling", "Wandering",
"Divining", "Percolating", "Whirring",
"Doing", "Wibbling",
"Effecting", "Wizarding",
"Working", "Wrangling",
};
#define ANIM_MSG_COUNT (sizeof(anim_messages) / sizeof(anim_messages[0]))
static lv_color_t pct_color(float pct) {
if (pct >= 80.0f) return COL_RED;
if (pct >= 50.0f) return COL_AMBER;
return COL_GREEN;
}
static void format_reset_time(int mins, char* buf, size_t len) {
if (mins < 0) {
snprintf(buf, len, "---");
} else if (mins < 60) {
snprintf(buf, len, "Resets in %dm", mins);
} else if (mins < 1440) {
snprintf(buf, len, "Resets in %dh %dm", mins / 60, mins % 60);
} else {
snprintf(buf, len, "Resets in %dd %dh", mins / 1440, (mins % 1440) / 60);
}
}
// Format a token count compactly: 94972115 -> "95.0M", 960621 -> "960.6K".
static void format_tokens(long long n, char* buf, size_t len) {
if (n >= 1000000) snprintf(buf, len, "%.1fM", (double)n / 1000000.0);
else if (n >= 1000) snprintf(buf, len, "%.1fK", (double)n / 1000.0);
else snprintf(buf, len, "%lld", n);
}
// Forward decls — callbacks defined near ui_show_screen below
static void global_click_cb(lv_event_t* e);
static void logo_click_cb(lv_event_t* e);
static void nav_back_cb(lv_event_t* e);
static void battery_click_cb(lv_event_t* e);
static void battery_screen_refresh(void);
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);
lv_obj_set_pos(panel, x, y);
lv_obj_set_size(panel, w, h);
lv_obj_set_style_bg_color(panel, COL_PANEL, 0);
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
lv_obj_set_style_radius(panel, 8, 0);
lv_obj_set_style_border_width(panel, 0, 0);
lv_obj_set_style_pad_left(panel, 16, 0);
lv_obj_set_style_pad_right(panel, 16, 0);
lv_obj_set_style_pad_top(panel, 12, 0);
lv_obj_set_style_pad_bottom(panel, 12, 0);
lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(panel, LV_OBJ_FLAG_EVENT_BUBBLE);
return panel;
}
static lv_obj_t* make_bar(lv_obj_t* parent, int x, int y, int w, int h) {
lv_obj_t* bar = lv_bar_create(parent);
lv_obj_set_pos(bar, x, y);
lv_obj_set_size(bar, w, h);
lv_bar_set_range(bar, 0, 100);
lv_bar_set_value(bar, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(bar, COL_BAR_BG, LV_PART_MAIN);
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(bar, 6, LV_PART_MAIN);
lv_obj_set_style_bg_color(bar, COL_GREEN, LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_radius(bar, 6, LV_PART_INDICATOR);
return bar;
}
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;
dsc->header.cf = LV_COLOR_FORMAT_RGB565A8;
dsc->header.stride = w * 2;
dsc->data = data;
dsc->data_size = w * h * 3;
}
static lv_obj_t* make_pill(lv_obj_t* parent, const char* text) {
lv_obj_t* lbl = lv_label_create(parent);
lv_label_set_text(lbl, text);
lv_obj_set_style_text_font(lbl, &font_styrene_28, 0);
lv_obj_set_style_text_color(lbl, COL_TEXT, 0);
lv_obj_set_style_bg_color(lbl, COL_BAR_BG, 0);
lv_obj_set_style_bg_opa(lbl, LV_OPA_COVER, 0);
lv_obj_set_style_radius(lbl, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_pad_left(lbl, 18, 0);
lv_obj_set_style_pad_right(lbl, 18, 0);
lv_obj_set_style_pad_top(lbl, 6, 0);
lv_obj_set_style_pad_bottom(lbl, 6, 0);
return lbl;
}
static void init_battery_icons(void) {
init_icon_dsc_rgb565a8(&battery_dscs[0], ICON_BATTERY_W, ICON_BATTERY_H, icon_battery_data);
init_icon_dsc_rgb565a8(&battery_dscs[1], ICON_BATTERY_LOW_W, ICON_BATTERY_LOW_H, icon_battery_low_data);
init_icon_dsc_rgb565a8(&battery_dscs[2], ICON_BATTERY_MEDIUM_W, ICON_BATTERY_MEDIUM_H, icon_battery_medium_data);
init_icon_dsc_rgb565a8(&battery_dscs[3], ICON_BATTERY_FULL_W, ICON_BATTERY_FULL_H, icon_battery_full_data);
init_icon_dsc_rgb565a8(&battery_dscs[4], ICON_BATTERY_CHARGING_W, ICON_BATTERY_CHARGING_H, icon_battery_charging_data);
}
// ======== Usage Screen ========
static void make_usage_panel(lv_obj_t* parent, int y, const char* pill_text,
lv_obj_t** out_pct, lv_obj_t** out_pill,
lv_obj_t** out_bar, lv_obj_t** out_reset) {
lv_obj_t* panel = make_panel(parent, L.margin, y, L.content_w, L.usage_panel_h);
*out_pct = lv_label_create(panel);
lv_label_set_text(*out_pct, "---%");
lv_obj_set_style_text_font(*out_pct, &font_styrene_48, 0);
lv_obj_set_style_text_color(*out_pct, COL_TEXT, 0);
lv_obj_set_pos(*out_pct, 0, 0);
*out_pill = make_pill(panel, pill_text);
lv_obj_align(*out_pill, LV_ALIGN_TOP_RIGHT, 0, 1);
*out_bar = make_bar(panel, 0, L.usage_bar_y, L.content_w - 32, 24);
*out_reset = lv_label_create(panel);
lv_label_set_text(*out_reset, "---");
lv_obj_set_style_text_font(*out_reset, &font_styrene_28, 0);
lv_obj_set_style_text_color(*out_reset, COL_DIM, 0);
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
}
// Idle "Zzz" screen — shown when the host is connected but no usage update has
// landed recently (token expired, daemon down, host asleep…). Full-screen, like
// the pairing hint, so we never render hours-old numbers as if they were live.
static void build_idle_group(lv_obj_t* parent) {
idle_group = lv_obj_create(parent);
lv_obj_set_size(idle_group, L.scr_w, L.scr_h - L.content_y);
lv_obj_set_pos(idle_group, 0, L.content_y);
lv_obj_set_style_bg_opa(idle_group, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(idle_group, 0, 0);
lv_obj_set_style_pad_all(idle_group, 0, 0);
lv_obj_clear_flag(idle_group, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(idle_group, LV_OBJ_FLAG_EVENT_BUBBLE);
// A shrunk-down sleeping creature (reused claudepix "expression sleep" art)
// sits between the header and the status line; the animated "Listening…"
// status line carries the words, so no extra text is needed here.
lv_obj_t* creature = splash_mini_create(idle_group, "expression sleep", 160);
if (creature) lv_obj_align(creature, LV_ALIGN_CENTER, 0, -20);
lv_obj_add_flag(idle_group, LV_OBJ_FLAG_HIDDEN); // update_view_state 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);
lv_obj_set_pos(usage_container, 0, 0);
lv_obj_set_style_bg_opa(usage_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(usage_container, 0, 0);
lv_obj_set_style_pad_all(usage_container, 0, 0);
lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_SCROLLABLE);
lbl_title = lv_label_create(usage_container);
lv_label_set_text(lbl_title, "Usage");
lv_obj_set_style_text_font(lbl_title, &font_tiempos_56, 0);
lv_obj_set_style_text_color(lbl_title, COL_TEXT, 0);
lv_obj_align(lbl_title, LV_ALIGN_TOP_MID, 16, L.title_y);
// 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_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);
build_idle_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);
lv_obj_set_style_text_color(lbl_anim, COL_ACCENT, 0);
lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
}
// ======== v2: launcher & bluetooth screens ========
// Centered screen title for the navigation screens. The back chevron lives in
// the shared top-left chrome (nav_back_btn), so only the title is drawn here.
static lv_obj_t* make_screen_title(lv_obj_t* parent, const char* text) {
lv_obj_t* t = lv_label_create(parent);
lv_label_set_text(t, text);
lv_obj_set_style_text_font(t, &font_styrene_28, 0);
lv_obj_set_style_text_color(t, COL_TEXT, 0);
lv_obj_align(t, LV_ALIGN_TOP_MID, 16, L.title_y);
return t;
}
static void tile_click_cb(lv_event_t* e) {
screen_t s = (screen_t)(intptr_t)lv_event_get_user_data(e);
ui_show_screen(s);
}
static lv_obj_t* make_tile(lv_obj_t* parent, const AppEntry* app, int w, int h) {
lv_obj_t* tile = lv_obj_create(parent);
lv_obj_set_size(tile, w, h);
lv_obj_set_style_bg_color(tile, COL_PANEL, 0);
lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0);
lv_obj_set_style_radius(tile, 16, 0);
lv_obj_set_style_border_width(tile, 0, 0);
lv_obj_set_style_pad_all(tile, 6, 0);
lv_obj_set_flex_flow(tile, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(tile, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(tile, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(tile, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(tile, tile_click_cb, LV_EVENT_CLICKED, (void*)(intptr_t)app->screen);
lv_obj_t* icon = lv_label_create(tile);
lv_label_set_text(icon, app->symbol);
lv_obj_set_style_text_font(icon, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(icon, COL_ACCENT, 0);
lv_obj_t* lbl = lv_label_create(tile);
lv_label_set_text(lbl, app->label);
lv_obj_set_style_text_font(lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(lbl, COL_TEXT, 0);
lv_obj_set_style_pad_top(lbl, 6, 0);
return tile;
}
static void init_menu_screen(lv_obj_t* scr) {
menu_container = lv_obj_create(scr);
lv_obj_set_size(menu_container, L.scr_w, L.scr_h);
lv_obj_set_pos(menu_container, 0, 0);
lv_obj_set_style_bg_opa(menu_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(menu_container, 0, 0);
lv_obj_set_style_pad_all(menu_container, 0, 0);
lv_obj_clear_flag(menu_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(menu_container, "Menu");
// Scrollable 2-column tile grid — wraps and scrolls vertically as the app
// registry grows, so new screens need no layout changes here.
lv_obj_t* grid = lv_obj_create(menu_container);
lv_obj_set_size(grid, L.content_w, L.scr_h - L.content_y - L.margin);
lv_obj_set_pos(grid, L.margin, L.content_y);
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(grid, 0, 0);
lv_obj_set_style_pad_all(grid, 0, 0);
lv_obj_set_style_pad_row(grid, 12, 0);
lv_obj_set_style_pad_column(grid, 12, 0);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
// track_cross_place = START: stack rows from the top so the first row sits
// just under the title and the rest is reached by scrolling DOWN. (CENTER
// would vertically center the overflowing block, hiding the top row under
// the title and springing back to it after an elastic pull.)
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
lv_obj_set_scroll_dir(grid, LV_DIR_VER);
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_AUTO);
int tile_w = (L.content_w - 16) / 2; // 4px slack so two fit per row beside a scrollbar
int tile_h = 116;
for (size_t i = 0; i < APP_COUNT; i++)
make_tile(grid, &APPS[i], tile_w, tile_h);
lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
}
static void init_bt_screen(lv_obj_t* scr) {
bt_container = lv_obj_create(scr);
lv_obj_set_size(bt_container, L.scr_w, L.scr_h);
lv_obj_set_pos(bt_container, 0, 0);
lv_obj_set_style_bg_opa(bt_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(bt_container, 0, 0);
lv_obj_set_style_pad_all(bt_container, 0, 0);
lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(bt_container, "Bluetooth");
bt_status_lbl = lv_label_create(bt_container);
lv_label_set_text(bt_status_lbl, "");
lv_obj_set_style_text_font(bt_status_lbl, &font_styrene_28, 0);
lv_obj_align(bt_status_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 20);
bt_name_lbl = lv_label_create(bt_container);
lv_label_set_text(bt_name_lbl, "");
lv_obj_set_style_text_font(bt_name_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(bt_name_lbl, COL_DIM, 0);
lv_obj_align(bt_name_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 80);
bt_mac_lbl = lv_label_create(bt_container);
lv_label_set_text(bt_mac_lbl, "");
lv_obj_set_style_text_font(bt_mac_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(bt_mac_lbl, COL_DIM, 0);
lv_obj_align(bt_mac_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 120);
lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
}
static void bt_refresh(void) {
if (!bt_container) return;
lv_label_set_text(bt_status_lbl, s_ble_connected ? "Connected" : "Waiting");
lv_obj_set_style_text_color(bt_status_lbl, s_ble_connected ? COL_GREEN : COL_AMBER, 0);
lv_label_set_text_fmt(bt_name_lbl, "%s", ble_get_device_name());
lv_label_set_text_fmt(bt_mac_lbl, "%s", ble_get_mac_address());
}
// ---- Soundpad: 6 pads → HID F13..F18 (the host maps them to a soundboard) ----
// F13..F24 are HID usage IDs 0x68..0x73; the report-map keycode ceiling was
// raised to 0x73 in ble.cpp so these transmit.
struct PadDef { uint8_t keycode; const char* label; uint32_t color; };
static const PadDef PADS[6] = {
{ 0x68, "F13", 0xD85A30 }, // coral
{ 0x69, "F14", 0xBA7517 }, // amber
{ 0x6A, "F15", 0x639922 }, // green
{ 0x6B, "F16", 0x1D9E75 }, // teal
{ 0x6C, "F17", 0x534AB7 }, // purple
{ 0x6D, "F18", 0xD4537E }, // pink
};
static lv_obj_t* soundpad_container;
static void release_timer_cb(lv_timer_t* t) {
(void)t;
ble_keyboard_release();
}
static void pad_click_cb(lv_event_t* e) {
uint8_t key = (uint8_t)(intptr_t)lv_event_get_user_data(e);
ble_keyboard_press(key, 0);
// Release shortly after so the host sees a discrete keypress, without
// blocking the UI thread. The one-shot timer auto-deletes once it fires.
lv_timer_t* t = lv_timer_create(release_timer_cb, 40, NULL);
lv_timer_set_repeat_count(t, 1);
}
static lv_obj_t* make_pad(lv_obj_t* parent, const PadDef* pad, int w, int h) {
lv_obj_t* btn = lv_obj_create(parent);
lv_obj_set_size(btn, w, h);
lv_obj_set_style_bg_color(btn, lv_color_hex(pad->color), 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_radius(btn, 16, 0);
lv_obj_set_style_border_width(btn, 0, 0);
lv_obj_set_style_border_width(btn, 3, LV_STATE_PRESSED); // white ring lights on press
lv_obj_set_style_border_color(btn, lv_color_white(), LV_STATE_PRESSED);
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(btn, pad_click_cb, LV_EVENT_CLICKED, (void*)(intptr_t)pad->keycode);
lv_obj_t* lbl = lv_label_create(btn);
lv_label_set_text(lbl, pad->label);
lv_obj_set_style_text_font(lbl, &font_styrene_28, 0);
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
lv_obj_center(lbl);
return btn;
}
static void init_soundpad_screen(lv_obj_t* scr) {
soundpad_container = lv_obj_create(scr);
lv_obj_set_size(soundpad_container, L.scr_w, L.scr_h);
lv_obj_set_pos(soundpad_container, 0, 0);
lv_obj_set_style_bg_opa(soundpad_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(soundpad_container, 0, 0);
lv_obj_set_style_pad_all(soundpad_container, 0, 0);
lv_obj_clear_flag(soundpad_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(soundpad_container, "Soundpad");
lv_obj_t* grid = lv_obj_create(soundpad_container);
int avail_h = L.scr_h - L.content_y - L.margin;
lv_obj_set_size(grid, L.content_w, avail_h);
lv_obj_set_pos(grid, L.margin, L.content_y);
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(grid, 0, 0);
lv_obj_set_style_pad_all(grid, 0, 0);
lv_obj_set_style_pad_row(grid, 12, 0);
lv_obj_set_style_pad_column(grid, 12, 0);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
int pad_w = (L.content_w - 16) / 2; // 2 columns, 4px slack
int pad_h = (avail_h - 2 * 12) / 3 - 1; // 3 rows
for (int i = 0; i < 6; i++)
make_pad(grid, &PADS[i], pad_w, pad_h);
lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN);
}
// Session: today's Claude Code usage — equivalent API cost (hero), total tokens,
// and message count. Labels are refreshed from ui_update() as data arrives.
static void init_session_screen(lv_obj_t* scr) {
session_container = lv_obj_create(scr);
lv_obj_set_size(session_container, L.scr_w, L.scr_h);
lv_obj_set_pos(session_container, 0, 0);
lv_obj_set_style_bg_opa(session_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(session_container, 0, 0);
lv_obj_set_style_pad_all(session_container, 0, 0);
lv_obj_clear_flag(session_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(session_container, "Session");
sess_cost_lbl = lv_label_create(session_container);
lv_label_set_text(sess_cost_lbl, "$0.00");
lv_obj_set_style_text_font(sess_cost_lbl, &font_tiempos_56, 0);
lv_obj_set_style_text_color(sess_cost_lbl, COL_TEXT, 0);
lv_obj_align(sess_cost_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 25);
lv_obj_t* sub = lv_label_create(session_container);
lv_label_set_text(sub, "spent today");
lv_obj_set_style_text_font(sub, &font_styrene_20, 0);
lv_obj_set_style_text_color(sub, COL_DIM, 0);
lv_obj_align(sub, LV_ALIGN_TOP_MID, 0, L.content_y + 105);
sess_tokens_lbl = lv_label_create(session_container);
lv_label_set_text(sess_tokens_lbl, "\xE2\x80\x94 tokens");
lv_obj_set_style_text_font(sess_tokens_lbl, &font_styrene_28, 0);
lv_obj_set_style_text_color(sess_tokens_lbl, COL_ACCENT, 0);
lv_obj_align(sess_tokens_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 152);
sess_gen_lbl = lv_label_create(session_container);
lv_label_set_text(sess_gen_lbl, "\xE2\x80\x94 generated");
lv_obj_set_style_text_font(sess_gen_lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(sess_gen_lbl, COL_DIM, 0);
lv_obj_align(sess_gen_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 192);
sess_msgs_lbl = lv_label_create(session_container);
lv_label_set_text(sess_msgs_lbl, "\xE2\x80\x94 requests");
lv_obj_set_style_text_font(sess_msgs_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(sess_msgs_lbl, COL_DIM, 0);
lv_obj_align(sess_msgs_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 232);
lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
}
// Now Playing: the Windows media session relayed by the daemon. A play/pause glyph
// and status line reflect playback; the title scrolls if it overflows. Independent
// of rate-limit data, so it stays live even when the OAuth token is unavailable.
static void init_nowplaying_screen(lv_obj_t* scr) {
nowplaying_container = lv_obj_create(scr);
lv_obj_set_size(nowplaying_container, L.scr_w, L.scr_h);
lv_obj_set_pos(nowplaying_container, 0, 0);
lv_obj_set_style_bg_opa(nowplaying_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(nowplaying_container, 0, 0);
lv_obj_set_style_pad_all(nowplaying_container, 0, 0);
lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(nowplaying_container, "Now Playing");
np_icon_lbl = lv_label_create(nowplaying_container);
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
lv_obj_set_style_text_font(np_icon_lbl, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
lv_obj_align(np_icon_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 35);
np_title_lbl = lv_label_create(nowplaying_container);
lv_obj_set_width(np_title_lbl, L.content_w);
lv_label_set_long_mode(np_title_lbl, LV_LABEL_LONG_SCROLL_CIRCULAR);
lv_obj_set_style_text_align(np_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(np_title_lbl, &font_styrene_cyr_28, 0);
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
lv_label_set_text(np_title_lbl, "Nothing playing");
lv_obj_align(np_title_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 120);
np_artist_lbl = lv_label_create(nowplaying_container);
lv_obj_set_width(np_artist_lbl, L.content_w);
lv_label_set_long_mode(np_artist_lbl, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_align(np_artist_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(np_artist_lbl, &font_styrene_cyr_20, 0);
lv_obj_set_style_text_color(np_artist_lbl, COL_DIM, 0);
lv_label_set_text(np_artist_lbl, "");
lv_obj_align(np_artist_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 170);
np_status_lbl = lv_label_create(nowplaying_container);
lv_obj_set_style_text_font(np_status_lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(np_status_lbl, COL_DIM, 0);
lv_label_set_text(np_status_lbl, "");
lv_obj_align(np_status_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 215);
lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
}
// Home control (Phase 7 M2): a grid of buttons defined by the desktop config.
// The watch is dumb — each tile only carries its index; on tap it notifies
// {"cmd":"btn","i":N} and the daemon maps the index → action/entity → Home
// Assistant call. Labels arrive in the RX payload's "btns" array (ui_set_buttons).
static void ha_btn_click_cb(lv_event_t* e) {
int idx = (int)(intptr_t)lv_event_get_user_data(e);
char cmd[24];
snprintf(cmd, sizeof cmd, "{\"cmd\":\"btn\",\"i\":%d}", idx);
ble_send_command(cmd);
if (ha_status_lbl && idx >= 0 && idx < ha_btn_count)
lv_label_set_text_fmt(ha_status_lbl, "Sent: %s", ha_btn_text[idx]);
}
static lv_obj_t* make_ha_button(lv_obj_t* parent, int idx, int w, int h) {
lv_obj_t* btn = lv_obj_create(parent);
lv_obj_set_size(btn, w, h);
lv_obj_set_style_bg_color(btn, COL_PANEL, 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_radius(btn, 16, 0);
lv_obj_set_style_border_width(btn, 0, 0);
lv_obj_set_style_border_width(btn, 3, LV_STATE_PRESSED); // accent ring on press
lv_obj_set_style_border_color(btn, COL_ACCENT, LV_STATE_PRESSED);
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(btn, ha_btn_click_cb, LV_EVENT_CLICKED, (void*)(intptr_t)idx);
lv_obj_t* lbl = lv_label_create(btn);
lv_obj_set_width(lbl, w - 16);
lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(lbl, &font_styrene_cyr_20, 0); // Cyrillic-capable (labels may be RU)
lv_obj_set_style_text_color(lbl, COL_TEXT, 0);
lv_label_set_text(lbl, "");
lv_obj_center(lbl);
ha_btn_lbls[idx] = lbl;
return btn;
}
static void init_homeassist_screen(lv_obj_t* scr) {
homeassist_container = lv_obj_create(scr);
lv_obj_set_size(homeassist_container, L.scr_w, L.scr_h);
lv_obj_set_pos(homeassist_container, 0, 0);
lv_obj_set_style_bg_opa(homeassist_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(homeassist_container, 0, 0);
lv_obj_set_style_pad_all(homeassist_container, 0, 0);
lv_obj_clear_flag(homeassist_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(homeassist_container, "Home");
// Button grid — same 2-column wrap/scroll pattern as the launcher & soundpad.
int avail_h = L.scr_h - L.content_y - L.margin - 34; // leave room for the status line
ha_btn_grid = lv_obj_create(homeassist_container);
lv_obj_set_size(ha_btn_grid, L.content_w, avail_h);
lv_obj_set_pos(ha_btn_grid, L.margin, L.content_y);
lv_obj_set_style_bg_opa(ha_btn_grid, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(ha_btn_grid, 0, 0);
lv_obj_set_style_pad_all(ha_btn_grid, 0, 0);
lv_obj_set_style_pad_row(ha_btn_grid, 12, 0);
lv_obj_set_style_pad_column(ha_btn_grid, 12, 0);
lv_obj_set_flex_flow(ha_btn_grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(ha_btn_grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
lv_obj_set_scroll_dir(ha_btn_grid, LV_DIR_VER);
lv_obj_set_scrollbar_mode(ha_btn_grid, LV_SCROLLBAR_MODE_AUTO);
int btn_w = (L.content_w - 16) / 2; // 2 columns, 4px slack
int btn_h = (avail_h - 2 * 12) / 3 - 1; // up to 3 rows (6 buttons)
for (int i = 0; i < UI_MAX_BUTTONS; i++) {
ha_btns[i] = make_ha_button(ha_btn_grid, i, btn_w, btn_h);
lv_obj_add_flag(ha_btns[i], LV_OBJ_FLAG_HIDDEN); // revealed by ui_set_buttons()
}
// Placeholder shown until the daemon pushes a button set.
ha_empty_lbl = lv_label_create(homeassist_container);
lv_obj_set_width(ha_empty_lbl, L.content_w);
lv_label_set_long_mode(ha_empty_lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_align(ha_empty_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(ha_empty_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(ha_empty_lbl, COL_DIM, 0);
lv_label_set_text(ha_empty_lbl, "Add buttons in the\ndesktop app");
lv_obj_align(ha_empty_lbl, LV_ALIGN_CENTER, 0, -10);
// Bottom feedback line — shows "Sent: <label>" on press.
ha_status_lbl = lv_label_create(homeassist_container);
lv_label_set_text(ha_status_lbl, "");
lv_obj_set_style_text_font(ha_status_lbl, &font_styrene_cyr_20, 0);
lv_obj_set_style_text_color(ha_status_lbl, COL_DIM, 0);
lv_obj_align(ha_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -8);
// Start in the "no buttons yet" state (grid hidden, placeholder shown).
lv_obj_add_flag(ha_btn_grid, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN);
}
// Apply the button labels pushed by the daemon (RX "btns"). Copies the strings
// (the source JSON is freed right after the parse), reveals that many tiles,
// hides the rest, and toggles the placeholder. A no-op when nothing changed, so
// the ~60s refresh never churns the UI or restarts press animations.
void ui_set_buttons(const char* const* labels, int count) {
if (!homeassist_container) return;
if (count < 0) count = 0;
if (count > UI_MAX_BUTTONS) count = UI_MAX_BUTTONS;
bool changed = (count != ha_btn_count);
for (int i = 0; i < count && !changed; i++) {
const char* s = labels[i] ? labels[i] : "";
if (strncmp(ha_btn_text[i], s, sizeof(ha_btn_text[i])) != 0) changed = true;
}
if (!changed) return;
ha_btn_count = count;
for (int i = 0; i < UI_MAX_BUTTONS; i++) {
if (i < count) {
strlcpy(ha_btn_text[i], labels[i] ? labels[i] : "", sizeof(ha_btn_text[i]));
lv_label_set_text(ha_btn_lbls[i], ha_btn_text[i]);
lv_obj_clear_flag(ha_btns[i], LV_OBJ_FLAG_HIDDEN);
} else {
ha_btn_text[i][0] = '\0';
lv_obj_add_flag(ha_btns[i], LV_OBJ_FLAG_HIDDEN);
}
}
if (count > 0) {
lv_obj_add_flag(ha_empty_lbl, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(ha_btn_grid, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_clear_flag(ha_empty_lbl, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ha_btn_grid, LV_OBJ_FLAG_HIDDEN);
}
}
// ======== Tilt-dimmer (Phase 6 step 3 / M3) ========
//
// The wrist is a one-axis joystick. While "armed", the watch reads its IMU each
// loop, measures how far the chosen device axis has tilted away from the neutral
// pose captured at arm time, and ramps the working value at a rate proportional
// to that tilt (zero inside a small deadzone, capped at full tilt). The working
// value drives the on-screen dial and is streamed to the daemon as an absolute
// {"cmd":"bri"|"ct","v":..} write, throttled to ~5 Hz. Holding the wrist still
// for a few seconds, or tapping the screen, commits and locks; BOOT re-arms;
// PWR switches which parameter (brightness ⇄ color temperature) the tilt drives.
enum { DIM_BRI = 0, DIM_CT = 1 };
// --- tuning ---------------------------------------------------------------
static const float DIM_DEADZONE_DEG = 8.0f; // ignore tilt smaller than this
static const float DIM_MAX_DEG = 45.0f; // tilt for full-speed ramp
static const float DIM_BRI_RATE = 45.0f; // %/s at full tilt
static const float DIM_CT_RATE_FRAC = 0.45f; // fraction of the K-range/s at full tilt
static const uint32_t DIM_SEND_MS = 180; // BLE write cap (~5.5 Hz)
static const uint32_t DIM_AUTOLOCK_MS = 3500; // stillness this long → auto-commit
// Which device axis drives the dimmer and its sign. Tilt is the change in
// asin(normalized gravity[axis]) from neutral. Calibrated on hardware — flip
// these after watching the live read-out below. 0=X, 1=Y, 2=Z.
#define DIM_AXIS 1
#define DIM_SIGN (+1.0f)
// While 1, the armed status line shows the live gravity vector + selected-axis
// tilt so the axis/sign above can be chosen on the bench. Set to 0 once locked.
// (Flip to 1 when calibrating the tilt axis on a new board.)
#define DIM_CALIB 0
// --- state ----------------------------------------------------------------
static int dim_param = DIM_BRI;
static bool dim_armed = false;
static bool dim_capture_pending = false; // capture neutral on the next good read
static bool dim_have_snapshot = false;
static bool dim_light_on = false;
static float dim_bri = 50.0f; // working brightness % (1..100)
static float dim_ct = 3000.0f; // working color temp (K)
static int dim_min_k = 2000;
static int dim_max_k = 6500;
static float dim_neutral_deg = 0.0f; // selected-axis angle at neutral
static uint32_t dim_last_tick_ms = 0;
static uint32_t dim_last_send_ms = 0;
static uint32_t dim_still_since_ms = 0;
static int dim_last_sent_val = -99999;
static float dim_axis_angle_deg(float x, float y, float z) {
float n = sqrtf(x * x + y * y + z * z);
if (n < 0.01f) return 0.0f;
float a = (DIM_AXIS == 0 ? x : (DIM_AXIS == 1 ? y : z)) / n;
if (a > 1.0f) a = 1.0f;
if (a < -1.0f) a = -1.0f;
return DIM_SIGN * asinf(a) * 57.2958f;
}
static void dim_update_status(void) {
if (!dim_status_lbl) return;
if (dim_armed) {
lv_obj_set_style_text_color(dim_status_lbl, COL_ACCENT, 0);
lv_label_set_text(dim_status_lbl, "Tilt to adjust - tap to set");
} else if (!dim_have_snapshot) {
lv_obj_set_style_text_color(dim_status_lbl, COL_DIM, 0);
lv_label_set_text(dim_status_lbl, "Connecting...");
} else {
lv_obj_set_style_text_color(dim_status_lbl, COL_DIM, 0);
lv_label_set_text(dim_status_lbl, "BOOT to adjust - PWR switches");
}
}
static void dim_refresh_dial(void) {
if (!dim_arc) return;
if (dim_param == DIM_BRI) {
int v = (int)lroundf(dim_bri);
lv_arc_set_range(dim_arc, 0, 100);
lv_arc_set_value(dim_arc, v);
lv_label_set_text_fmt(dim_value_lbl, "%d%%", v);
lv_label_set_text(dim_param_lbl, "Brightness");
} else {
int v = (int)lroundf(dim_ct);
lv_arc_set_range(dim_arc, dim_min_k, dim_max_k);
lv_arc_set_value(dim_arc, v);
lv_label_set_text_fmt(dim_value_lbl, "%dK", v);
lv_label_set_text(dim_param_lbl, "Temp");
}
}
// Push the working value to the daemon. `force` ignores the change-dedupe so a
// commit lands exactly on the displayed number.
static void dim_send_current(bool force) {
char cmd[40];
if (dim_param == DIM_BRI) {
int v = (int)lroundf(dim_bri);
if (!force && v == dim_last_sent_val) return;
snprintf(cmd, sizeof cmd, "{\"cmd\":\"bri\",\"v\":%d}", v);
dim_last_sent_val = v;
} else {
int v = (int)lroundf(dim_ct);
if (!force && abs(v - dim_last_sent_val) < 20) return; // ~20K granularity
snprintf(cmd, sizeof cmd, "{\"cmd\":\"ct\",\"v\":%d}", v);
dim_last_sent_val = v;
}
ble_send_command(cmd);
dim_light_on = true; // setting brightness/temp implicitly turns the light on
}
static void dim_commit(void) {
if (!dim_armed) return;
dim_armed = false;
dim_capture_pending = false;
dim_send_current(true); // land HA exactly on the shown value
dim_update_status();
}
// Tap anywhere on the dial area commits (only meaningful while armed). The back
// chevron and battery icon sit above this container and take their own taps.
static void dim_tap_cb(lv_event_t* e) {
(void)e;
if (dim_armed) dim_commit();
}
static void init_dimmer_screen(lv_obj_t* scr) {
dimmer_container = lv_obj_create(scr);
lv_obj_set_size(dimmer_container, L.scr_w, L.scr_h);
lv_obj_set_pos(dimmer_container, 0, 0);
lv_obj_set_style_bg_opa(dimmer_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(dimmer_container, 0, 0);
lv_obj_set_style_pad_all(dimmer_container, 0, 0);
lv_obj_clear_flag(dimmer_container, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(dimmer_container, LV_OBJ_FLAG_CLICKABLE); // tap → commit
lv_obj_add_event_cb(dimmer_container, dim_tap_cb, LV_EVENT_CLICKED, NULL);
make_screen_title(dimmer_container, "Dimmer");
int arc_sz = (L.scr_h >= 460) ? 280 : 240;
dim_arc = lv_arc_create(dimmer_container);
lv_obj_set_size(dim_arc, arc_sz, arc_sz);
lv_obj_align(dim_arc, LV_ALIGN_CENTER, 0, 4);
lv_arc_set_rotation(dim_arc, 135);
lv_arc_set_bg_angles(dim_arc, 0, 270);
lv_arc_set_range(dim_arc, 0, 100);
lv_arc_set_value(dim_arc, 50);
lv_obj_remove_style(dim_arc, NULL, LV_PART_KNOB); // display-only: no draggable knob
lv_obj_clear_flag(dim_arc, LV_OBJ_FLAG_CLICKABLE); // taps fall through to the container
lv_obj_clear_flag(dim_arc, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_arc_color(dim_arc, COL_BAR_BG, LV_PART_MAIN);
lv_obj_set_style_arc_color(dim_arc, COL_ACCENT, LV_PART_INDICATOR);
lv_obj_set_style_arc_width(dim_arc, 14, LV_PART_MAIN);
lv_obj_set_style_arc_width(dim_arc, 14, LV_PART_INDICATOR);
dim_value_lbl = lv_label_create(dimmer_container);
lv_obj_set_style_text_font(dim_value_lbl, &font_styrene_48, 0);
lv_obj_set_style_text_color(dim_value_lbl, COL_TEXT, 0);
lv_label_set_text(dim_value_lbl, "50%");
lv_obj_align(dim_value_lbl, LV_ALIGN_CENTER, 0, -6);
dim_param_lbl = lv_label_create(dimmer_container);
lv_obj_set_style_text_font(dim_param_lbl, &font_styrene_20, 0);
lv_obj_set_style_text_color(dim_param_lbl, COL_DIM, 0);
lv_label_set_text(dim_param_lbl, "Brightness");
lv_obj_align(dim_param_lbl, LV_ALIGN_CENTER, 0, 36);
dim_status_lbl = lv_label_create(dimmer_container);
lv_obj_set_width(dim_status_lbl, L.content_w);
lv_obj_set_style_text_align(dim_status_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(dim_status_lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(dim_status_lbl, COL_DIM, 0);
lv_label_set_text(dim_status_lbl, "Connecting...");
lv_obj_align(dim_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -16);
lv_obj_add_flag(dimmer_container, LV_OBJ_FLAG_HIDDEN);
}
// The daemon pushes the light's live state (RX "dim"). Seed the working value
// and dial from it — but only while LOCKED, so a heartbeat that arrives mid-
// adjust can't yank the value out from under the user's wrist.
void ui_dimmer_set_snapshot(bool on, int bri_pct, int ct_kelvin, int min_k, int max_k) {
dim_have_snapshot = true;
dim_light_on = on;
if (min_k > 0) dim_min_k = min_k;
if (max_k > dim_min_k) dim_max_k = max_k;
if (!dim_armed) {
if (bri_pct >= 0) dim_bri = (float)bri_pct;
if (ct_kelvin > 0) dim_ct = (float)ct_kelvin;
if (dim_ct < dim_min_k) dim_ct = dim_min_k;
if (dim_ct > dim_max_k) dim_ct = dim_max_k;
dim_refresh_dial();
if (current_screen == SCREEN_DIMMER) dim_update_status();
}
}
void ui_dimmer_arm(void) {
if (current_screen != SCREEN_DIMMER) return;
dim_armed = true;
dim_capture_pending = true; // neutral pose captured on the next good read
dim_last_tick_ms = lv_tick_get();
dim_still_since_ms = 0;
dim_last_send_ms = 0;
dim_last_sent_val = -99999;
dim_update_status();
}
void ui_dimmer_switch_param(void) {
if (current_screen != SCREEN_DIMMER) return;
dim_param = (dim_param == DIM_BRI) ? DIM_CT : DIM_BRI;
dim_armed = false; // re-arm with BOOT for the newly selected param
dim_capture_pending = false;
dim_last_sent_val = -99999;
if (dim_param == DIM_CT && dim_ct <= 0) dim_ct = (dim_min_k + dim_max_k) / 2.0f;
dim_refresh_dial();
dim_update_status();
}
bool ui_dimmer_is_armed(void) { return dim_armed && current_screen == SCREEN_DIMMER; }
void ui_dimmer_tick(void) {
if (current_screen != SCREEN_DIMMER || !dim_armed) return;
float x, y, z;
if (!imu_hal_read_accel(&x, &y, &z)) return;
float ang_abs = dim_axis_angle_deg(x, y, z);
if (dim_capture_pending) {
dim_neutral_deg = ang_abs; // this pose is "centered"
dim_capture_pending = false;
dim_last_tick_ms = lv_tick_get();
return;
}
float ang = ang_abs - dim_neutral_deg; // signed tilt from neutral
#if DIM_CALIB
// LVGL's lv_label_set_text_fmt has no %f, so format with the C library first.
char dbg[48];
snprintf(dbg, sizeof dbg, "x%+.2f y%+.2f z%+.2f tilt%+.0f", x, y, z, ang);
lv_label_set_text(dim_status_lbl, dbg);
lv_obj_set_style_text_color(dim_status_lbl, COL_ACCENT, 0);
#endif
uint32_t now = lv_tick_get();
uint32_t dt_ms = now - dim_last_tick_ms;
dim_last_tick_ms = now;
if (dt_ms > 500) dt_ms = 0; // first/stale tick — don't integrate a big jump
float dt = dt_ms / 1000.0f;
float mag = fabsf(ang) - DIM_DEADZONE_DEG;
float rate = 0.0f; // -1..+1 normalized
if (mag > 0.0f) {
float frac = mag / (DIM_MAX_DEG - DIM_DEADZONE_DEG);
if (frac > 1.0f) frac = 1.0f;
rate = (ang > 0.0f ? 1.0f : -1.0f) * frac;
}
if (rate == 0.0f) { // inside the deadzone → hold; auto-lock on stillness
if (dim_still_since_ms == 0) dim_still_since_ms = now;
else if (now - dim_still_since_ms >= DIM_AUTOLOCK_MS) dim_commit();
return;
}
dim_still_since_ms = 0;
if (dim_param == DIM_BRI) {
dim_bri += rate * DIM_BRI_RATE * dt;
if (dim_bri < 1.0f) dim_bri = 1.0f;
if (dim_bri > 100.0f) dim_bri = 100.0f;
} else {
float span = (float)(dim_max_k - dim_min_k);
dim_ct += rate * DIM_CT_RATE_FRAC * span * dt;
if (dim_ct < dim_min_k) dim_ct = dim_min_k;
if (dim_ct > dim_max_k) dim_ct = dim_max_k;
}
dim_refresh_dial();
if (now - dim_last_send_ms >= DIM_SEND_MS) {
dim_send_current(false);
dim_last_send_ms = now;
}
}
// Battery detail — opened by tapping the battery icon (not in the launcher).
// Shows the gauge percentage, the raw cell voltage, and a rough "time left"
// projected from the discharge rate (battery_est in main.cpp). The bottom note
// states the protective auto-off threshold and reddens as the cell nears it.
static lv_color_t batt_color(int pct) {
if (pct < 0) return COL_DIM; // unknown
if (pct <= 15) return COL_RED;
if (pct <= 35) return COL_AMBER;
return COL_GREEN; // low % is bad here — inverse of the usage bars
}
static void init_battery_screen(lv_obj_t* scr) {
battery_container = lv_obj_create(scr);
lv_obj_set_size(battery_container, L.scr_w, L.scr_h);
lv_obj_set_pos(battery_container, 0, 0);
lv_obj_set_style_bg_opa(battery_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(battery_container, 0, 0);
lv_obj_set_style_pad_all(battery_container, 0, 0);
lv_obj_clear_flag(battery_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(battery_container, "Battery");
bat_pct_lbl = lv_label_create(battery_container);
lv_label_set_text(bat_pct_lbl, "--%");
lv_obj_set_style_text_font(bat_pct_lbl, &font_tiempos_56, 0);
lv_obj_set_style_text_color(bat_pct_lbl, COL_TEXT, 0);
lv_obj_align(bat_pct_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 10);
bat_bar = make_bar(battery_container, L.margin, L.content_y + 115, L.content_w, 22);
bat_volt_lbl = lv_label_create(battery_container);
lv_label_set_text(bat_volt_lbl, "--.-- V");
lv_obj_set_style_text_font(bat_volt_lbl, &font_styrene_28, 0);
lv_obj_set_style_text_color(bat_volt_lbl, COL_DIM, 0);
lv_obj_align(bat_volt_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 155);
bat_time_lbl = lv_label_create(battery_container);
lv_label_set_text(bat_time_lbl, "Estimating time left\xE2\x80\xA6");
lv_obj_set_style_text_font(bat_time_lbl, &font_styrene_28, 0);
lv_obj_set_style_text_color(bat_time_lbl, COL_ACCENT, 0);
lv_obj_align(bat_time_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 205);
bat_note_lbl = lv_label_create(battery_container);
lv_label_set_text(bat_note_lbl, "Auto-off below 3.0 V");
lv_obj_set_style_text_font(bat_note_lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(bat_note_lbl, COL_DIM, 0);
lv_obj_align(bat_note_lbl, LV_ALIGN_BOTTOM_MID, 0, -36);
lv_obj_add_flag(battery_container, LV_OBJ_FLAG_HIDDEN);
}
static void battery_screen_refresh(void) {
if (!battery_container) return;
char buf[48];
if (g_bat_pct < 0) {
lv_label_set_text(bat_pct_lbl, "--%");
} else {
snprintf(buf, sizeof buf, "%d%%", g_bat_pct);
lv_label_set_text(bat_pct_lbl, buf);
}
lv_obj_set_style_text_color(bat_pct_lbl, batt_color(g_bat_pct), 0);
lv_bar_set_value(bat_bar, (g_bat_pct < 0) ? 0 : g_bat_pct, LV_ANIM_OFF);
lv_obj_set_style_bg_color(bat_bar, batt_color(g_bat_pct), LV_PART_INDICATOR);
if (g_bat_mv <= 0) {
lv_label_set_text(bat_volt_lbl, "--.-- V");
} else {
snprintf(buf, sizeof buf, "%d.%02d V", g_bat_mv / 1000, (g_bat_mv % 1000) / 10);
lv_label_set_text(bat_volt_lbl, buf);
}
if (g_bat_charging) {
lv_label_set_text(bat_time_lbl, LV_SYMBOL_CHARGE " Charging");
lv_obj_set_style_text_color(bat_time_lbl, COL_GREEN, 0);
} else if (g_bat_min < 0) {
lv_label_set_text(bat_time_lbl, "Estimating time left...");
lv_obj_set_style_text_color(bat_time_lbl, COL_DIM, 0);
} else {
int h = g_bat_min / 60, m = g_bat_min % 60;
if (h > 0) snprintf(buf, sizeof buf, "~%dh %dm left", h, m);
else snprintf(buf, sizeof buf, "~%dm left", m);
lv_label_set_text(bat_time_lbl, buf);
lv_obj_set_style_text_color(bat_time_lbl, COL_ACCENT, 0);
}
// Reddens as the cell nears the cutoff. 3.0 V mirrors LOW_V_CUTOFF_MV in
// main.cpp; below 3.2 V is the "getting close" warn band.
bool near_cutoff = (!g_bat_charging && g_bat_mv > 0 && g_bat_mv < 3200);
lv_obj_set_style_text_color(bat_note_lbl, near_cutoff ? COL_RED : COL_DIM, 0);
}
// ======== Public API ========
void ui_init(void) {
compute_layout(board_caps());
lv_obj_t* scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, COL_BG, 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
init_icon_dsc_rgb565a8(&logo_dsc, LOGO_WIDTH, LOGO_HEIGHT, logo_data);
init_battery_icons();
init_usage_screen(scr);
splash_init(scr);
if (splash_get_root()) {
lv_obj_add_event_cb(splash_get_root(), global_click_cb, LV_EVENT_CLICKED, NULL);
}
// v2 screens — created hidden, shown on navigation.
init_menu_screen(scr);
init_bt_screen(scr);
init_soundpad_screen(scr);
init_session_screen(scr);
init_nowplaying_screen(scr);
init_homeassist_screen(scr);
init_dimmer_screen(scr);
init_battery_screen(scr);
logo_img = lv_image_create(scr);
lv_image_set_src(logo_img, &logo_dsc);
lv_obj_set_pos(logo_img, L.margin, L.title_y - 10);
lv_obj_add_flag(logo_img, LV_OBJ_FLAG_CLICKABLE); // tap the Claude logo → launcher
lv_obj_set_ext_click_area(logo_img, 14);
lv_obj_add_event_cb(logo_img, logo_click_cb, LV_EVENT_CLICKED, NULL);
// Back chevron — shared across the menu/app screens, hidden on home/splash.
nav_back_btn = lv_label_create(scr);
lv_label_set_text(nav_back_btn, LV_SYMBOL_LEFT);
lv_obj_set_style_text_font(nav_back_btn, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(nav_back_btn, COL_TEXT, 0);
lv_obj_set_pos(nav_back_btn, L.margin + 4, L.title_y + 10); // clear of the rounded top-left corner
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_ext_click_area(nav_back_btn, 40); // generous tap zone — the bare chevron was hard to hit
lv_obj_add_event_cb(nav_back_btn, nav_back_cb, LV_EVENT_CLICKED, NULL);
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
battery_img = lv_image_create(scr);
lv_image_set_src(battery_img, &battery_dscs[0]);
lv_obj_set_pos(battery_img, L.scr_w - 48 - L.margin, L.title_y);
lv_obj_add_flag(battery_img, LV_OBJ_FLAG_CLICKABLE); // tap → battery detail
lv_obj_set_ext_click_area(battery_img, 16); // generous tap target near the corner
lv_obj_add_event_cb(battery_img, battery_click_cb, LV_EVENT_CLICKED, NULL);
}
void ui_update(const UsageData* data) {
if (!data->valid) return;
char buf[48];
// Session screen — local token data, computed by the daemon from transcripts
// with no API/token needed. Always refresh it so it stays live even when the
// rate-limit data below is unavailable (data->ok == false).
if (sess_cost_lbl) {
lv_label_set_text_fmt(sess_cost_lbl, "$%d.%02d",
data->cost_cents_today / 100, data->cost_cents_today % 100);
format_tokens(data->tokens_today, buf, sizeof(buf));
lv_label_set_text_fmt(sess_tokens_lbl, "%s tokens", buf);
format_tokens(data->output_today, buf, sizeof(buf));
lv_label_set_text_fmt(sess_gen_lbl, "%s generated", buf);
lv_label_set_text_fmt(sess_msgs_lbl, "%d requests", data->messages_today);
}
// Now Playing — local media session relayed by the daemon. Like Session, it's
// independent of the rate-limit data, so refresh it before the ok==false bail.
// Only touch the labels when the track / playback state actually changed: the
// daemon pushes a payload every few seconds (fast "music card" cadence), and
// re-running lv_label_set_text() on an unchanged title restarts the circular
// scroll animation each time, so a long title would never get to scroll.
if (nowplaying_container) {
static int np_state_shown = -1;
static char np_title_shown[64] = {0};
static char np_artist_shown[64] = {0};
if (data->np_state != np_state_shown
|| strcmp(data->np_title, np_title_shown) != 0
|| strcmp(data->np_artist, np_artist_shown) != 0) {
np_state_shown = data->np_state;
strlcpy(np_title_shown, data->np_title, sizeof(np_title_shown));
strlcpy(np_artist_shown, data->np_artist, sizeof(np_artist_shown));
if (data->np_state == 0) {
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
lv_label_set_text(np_title_lbl, "Nothing playing");
lv_obj_set_style_text_color(np_title_lbl, COL_DIM, 0);
lv_label_set_text(np_artist_lbl, "");
lv_label_set_text(np_status_lbl, "");
} else {
bool playing = (data->np_state == 1);
lv_label_set_text(np_icon_lbl, playing ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE);
lv_obj_set_style_text_color(np_icon_lbl, COL_ACCENT, 0);
lv_label_set_text(np_title_lbl, data->np_title[0] ? data->np_title : "(no title)");
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
lv_label_set_text(np_artist_lbl, data->np_artist);
lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused");
}
}
}
// Rate-limit utilization (5h / 7d). The daemon sets ok=false when this data
// is unavailable (expired OAuth token, API down). Skip the bars and DON'T
// bump the freshness clock then, so the usage view falls back to its idle
// "Zzz" state instead of showing stale or zeroed percentages.
if (!data->ok) return;
last_data_ms = lv_tick_get(); // fresh limit data just landed → dot goes green
data_received = true;
int s_pct = (int)(data->session_pct + 0.5f);
lv_label_set_text_fmt(lbl_session_pct, "%d%%", s_pct);
lv_bar_set_value(bar_session, s_pct, LV_ANIM_ON);
lv_obj_set_style_bg_color(bar_session, pct_color(data->session_pct), LV_PART_INDICATOR);
format_reset_time(data->session_reset_mins, buf, sizeof(buf));
lv_label_set_text(lbl_session_reset, buf);
int w_pct = (int)(data->weekly_pct + 0.5f);
lv_label_set_text_fmt(lbl_weekly_pct, "%d%%", w_pct);
lv_bar_set_value(bar_weekly, w_pct, LV_ANIM_ON);
lv_obj_set_style_bg_color(bar_weekly, pct_color(data->weekly_pct), LV_PART_INDICATOR);
format_reset_time(data->weekly_reset_mins, buf, sizeof(buf));
lv_label_set_text(lbl_weekly_reset, buf);
}
// Pick the usage-view sub-screen: pairing hint (BLE down), the idle "Zzz" screen
// (connected but data has gone stale), or the live usage panels. Only re-lays-out
// on an actual change. The animated status line stays visible everywhere — it
// reads "Listening…" on the idle screen, keeping it alive rather than frozen.
static void update_view_state(void) {
if (!usage_group || !pair_group || !idle_group) return;
int v;
if (!s_ble_connected) {
v = 0; // pairing hint
} else if (data_received && (lv_tick_get() - last_data_ms) < DATA_FRESH_MS) {
v = 2; // live usage
} else {
v = 1; // idle / Zzz
}
if (v == view_state) return;
view_state = v;
lv_obj_add_flag(pair_group, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(idle_group, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(usage_group, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(v == 0 ? pair_group : v == 1 ? idle_group : usage_group,
LV_OBJ_FLAG_HIDDEN);
}
void ui_tick_anim(void) {
if (current_screen != SCREEN_USAGE) return;
update_view_state();
if (view_state == 1) splash_mini_tick(); // animate the sleeping creature on the idle screen
uint32_t now = lv_tick_get();
if (now - anim_msg_start >= ANIM_MSG_MS) {
anim_msg_idx = (anim_msg_idx + 1) % ANIM_MSG_COUNT;
anim_msg_start = now;
}
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 = "Waiting"; // advertising / waiting for a host connection
} else if (view_state == 1) { // idle — alternate so it reads as alive AND data-less
text = (anim_msg_idx & 1) ? "No data" : "Listening";
} 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], text);
lv_label_set_text(lbl_anim, buf);
}
static screen_t prev_non_splash_screen = SCREEN_USAGE;
static screen_t screen_before_battery = SCREEN_USAGE; // where the battery tap came from
static void apply_battery_visibility(void) {
if (!battery_img) return;
// Hidden on the splash, and on the battery detail itself (no point offering a
// tap target for the screen you're already on).
if (current_screen == SCREEN_SPLASH || current_screen == SCREEN_BATTERY)
lv_obj_add_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
else
lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
}
// Tapping the splash/animations screen returns to the home (usage) screen.
static void global_click_cb(lv_event_t* e) {
(void)e;
if (current_screen == SCREEN_SPLASH) ui_show_screen(SCREEN_USAGE);
}
// The Claude logo on the home screen opens the launcher.
static void logo_click_cb(lv_event_t* e) {
(void)e;
ui_show_screen(SCREEN_MENU);
}
// The battery icon (top-right, on every non-splash screen) opens the detail
// screen. Remember where we came from so the back chevron returns there.
static void battery_click_cb(lv_event_t* e) {
(void)e;
if (current_screen == SCREEN_SPLASH || current_screen == SCREEN_BATTERY) return;
screen_before_battery = current_screen;
ui_show_screen(SCREEN_BATTERY);
}
// Back chevron: from the battery detail → wherever it was opened from; from the
// launcher → home; from any other app screen → launcher.
static void nav_back_cb(lv_event_t* e) {
(void)e;
if (current_screen == SCREEN_BATTERY) { ui_show_screen(screen_before_battery); return; }
ui_show_screen(current_screen == SCREEN_MENU ? SCREEN_USAGE : SCREEN_MENU);
}
// Top-left chrome: the Claude logo (tap → menu) on home, the back chevron on the
// screens below it, neither on the splash.
static void update_nav_chrome(screen_t screen) {
bool show_logo = (screen == SCREEN_USAGE);
bool show_back = (screen != SCREEN_USAGE && screen != SCREEN_SPLASH);
if (logo_img) {
if (show_logo) lv_obj_clear_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(logo_img, LV_OBJ_FLAG_HIDDEN);
}
if (nav_back_btn) {
if (show_back) lv_obj_clear_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
}
}
void ui_show_screen(screen_t screen) {
lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN);
if (menu_container) lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
if (bt_container) lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
if (soundpad_container) lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN);
if (session_container) lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
if (nowplaying_container) lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
if (homeassist_container) lv_obj_add_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN);
if (dimmer_container) lv_obj_add_flag(dimmer_container, LV_OBJ_FLAG_HIDDEN);
if (battery_container) lv_obj_add_flag(battery_container, LV_OBJ_FLAG_HIDDEN);
splash_hide();
// Leaving the dimmer disarms it so the wrist can't keep driving the light
// from another screen.
if (current_screen == SCREEN_DIMMER && screen != SCREEN_DIMMER) dim_armed = false;
switch (screen) {
case SCREEN_SPLASH: splash_show(); break;
case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_MENU: lv_obj_clear_flag(menu_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_SOUNDPAD: lv_obj_clear_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_SESSION: lv_obj_clear_flag(session_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_NOWPLAYING: lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_HOMEASSIST:
lv_obj_clear_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN);
break;
case SCREEN_DIMMER:
dim_armed = false;
dim_refresh_dial();
dim_update_status();
ble_send_command("{\"cmd\":\"dimreq\"}"); // ask the daemon for a fresh light snapshot
lv_obj_clear_flag(dimmer_container, LV_OBJ_FLAG_HIDDEN);
break;
case SCREEN_BATTERY:
battery_screen_refresh();
lv_obj_clear_flag(battery_container, LV_OBJ_FLAG_HIDDEN);
break;
default: break;
}
if (screen != SCREEN_SPLASH) prev_non_splash_screen = screen;
current_screen = screen;
update_nav_chrome(screen);
apply_battery_visibility();
}
void ui_toggle_splash(void) {
if (current_screen == SCREEN_SPLASH) ui_show_screen(prev_non_splash_screen);
else ui_show_screen(SCREEN_SPLASH);
}
screen_t ui_get_current_screen(void) {
return current_screen;
}
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac) {
(void)name; (void)mac;
bool was_connected = s_ble_connected;
s_ble_connected = (state == BLE_STATE_CONNECTED);
if (s_ble_connected && !was_connected) connected_at_ms = lv_tick_get();
// pair / idle / usage — picked from connection + data freshness.
update_view_state();
}
void ui_update_battery(int percent, int voltage_mv, int minutes_left, bool charging) {
g_bat_pct = percent;
g_bat_mv = voltage_mv;
g_bat_min = minutes_left;
g_bat_charging = charging;
int idx;
if (charging) {
idx = 4;
} else if (percent < 0) {
idx = 0;
} else if (percent <= 10) {
idx = 0;
} else if (percent <= 35) {
idx = 1;
} else if (percent <= 75) {
idx = 2;
} else {
idx = 3;
}
lv_image_set_src(battery_img, &battery_dscs[idx]);
apply_battery_visibility();
// Keep the detail screen live while it's the one on display.
if (current_screen == SCREEN_BATTERY) battery_screen_refresh();
}
void ui_show_low_battery(void) {
// Top layer so it covers whatever screen was active, including the splash.
lv_obj_t* ov = lv_obj_create(lv_layer_top());
lv_obj_set_size(ov, L.scr_w, L.scr_h);
lv_obj_set_pos(ov, 0, 0);
lv_obj_set_style_bg_color(ov, COL_BG, 0);
lv_obj_set_style_bg_opa(ov, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(ov, 0, 0);
lv_obj_set_style_pad_all(ov, 0, 0);
lv_obj_clear_flag(ov, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* icon = lv_label_create(ov);
lv_label_set_text(icon, LV_SYMBOL_WARNING);
lv_obj_set_style_text_font(icon, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(icon, COL_RED, 0);
lv_obj_align(icon, LV_ALIGN_CENTER, 0, -70);
lv_obj_t* t = lv_label_create(ov);
lv_label_set_text(t, "Battery critically low");
lv_obj_set_style_text_font(t, L.bt_status_font, 0);
lv_obj_set_style_text_color(t, COL_TEXT, 0);
lv_obj_align(t, LV_ALIGN_CENTER, 0, -10);
lv_obj_t* s = lv_label_create(ov);
lv_obj_set_width(s, L.content_w);
lv_label_set_long_mode(s, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_align(s, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(s, "Shutting down to protect the cell");
lv_obj_set_style_text_font(s, L.bt_device_font, 0);
lv_obj_set_style_text_color(s, COL_DIM, 0);
lv_obj_align(s, LV_ALIGN_CENTER, 0, 55);
}