v2 Phase 4: Session screen — today's tokens and equivalent cost

Daemon: compute_today_usage() sums today's Claude Code token usage across the
local project transcripts (~/.claude/projects/**/*.jsonl), reading only files
modified today so the scan stays cheap, and prices it with Anthropic list rates
to show the equivalent API cost (the ccusage-style flex for subscription users).
The compact fields tk (total tokens), tc (cost in cents) and tn (message count)
piggyback onto the existing 60s BLE payload.

Firmware: UsageData gains tokens_today (64-bit) / cost_cents_today /
messages_today; parse_json reads tk/tc/tn; a new Session screen shows the cost
as the hero with total tokens and message count below, refreshed from ui_update
so it's current whenever opened. Wire SCREEN_SESSION to the real screen (was a
stub). Verified end to end: daemon sends e.g. tk=105374109 tc=26838 tn=663.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wenil
2026-06-20 15:03:23 +03:00
co-authored by Claude Opus 4.8
parent abe72eca0a
commit 56b49898a3
4 changed files with 159 additions and 1 deletions
+87
View File
@@ -57,6 +57,90 @@ API_BODY = {
"messages": [{"role": "user", "content": "hi"}], "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: def _build_file_logger() -> logging.Logger | None:
"""Create a rotating file logger for field diagnostics, or 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"), "st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"),
"ok": True, "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 return payload
+6
View File
@@ -9,4 +9,10 @@ struct UsageData {
char status[16]; // "allowed" or "limited" char status[16]; // "allowed" or "limited"
bool ok; // data parse succeeded bool ok; // data parse succeeded
bool valid; // false until first successful parse 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
}; };
+3
View File
@@ -112,6 +112,9 @@ static bool parse_json(const char* json, UsageData* out) {
out->weekly_reset_mins = doc["wr"] | -1; out->weekly_reset_mins = doc["wr"] | -1;
strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status)); strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status));
out->ok = doc["ok"] | false; 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; out->valid = true;
return true; return true;
} }
+63 -1
View File
@@ -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_status_lbl;
static lv_obj_t* bt_name_lbl; static lv_obj_t* bt_name_lbl;
static lv_obj_t* bt_mac_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 // 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 // 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 // Forward decls — callbacks defined near ui_show_screen below
static void global_click_cb(lv_event_t* e); static void global_click_cb(lv_event_t* e);
static void logo_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); 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 ======== // ======== Public API ========
void ui_init(void) { void ui_init(void) {
@@ -700,6 +751,7 @@ void ui_init(void) {
init_stub_screen(scr); init_stub_screen(scr);
init_bt_screen(scr); init_bt_screen(scr);
init_soundpad_screen(scr); init_soundpad_screen(scr);
init_session_screen(scr);
logo_img = lv_image_create(scr); logo_img = lv_image_create(scr);
lv_image_set_src(logo_img, &logo_dsc); 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)); format_reset_time(data->weekly_reset_mins, buf, sizeof(buf));
lv_label_set_text(lbl_weekly_reset, 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 // 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 (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 (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 (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(); splash_hide();
switch (screen) { 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_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_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_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_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_SESSION:
case SCREEN_NOWPLAYING: case SCREEN_NOWPLAYING:
case SCREEN_HOMEASSIST: case SCREEN_HOMEASSIST:
stub_show_for(screen); stub_show_for(screen);