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"}],
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user