diff --git a/daemon/claude_usage_daemon_windows.py b/daemon/claude_usage_daemon_windows.py index 01135ea..e615b7d 100644 --- a/daemon/claude_usage_daemon_windows.py +++ b/daemon/claude_usage_daemon_windows.py @@ -57,6 +57,90 @@ API_BODY = { "messages": [{"role": "user", "content": "hi"}], } +# Anthropic list prices, USD per million tokens, keyed by a substring of the +# model id. Used to show the "equivalent API cost" of today's Claude Code usage +# — subscription users don't actually pay this; it's the ccusage-style flex. +PRICING = { + "opus": {"in": 15.0, "out": 75.0, "cache_w": 18.75, "cache_r": 1.50}, + "sonnet": {"in": 3.0, "out": 15.0, "cache_w": 3.75, "cache_r": 0.30}, + "haiku": {"in": 1.0, "out": 5.0, "cache_w": 1.25, "cache_r": 0.10}, +} +_DEFAULT_PRICE = PRICING["opus"] + + +def _price_for(model: str) -> dict: + m = (model or "").lower() + for key, price in PRICING.items(): + if key in m: + return price + return _DEFAULT_PRICE + + +def compute_today_usage() -> dict: + """Sum today's Claude Code token usage + equivalent API cost across all local + project transcripts (~/.claude/projects/**/*.jsonl). + + Reads only files modified today (a session that touched today has mtime + today), so the scan stays cheap even with a large transcript history. Each + assistant line carries message.usage (input/output/cache token counts) and a + UTC timestamp; per-line timestamps gate to today so a session spanning + midnight is split correctly. Returns the compact BLE fields tk/tc/tn. + """ + base = Path.home() / ".claude" / "projects" + today = datetime.date.today() + midnight = datetime.datetime.combine(today, datetime.time.min) + total_tokens = 0 + cost = 0.0 + messages = 0 + try: + files = list(base.glob("**/*.jsonl")) + except OSError: + return {"tk": 0, "tc": 0, "tn": 0} + for f in files: + try: + if datetime.datetime.fromtimestamp(f.stat().st_mtime) < midnight: + continue + except OSError: + continue + try: + with open(f, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + msg = obj.get("message") if isinstance(obj, dict) else None + if not isinstance(msg, dict): + continue + usage = msg.get("usage") + if not isinstance(usage, dict): + continue + ts = obj.get("timestamp") + if not ts: + continue + try: + d = datetime.datetime.fromisoformat( + ts.replace("Z", "+00:00")).astimezone().date() + except ValueError: + continue + if d != today: + continue + inp = usage.get("input_tokens", 0) or 0 + out = usage.get("output_tokens", 0) or 0 + cw = usage.get("cache_creation_input_tokens", 0) or 0 + cr = usage.get("cache_read_input_tokens", 0) or 0 + total_tokens += inp + out + cw + cr + messages += 1 + p = _price_for(msg.get("model")) + cost += (inp * p["in"] + out * p["out"] + + cw * p["cache_w"] + cr * p["cache_r"]) / 1_000_000 + except OSError: + continue + return {"tk": int(total_tokens), "tc": int(round(cost * 100)), "tn": messages} + def _build_file_logger() -> logging.Logger | None: """Create a rotating file logger for field diagnostics, or None. @@ -158,6 +242,9 @@ async def poll_api(token: str) -> dict | None: "st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"), "ok": True, } + # Piggyback today's local token usage/cost onto the same 60s payload. Local + # file read (~tens of ms once per minute) — fine to do inline in the poller. + payload.update(compute_today_usage()) return payload diff --git a/firmware/src/data.h b/firmware/src/data.h index f794477..42421bc 100644 --- a/firmware/src/data.h +++ b/firmware/src/data.h @@ -9,4 +9,10 @@ struct UsageData { char status[16]; // "allowed" or "limited" bool ok; // data parse succeeded bool valid; // false until first successful parse + + // Today's Claude Code usage (Phase 4 — computed by the daemon from local + // transcripts). Tokens can exceed 2^31 on a heavy day, so use 64-bit. + long long tokens_today; // total tokens today (input+output+cache) + int cost_cents_today; // equivalent API cost today, US cents + int messages_today; // assistant messages today }; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 8822235..885c4af 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -112,6 +112,9 @@ static bool parse_json(const char* json, UsageData* out) { 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->cost_cents_today = doc["tc"] | 0; + out->messages_today = doc["tn"] | 0; out->valid = true; return true; } diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 2d41cce..a995cd7 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -129,6 +129,10 @@ 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_msgs_lbl; // 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 @@ -237,6 +241,13 @@ static void format_reset_time(int mins, char* buf, size_t len) { } } +// 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); @@ -676,6 +687,46 @@ static void init_soundpad_screen(lv_obj_t* scr) { 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 + 30); + + 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 + 110); + + 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 + 165); + + sess_msgs_lbl = lv_label_create(session_container); + lv_label_set_text(sess_msgs_lbl, "\xE2\x80\x94 messages"); + 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 + 215); + + lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN); +} + // ======== Public API ======== void ui_init(void) { @@ -700,6 +751,7 @@ void ui_init(void) { init_stub_screen(scr); init_bt_screen(scr); init_soundpad_screen(scr); + init_session_screen(scr); logo_img = lv_image_create(scr); lv_image_set_src(logo_img, &logo_dsc); @@ -746,6 +798,15 @@ void ui_update(const UsageData* data) { format_reset_time(data->weekly_reset_mins, buf, sizeof(buf)); lv_label_set_text(lbl_weekly_reset, buf); + + // Session screen — refreshed here so it's current whenever it's opened. + 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); + lv_label_set_text_fmt(sess_msgs_lbl, "%d messages", data->messages_today); + } } // Pick the usage-view sub-screen: pairing hint (BLE down), the idle "Zzz" screen @@ -854,6 +915,7 @@ void ui_show_screen(screen_t screen) { if (stub_container) lv_obj_add_flag(stub_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); splash_hide(); switch (screen) { @@ -861,8 +923,8 @@ void ui_show_screen(screen_t screen) { 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_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break; - case SCREEN_SESSION: case SCREEN_NOWPLAYING: case SCREEN_HOMEASSIST: stub_show_for(screen);