diff --git a/firmware/main/main.c b/firmware/main/main.c index d0de904..7f2c4c1 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -1,35 +1,9 @@ #include "display.h" #include "esp_lvgl_port.h" -#include "esp_timer.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" #include "ui.h" -#include "comm.h" #include "usb.h" #include "keepawake.h" -/* Watchdog task: runs on Core1 at 5-second intervals. - * Checks if comm_last_rx_ms() is older than 60s; if so, sets stale=true - * and calls ui_update to grey-out all data fields. - * - * Cross-core safety: comm_last_rx_ms() returns volatile uint32_t - * (atomic on ESP32-S3 Xtensa LX7), so no lock needed around that read. */ -static void watchdog_task(void *arg) -{ - while (1) { - uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000); - uint32_t last = comm_last_rx_ms(); - /* Unsigned subtraction handles uint32_t wrap correctly */ - if ((now_ms - last) > 60000U) { - ui_state_t st = { .stale = true }; - lvgl_port_lock(0); - ui_update(&st); - lvgl_port_unlock(); - } - vTaskDelay(pdMS_TO_TICKS(5000)); - } -} - void app_main(void) { /* Display + LVGL */ @@ -47,7 +21,7 @@ void app_main(void) /* Start 30-second F15 keep-awake timer */ keepawake_start(); - /* Stale-data watchdog: grey-out UI if no snapshot received for >60s. - * Priority 3, default-core (Core1 on S3, opposite to TinyUSB on Core0). */ - xTaskCreate(watchdog_task, "wd", 4096, NULL, 3, NULL); + /* No stale watchdog: the board holds the last received snapshot until a new + * one arrives (the UI only updates when comm gets a fresh line). On boot it + * shows the "--" placeholders until the first snapshot. */ } diff --git a/host/run.go b/host/run.go index d5952ad..f08f98d 100644 --- a/host/run.go +++ b/host/run.go @@ -14,7 +14,8 @@ func Run(stdin io.Reader, stdout io.Writer, sink SnapshotSink, inner InnerCmd, n raw = nil } // side effect:推板子(失败忽略,绝不影响 stdout) - if in, perr := ParseInput(raw); perr == nil { + // 只在有真实 rate_limits 时推:否则会把板子覆盖成全 0(板子保持上次真实值)。 + if in, perr := ParseInput(raw); perr == nil && in.HasRateLimits() { if line, lerr := BuildSnapshot(in, nowUnix).Line(); lerr == nil { _ = sink.Send(line) } diff --git a/host/run_test.go b/host/run_test.go index 47d00b1..37ce3de 100644 --- a/host/run_test.go +++ b/host/run_test.go @@ -65,3 +65,36 @@ func TestRun_BadJSONStillPassesThrough(t *testing.T) { t.Errorf("stdout = %q, passthrough must still happen", out.String()) } } + +func TestRun_NoRateLimits_DoesNotPush(t *testing.T) { + // status-line payload without rate_limits (all-zero) must NOT overwrite the + // board with zeros — skip the push, board keeps its last real value. + sink := &fakeSink{} + var out bytes.Buffer + inner := func(stdin []byte) ([]byte, error) { return []byte("OK"), nil } + noRL := `{"model":{"display_name":"Opus 4.8"},"cost":{"total_cost_usd":0}}` + if err := Run(bytes.NewReader([]byte(noRL)), &out, sink, inner, 1000); err != nil { + t.Fatalf("run: %v", err) + } + if len(sink.lines) != 0 { + t.Fatalf("pushed %d lines, want 0 (no rate_limits)", len(sink.lines)) + } + if out.String() != "OK" { + t.Errorf("stdout = %q, want passthrough", out.String()) + } +} + +func TestHasRateLimits(t *testing.T) { + none, _ := ParseInput([]byte(`{"cost":{"total_cost_usd":1}}`)) + if none.HasRateLimits() { + t.Error("empty rate_limits should be false") + } + zeroReset, _ := ParseInput([]byte(`{"rate_limits":{"five_hour":{"used_percentage":0,"resets_at":0}}}`)) + if zeroReset.HasRateLimits() { + t.Error("resets_at=0 should be false") + } + real, _ := ParseInput([]byte(`{"rate_limits":{"five_hour":{"used_percentage":0,"resets_at":5000}}}`)) + if !real.HasRateLimits() { + t.Error("real window (0% but resets_at>0) should be true") + } +} diff --git a/host/snapshot.go b/host/snapshot.go index 567c3ec..396923f 100644 --- a/host/snapshot.go +++ b/host/snapshot.go @@ -35,6 +35,15 @@ type Snapshot struct { Providers []Provider `json:"providers"` } +// HasRateLimits reports whether the input carries real rate-limit data. +// Claude Code sometimes emits a status-line payload with no rate_limits (e.g. +// before it has fetched them), which unmarshals to all-zero fields. A real +// window always has a reset timestamp, so resets_at>0 distinguishes real data +// (including a legitimate 0% at the start of a window) from "no data yet". +func (in StatusLineInput) HasRateLimits() bool { + return in.RateLimits.FiveHour.ResetsAt > 0 || in.RateLimits.SevenDay.ResetsAt > 0 +} + func ParseInput(b []byte) (StatusLineInput, error) { var in StatusLineInput err := json.Unmarshal(b, &in)