v3 M1b: runtime per-provider theming (firmware) + accent push (daemon)

The watch recolors to the active provider's brand, live, and it's cheap to add
more providers later.

Firmware:
- theme.h: expose THEME_ACCENT_HEX; ui.cpp: COL_ACCENT becomes a runtime
  lv_color_t backed by a shared style (s_accent_style). Static accent widgets
  (launcher tile icons, usage status line, session tokens) attach the shared
  style, so ui_set_theme() recolors them instantly via lv_obj_report_style_change
  — no widget tracking, no recreation.
- ui_set_theme(pv, accent_rgb): swaps the accent + a small logo table keyed by
  provider id (fallback to the Claude mark until a provider ships a logo). Change-
  guarded, so the ~3s payload cadence doesn't churn.
- main.cpp: parse "pv" (id) + "ac" (0xRRGGBB) from the payload and apply.
- Adding a provider needs zero firmware color edits (accent is data-driven) and
  at most one logo-table row.

Daemon: push "ac" = active provider's brand accent alongside "pv".

Test fix: test_providers used asyncio.run(), which closed the suite's shared
event loop and broke every later get_event_loop() test (Py3.13) — mirror the
repo's _run() helper instead.

Verified on the 2.06: forcing an OpenAI-green theme recolored the live launcher
icons (report_style_change path). 216 builds clean; daemon suite 94 passed.
This commit is contained in:
wenil
2026-07-10 00:40:24 +03:00
parent 6e3f8e9084
commit 83bd2bed43
7 changed files with 83 additions and 7 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ daemon/ha_config.json
# Session scratch — BLE daemon run logs + one-off on-device QA screenshots
/m3_daemon*.log
/dimmer_qa.png
/*_qa.png
/build-exe.log
+4
View File
@@ -915,6 +915,10 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
auth_problem = status.auth_problem
cached = status.to_payload()
cached["pv"] = active_pv # which brand theme the watch wears
try: # brand accent (0xRRGGBB) for the theme
cached["ac"] = int(provider.accent, 16)
except (ValueError, TypeError):
pass
last_claude_poll = now
# Now Playing (Phase 5) — best-effort Windows media session, read every
+8 -1
View File
@@ -12,6 +12,13 @@ from daemon import config
from daemon.providers import get_provider, AnthropicProvider, StubProvider, ProviderStatus
def _run(coro):
# Reuse the suite's shared event loop (never closed) like the other test
# modules — asyncio.run() would close it and set the current loop to None,
# breaking every later test that calls get_event_loop() (Python 3.13).
return asyncio.get_event_loop().run_until_complete(coro)
def _write(tmp_path, monkeypatch, data: dict):
"""Point the config module at an isolated temp config (and LOCALAPPDATA, so
the legacy-ha_config migration can't reach the real machine's file)."""
@@ -67,7 +74,7 @@ def test_registry_types():
def test_stub_provider_poll_is_safe():
st = asyncio.run(get_provider("zai").poll())
st = _run(get_provider("zai").poll())
assert isinstance(st, ProviderStatus)
assert st.ok is False
+7
View File
@@ -166,6 +166,13 @@ static bool parse_json(const char* json, UsageData* out) {
int maxk = dim["maxk"] | 6500;
ui_dimmer_set_snapshot(on, bri, ct, mink, maxk);
}
// v3: provider theme — "pv" (id) selects the logo, "ac" (0xRRGGBB) the brand
// accent. Stamped on every usage payload; ui_set_theme is change-guarded so
// it only repaints on an actual provider switch.
const char* pv = doc["pv"] | (const char*)nullptr;
uint32_t ac = doc["ac"] | 0u;
if (pv != nullptr || ac != 0u) ui_set_theme(pv, ac);
return true;
}
+2 -1
View File
@@ -7,7 +7,8 @@
#define THEME_PANEL lv_color_hex(0x1f1f1e) // card/zone fill
#define THEME_TEXT lv_color_hex(0xfaf9f5) // primary text
#define THEME_DIM lv_color_hex(0xb0aea5) // secondary text
#define THEME_ACCENT lv_color_hex(0xd97757) // brand terra-cotta
#define THEME_ACCENT_HEX 0xd97757 // brand terra-cotta (raw, for runtime palette)
#define THEME_ACCENT lv_color_hex(THEME_ACCENT_HEX) // brand terra-cotta
#define THEME_GREEN lv_color_hex(0x788c5d)
#define THEME_AMBER lv_color_hex(0xd97757)
#define THEME_RED lv_color_hex(0xc0392b)
+53 -4
View File
@@ -100,7 +100,13 @@ static void compute_layout(const BoardCaps& c) {
#define COL_PANEL THEME_PANEL
#define COL_TEXT THEME_TEXT
#define COL_DIM THEME_DIM
#define COL_ACCENT THEME_ACCENT
// COL_ACCENT is runtime (v3): the daemon pushes each provider's brand accent and
// ui_set_theme() swaps it live. Widgets that read COL_ACCENT directly pick up the
// new value on their next update; static widgets attach s_accent_style so a
// provider switch recolors them instantly (lv_obj_report_style_change).
static lv_color_t g_accent = lv_color_hex(THEME_ACCENT_HEX);
static lv_style_t s_accent_style;
#define COL_ACCENT g_accent
#define COL_GREEN THEME_GREEN
#define COL_AMBER THEME_AMBER
#define COL_RED THEME_RED
@@ -477,7 +483,7 @@ static void init_usage_screen(lv_obj_t* scr) {
lbl_anim = lv_label_create(usage_container);
lv_label_set_text(lbl_anim, "");
lv_obj_set_style_text_font(lbl_anim, &font_mono_32, 0);
lv_obj_set_style_text_color(lbl_anim, COL_ACCENT, 0);
lv_obj_add_style(lbl_anim, &s_accent_style, 0); // v3: recolors on provider switch
lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
}
@@ -516,7 +522,7 @@ static lv_obj_t* make_tile(lv_obj_t* parent, const AppEntry* app, int w, int h)
lv_obj_t* icon = lv_label_create(tile);
lv_label_set_text(icon, app->symbol);
lv_obj_set_style_text_font(icon, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(icon, COL_ACCENT, 0);
lv_obj_add_style(icon, &s_accent_style, 0); // v3: recolors on provider switch
lv_obj_t* lbl = lv_label_create(tile);
lv_label_set_text(lbl, app->label);
@@ -712,7 +718,7 @@ static void init_session_screen(lv_obj_t* scr) {
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_add_style(sess_tokens_lbl, &s_accent_style, 0); // v3: recolors on provider switch
lv_obj_align(sess_tokens_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 152);
sess_gen_lbl = lv_label_create(session_container);
@@ -1282,6 +1288,11 @@ static void battery_screen_refresh(void) {
void ui_init(void) {
compute_layout(board_caps());
// Shared accent style (v3 theming) — MUST be initialized before any screen is
// built, so widgets can attach it. ui_set_theme() recolors it live.
lv_style_init(&s_accent_style);
lv_style_set_text_color(&s_accent_style, g_accent);
lv_obj_t* scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, COL_BG, 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
@@ -1584,6 +1595,44 @@ screen_t ui_get_current_screen(void) {
return current_screen;
}
// ---- v3 provider theming --------------------------------------------------
// Per-provider logo on the usage screen. Only the Claude mark ships today; adding
// a provider's logo is one row here plus an RGB565A8 asset. Unknown / not-yet-
// added providers fall back to the Claude mark, so a switch still recolors.
struct ProviderLogo { const char* pv; const lv_image_dsc_t* dsc; };
static const ProviderLogo s_provider_logos[] = {
{ "anthropic", &logo_dsc },
// { "openai", &logo_openai_dsc }, // add when the asset lands
// { "zai", &logo_zai_dsc },
};
static const lv_image_dsc_t* logo_for(const char* pv) {
if (pv)
for (const auto& e : s_provider_logos)
if (strcmp(e.pv, pv) == 0) return e.dsc;
return &logo_dsc; // fallback: Claude mark until the provider ships a logo
}
// Apply a provider's brand: recolor the shared accent style (every widget using
// it repaints via report_style_change), retint the few non-text accent widgets,
// and swap the logo. accent_rgb == 0 keeps the current accent (logo-only change).
void ui_set_theme(const char* pv, uint32_t accent_rgb) {
// Change-guarded: the daemon stamps pv/ac on every write (~3s), so only act
// on an actual switch — otherwise report_style_change would churn constantly.
static uint32_t last_accent = THEME_ACCENT_HEX;
static char last_pv[16] = "";
if (accent_rgb && accent_rgb != last_accent) {
last_accent = accent_rgb;
g_accent = lv_color_hex(accent_rgb);
lv_style_set_text_color(&s_accent_style, g_accent);
lv_obj_report_style_change(&s_accent_style);
if (dim_arc) lv_obj_set_style_arc_color(dim_arc, g_accent, LV_PART_INDICATOR);
}
if (pv && strncmp(pv, last_pv, sizeof(last_pv)) != 0) {
strlcpy(last_pv, pv, sizeof(last_pv));
if (logo_img) lv_image_set_src(logo_img, logo_for(pv));
}
}
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac) {
(void)name; (void)mac;
bool was_connected = s_ble_connected;
+8
View File
@@ -17,6 +17,14 @@ enum screen_t {
};
void ui_init(void);
// v3 multi-provider theming. The daemon tags each usage payload with the active
// provider id (`pv`, e.g. "anthropic"/"openai"/"zai") and its brand accent
// (`ac`, 0xRRGGBB). ui_set_theme recolors the UI live via a shared accent style
// and swaps the per-provider logo. accent_rgb == 0 keeps the current accent.
// Change-guarded, so it's safe to call on every payload.
void ui_set_theme(const char* provider_id, uint32_t accent_rgb);
void ui_update(const UsageData* data);
void ui_tick_anim(void);
void ui_show_screen(screen_t screen);