#include #include #include #include #include #include "data.h" #include "ui.h" #include "ble.h" #include "version.h" #include "splash.h" #include "usage_rate.h" #include "battery_est.h" #include "idle.h" #include "idle_cfg.h" #include "brightness.h" #include "hal/board_caps.h" #include "hal/display_hal.h" #include "hal/touch_hal.h" #include "hal/input_hal.h" #include "hal/power_hal.h" #include "hal/imu_hal.h" static UsageData usage = {}; // ---- Low-voltage protective cutoff ---- // Below LOW_V_CUTOFF_MV on battery power (USB absent), the firmware warns on // screen for LOW_V_WARN_MS then powers the PMU fully off, so the cell isn't // driven into deep over-discharge. The reading must stay below for // LOW_V_SUSTAIN_MS first: the cell voltage sags under the BLE + AMOLED load, so // a momentary dip must not trigger a shutdown. // // 3000 mV (3.0 V) is a gentle Li-ion floor — well clear of the deep // over-discharge zone (~2.5–2.8 V). Mirrored as the "Auto-off below 3.0 V" // note on the battery screen (ui.cpp). Raise toward 3300 mV to be even kinder // to the cell, or down to 2800 to squeeze out the last drops. #define LOW_V_CUTOFF_MV 3000 #define LOW_V_SUSTAIN_MS 6000UL #define LOW_V_WARN_MS 5000UL // ---- LVGL draw buffers (partial render mode) ---- // PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free // boards (e.g. ESP32-C6) allocate from internal SRAM, so we shrink the strip // — 480×20 RGB565 = 19 KB × 2 buffers = 38 KB, fits beside everything else. #ifdef BOARD_HAS_PSRAM #define BUF_LINES 40 #define LV_BUF_CAPS (MALLOC_CAP_SPIRAM) #else #define BUF_LINES 20 #define LV_BUF_CAPS (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) #endif static uint16_t* buf1 = nullptr; static uint16_t* buf2 = nullptr; static uint32_t my_tick(void) { return millis(); } static void my_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { int32_t w = area->x2 - area->x1 + 1; int32_t h = area->y2 - area->y1 + 1; display_hal_draw_bitmap(area->x1, area->y1, w, h, (uint16_t*)px_map); lv_display_flush_ready(disp); } static void rounder_cb(lv_event_t* e) { lv_area_t* area = (lv_area_t*)lv_event_get_param(e); display_hal_round_area(&area->x1, &area->y1, &area->x2, &area->y2); } // Touch policy is driven by IDLE_WAKE_ON_TOUCH: // true → a press edge while asleep wakes the device and the first touch is // swallowed (mirrors the button wake-consumption); a press while // awake counts as activity. // false → touch never counts as activity and is fully swallowed while the // panel is dark, so pets/sleeves can't wake it overnight and LVGL // can't quietly toggle splash<->usage on a black panel. static void my_touch_cb(lv_indev_t* indev, lv_indev_data_t* data) { uint16_t x, y; bool pressed; touch_hal_read(&x, &y, &pressed); const bool raw_pressed = pressed; if (IDLE_WAKE_ON_TOUCH) { static bool touch_was = false; static bool touch_wake_swallowed = false; if (raw_pressed && !touch_was) { // Press edge — consume as wake if asleep. if (idle_consume_wake_press()) { touch_wake_swallowed = true; pressed = false; } } else if (!raw_pressed && touch_was) { // Release edge. if (touch_wake_swallowed) { touch_wake_swallowed = false; pressed = false; } } else if (raw_pressed && touch_wake_swallowed) { // Held finger through wake — keep hiding until release. pressed = false; } touch_was = raw_pressed; } else if (idle_is_asleep()) { pressed = false; } if (pressed) { data->point.x = x; data->point.y = y; data->state = LV_INDEV_STATE_PRESSED; } else { data->state = LV_INDEV_STATE_RELEASED; } } // Parse a JSON line into UsageData. static bool parse_json(const char* json, UsageData* out) { JsonDocument doc; DeserializationError err = deserializeJson(doc, json); if (err) { Serial.printf("JSON parse error: %s\n", err.c_str()); return false; } out->session_pct = doc["s"] | 0.0f; out->session_reset_mins = doc["sr"] | -1; out->weekly_pct = doc["w"] | 0.0f; out->weekly_reset_mins = doc["wr"] | -1; strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status)); out->ok = doc["ok"] | false; out->tokens_today = doc["tk"] | (long long)0; out->output_today = doc["to"] | (long long)0; out->cost_cents_today = doc["tc"] | 0; out->messages_today = doc["tn"] | 0; out->np_state = doc["np"] | 0; strlcpy(out->np_title, doc["nt"] | "", sizeof(out->np_title)); strlcpy(out->np_artist, doc["na"] | "", sizeof(out->np_artist)); out->valid = true; // Dynamic Home buttons (Phase 7 M2) — optional "btns" array of labels pushed // by the daemon from the desktop config (only in the ~60s heartbeat write). // Absent (e.g. the frequent now-playing-only writes) => leave the current // buttons untouched; present => replace the whole set. The label strings live // in `doc`, which is valid until this function returns, and ui_set_buttons // copies them immediately. JsonArray btns = doc["btns"].as(); if (!btns.isNull()) { const char* labels[UI_MAX_BUTTONS]; int n = 0; for (JsonVariant v : btns) { if (n >= UI_MAX_BUTTONS) break; labels[n++] = v.as(); } ui_set_buttons(labels, n); } // Tilt-dimmer snapshot (Phase 6 step 3 / M3) — optional "dim" object carrying // the controlled light's live state so the dial seeds from reality. Pushed on // the ~60s heartbeat and on demand when the watch opens the Dimmer screen. JsonObject dim = doc["dim"].as(); if (!dim.isNull()) { bool on = dim["on"] | 0; int bri = dim["bri"] | -1; // -1 = unknown (light off) int ct = dim["ct"] | -1; int mink = dim["mink"] | 2000; int maxk = dim["maxk"] | 6500; ui_dimmer_set_snapshot(on, bri, ct, mink, maxk); } // v3: provider theme — "pv" (id) selects the logo, "ac" (0xRRGGBB) the brand // accent. Stamped on every usage payload; ui_set_theme is change-guarded so // it only repaints on an actual provider switch. const char* pv = doc["pv"] | (const char*)nullptr; uint32_t ac = doc["ac"] | 0u; if (pv != nullptr || ac != 0u) ui_set_theme(pv, ac); // v3 Provider screen badge — optional "pnm" (name) + "pi"/"pc" (1-based // position / count among enabled providers). Present on the ~60s heartbeat. const char* pnm = doc["pnm"] | (const char*)nullptr; int pi = doc["pi"] | 0; int pc = doc["pc"] | 0; if (pnm != nullptr || pc != 0) ui_set_provider_badge(pnm, pi, pc); return true; } // ---- Serial command buffer ---- #define CMD_BUF_SIZE 64 static char cmd_buf[CMD_BUF_SIZE]; static int cmd_pos = 0; static void send_screenshot() { #ifndef BOARD_HAS_PSRAM // A full RGB565 framebuffer doesn't fit in internal SRAM on PSRAM-free // boards (e.g. 480×480×2 = 460 KB). Capture is unsupported there. Serial.println("SCREENSHOT_UNSUPPORTED"); return; #else const uint32_t w = board_caps().width; const uint32_t h = board_caps().height; const uint32_t row_bytes = w * 2; const uint32_t buf_size = row_bytes * h; uint8_t* sbuf = (uint8_t*)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM); if (!sbuf) { Serial.println("SCREENSHOT_ERR"); return; } lv_draw_buf_t draw_buf; lv_draw_buf_init(&draw_buf, w, h, LV_COLOR_FORMAT_RGB565, row_bytes, sbuf, buf_size); lv_result_t res = lv_snapshot_take_to_draw_buf(lv_screen_active(), LV_COLOR_FORMAT_RGB565, &draw_buf); if (res != LV_RESULT_OK) { heap_caps_free(sbuf); Serial.println("SCREENSHOT_ERR"); return; } Serial.printf("SCREENSHOT_START %lu %lu %lu\n", (unsigned long)w, (unsigned long)h, (unsigned long)buf_size); Serial.flush(); Serial.write(sbuf, buf_size); Serial.flush(); Serial.println(); Serial.println("SCREENSHOT_END"); heap_caps_free(sbuf); #endif } static void check_serial_cmd() { while (Serial.available()) { char c = Serial.read(); if (c == '\n' || c == '\r') { cmd_buf[cmd_pos] = '\0'; if (strcmp(cmd_buf, "screenshot") == 0) send_screenshot(); cmd_pos = 0; } else if (cmd_pos < CMD_BUF_SIZE - 1) { cmd_buf[cmd_pos++] = c; } } } // Each board provides this. Must bring up the shared I2C bus (Wire.begin // with the board's SDA/SCL pins) and any board-private hardware that has // to settle before display/touch (e.g. an IO expander gating the LCD // reset line). Called exactly once at the start of setup(). extern "C" void board_init(void); void setup() { Serial.begin(115200); delay(300); Serial.printf("{\"ready\":true,\"fw\":\"%s\"}\n", CLAWDMETER_VERSION); board_init(); display_hal_init(); display_hal_begin(); 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(); touch_hal_init(); // ---- LVGL ---- const int W = board_caps().width; const int H = board_caps().height; lv_init(); lv_tick_set_cb(my_tick); buf1 = (uint16_t*)heap_caps_malloc(W * BUF_LINES * 2, LV_BUF_CAPS); buf2 = (uint16_t*)heap_caps_malloc(W * BUF_LINES * 2, LV_BUF_CAPS); lv_display_t* disp = lv_display_create(W, H); lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565); lv_display_set_flush_cb(disp, my_flush_cb); lv_display_set_buffers(disp, buf1, buf2, W * BUF_LINES * 2, LV_DISPLAY_RENDER_MODE_PARTIAL); lv_display_add_event_cb(disp, rounder_cb, LV_EVENT_INVALIDATE_AREA, NULL); lv_indev_t* indev = lv_indev_create(); lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); lv_indev_set_read_cb(indev, my_touch_cb); ble_init(); input_hal_init(); ui_init(); ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address()); ui_update_battery(power_hal_battery_pct(), power_hal_battery_mv(), -1, power_hal_is_charging()); ui_show_screen(SCREEN_SPLASH); Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n", board_caps().name, W, H); } 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(); ui_tick_anim(); ble_tick(); power_hal_tick(); imu_hal_tick(); splash_tick(); // Rotation transition (blank + ramp) would fight the idle fade — skip // ticks while the panel is dark. A rotation that happens during sleep // is detected by the next tick after wake and ramped in then. if (!idle_is_asleep()) display_hal_tick(); // ---- Physical buttons ---- // PRIMARY → HID Space (Claude Code voice-mode PTT) // SECONDARY → HID Shift+Tab (mode toggle; only if the board has one) // 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 // so no separate idle_note_activity() call is needed here. { static bool primary_was = false; static bool primary_wake_swallowed = false; static bool primary_was_dimmer = false; // press consumed by the dimmer (no HID) bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY); if (primary_now != primary_was) { if (primary_now) { if (idle_consume_wake_press()) primary_wake_swallowed = true; else if (ui_get_current_screen() == SCREEN_DIMMER) { ui_dimmer_arm(); // BOOT arms the tilt-dimmer (no HID here) primary_was_dimmer = true; } else { ble_keyboard_press(0x2C, 0); // HID Space, no mods primary_was_dimmer = false; } } else { if (primary_wake_swallowed) primary_wake_swallowed = false; else if (primary_was_dimmer) primary_was_dimmer = false; else ble_keyboard_release(); } primary_was = primary_now; } if (board_caps().button_count >= 2) { static bool secondary_was = false; static bool secondary_wake_swallowed = false; bool secondary_now = input_hal_is_held(INPUT_BTN_SECONDARY); if (secondary_now != secondary_was) { if (secondary_now) { if (idle_consume_wake_press()) secondary_wake_swallowed = true; else ble_keyboard_press(0x2B, 0x02); // HID Tab + LEFT_SHIFT } else { if (secondary_wake_swallowed) secondary_wake_swallowed = false; else ble_keyboard_release(); } secondary_was = secondary_now; } } if (power_hal_pwr_pressed()) { if (!idle_consume_wake_press()) { // On splash: cycle animations. On the dimmer: switch the // controlled parameter (brightness ⇄ temp). Elsewhere: cycle // screen brightness. screen_t cs = ui_get_current_screen(); if (cs == SCREEN_SPLASH) splash_next(); else if (cs == SCREEN_DIMMER) ui_dimmer_switch_param(); else brightness_cycle(); } } pair_tick(); } // Tilt-dimmer control loop (no-op unless the Dimmer screen is armed). Keep // the panel awake while adjusting so the idle timer can't sleep mid-tilt. ui_dimmer_tick(); if (ui_dimmer_is_armed()) idle_note_activity(); ble_state_t bs = ble_get_state(); if (bs != last_ble_state) { last_ble_state = bs; ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address()); } // ---- Battery telemetry → UI + time-left estimate ---- static int last_pct = -2; static int last_mv_bucket = -2; static bool last_charging = false; static uint32_t last_bat_ui_ms = 0; int pct = power_hal_battery_pct(); int mv = power_hal_battery_mv(); bool charging = power_hal_is_charging(); int mv_bucket = (mv <= 0) ? -1 : (mv / 20); // 20 mV buckets — ignore sub-bucket jitter uint32_t now_ms = millis(); bool bat_changed = (pct != last_pct) || (charging != last_charging) || (mv_bucket != last_mv_bucket); bool bat_periodic = (now_ms - last_bat_ui_ms >= 2000); // keep "time left" counting down live if (bat_changed || bat_periodic) { last_pct = pct; last_mv_bucket = mv_bucket; last_charging = charging; last_bat_ui_ms = now_ms; battery_est_update(pct, charging); ui_update_battery(pct, mv, battery_est_minutes(), charging); } // Push battery state to the host (~60s, plus on charge-state flip) so the PC // app can warn on low charge. Reuses the watch→PC command char (…0005), so no // new GATT characteristic / re-pair. ble_send_command no-ops if disconnected. { static uint32_t last_bat_tx_ms = 0; static bool last_tx_charging = false; if (mv > 0 && (now_ms - last_bat_tx_ms >= 60000 || charging != last_tx_charging)) { last_bat_tx_ms = now_ms; last_tx_charging = charging; char bm[64]; snprintf(bm, sizeof bm, "{\"bat\":%d,\"mv\":%d,\"chg\":%d}", pct, mv, charging ? 1 : 0); ble_send_command(bm); } } // ---- Low-voltage protective cutoff ---- // Sustained below the floor on battery power → warn, then power fully off. { static uint32_t low_v_since = 0; bool batt_present = (mv > 0); // 0 == no battery / not measurable if (batt_present && !power_hal_is_vbus_in() && mv < LOW_V_CUTOFF_MV) { if (low_v_since == 0) low_v_since = now_ms; if (now_ms - low_v_since >= LOW_V_SUSTAIN_MS) { Serial.printf("Low battery %d mV < %d mV - protective shutdown\n", mv, LOW_V_CUTOFF_MV); idle_note_activity(); // wake the panel so the warning is visible ui_show_low_battery(); uint32_t t0 = millis(); while (millis() - t0 < LOW_V_WARN_MS) { // pump LVGL so the warning paints + fades in idle_tick(); // drive the wake-from-sleep brightness ramp lv_timer_handler(); if (!idle_is_asleep()) display_hal_tick(); delay(20); } power_hal_shutdown(); delay(3000); // let the rail collapse low_v_since = 0; // bench-supply fallback: don't spin if still powered } } else { low_v_since = 0; } } check_serial_cmd(); if (ble_has_data()) { if (parse_json(ble_get_data(), &usage)) { int g_before = usage_rate_group(); usage_rate_sample(usage.session_pct); int g_after = usage_rate_group(); if (g_after != g_before) { Serial.printf("usage rate: group %d -> %d (s=%.2f%%)\n", g_before, g_after, usage.session_pct); if (splash_is_active()) splash_pick_for_current_rate(); } ui_update(&usage); ble_send_ack(); } else { ble_send_nack(); } } delay(5); }