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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+63
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user