v2 Phase 5: Now Playing — Windows media session on the watch
The daemon reads the Windows "now playing" media session (WinRT SMTC) and
adds np/nt/na to the BLE payload; the watch shows a play/pause glyph, a
scrolling title, artist and status. Best-effort and token-independent, so
it works even when the rate-limit data is unavailable.
Cyrillic titles: Styrene B has no Cyrillic glyphs, so the title/artist use
new composite fonts (font_styrene_cyr_{28,20}) — Latin from Styrene B,
Cyrillic + typographic punctuation from Montserrat, merged by lv_font_conv.
Latin titles stay on-brand; only Cyrillic falls back to Montserrat.
- daemon: read_now_playing() via winrt-Windows.Media.Control (lazy import,
never raises; PLAYING->1, PAUSED->2, else 0; title/artist truncated,
empty fields omitted)
- firmware: UsageData np_state/np_title/np_artist + parser; the real Now
Playing screen replaces the shared stub (SCREEN_NOWPLAYING)
- fonts: assets/Montserrat-Medium.ttf, font_styrene_cyr_{28,20}.c
- tools: patch_lvgl9_font.py (automates the 4 LVGL 9 font patches),
screenshot_win.py (Windows serial screenshot via pyserial + Pillow)
- README: document Cyrillic composite font generation
Verified on hardware (waveshare_amoled_206): idle state, a live Latin
title (scrolling), and Cyrillic (Prohozhdenie / Dunduk) all render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -289,6 +289,28 @@ to preserve the brand font — Chinese text in those slots renders as
|
|||||||
empty boxes. Add a `font_cjk_28.c` if full coverage is needed (~1MB
|
empty boxes. Add a `font_cjk_28.c` if full coverage is needed (~1MB
|
||||||
more flash).
|
more flash).
|
||||||
|
|
||||||
|
### Cyrillic (Now Playing titles)
|
||||||
|
|
||||||
|
`firmware/src/font_styrene_cyr_{28,20}.c` back the Now Playing screen's
|
||||||
|
title/artist, where user content (media titles) can be non-Latin. Styrene B
|
||||||
|
has no Cyrillic glyphs, so these are **composite** fonts: Latin from Styrene B,
|
||||||
|
Cyrillic + typographic punctuation (– — ' ' " " …) from Montserrat. `lv_font_conv`
|
||||||
|
merges multiple `--font`/`-r` groups into one font — a pure-Latin title stays
|
||||||
|
on-brand, only Cyrillic falls back to Montserrat.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for size in 28 20; do
|
||||||
|
lv_font_conv \
|
||||||
|
--font assets/StyreneB-Regular.otf -r 0x20-0x7E \
|
||||||
|
--font assets/Montserrat-Medium.ttf -r '0x2013,0x2014,0x2018,0x2019,0x201C,0x201D,0x2026,0x400-0x45F' \
|
||||||
|
--size $size --format lvgl --bpp 4 --no-compress \
|
||||||
|
-o firmware/src/font_styrene_cyr_${size}.c --lv-include lvgl.h
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Then apply the four LVGL 9 patches above — `tools/patch_lvgl9_font.py <file...>` does
|
||||||
|
all four automatically.
|
||||||
|
|
||||||
## Converting Lucide icons
|
## Converting Lucide icons
|
||||||
|
|
||||||
The UI uses a small set of [Lucide](https://lucide.dev) icons (bluetooth + battery states) converted to RGB565 / RGB565A8 C arrays for LVGL.
|
The UI uses a small set of [Lucide](https://lucide.dev) icons (bluetooth + battery states) converted to RGB565 / RGB565A8 C arrays for LVGL.
|
||||||
|
|||||||
Binary file not shown.
@@ -527,6 +527,58 @@ async def refresh_token_if_needed(force: bool = False) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Now Playing (Phase 5) ----------------------------------------------------
|
||||||
|
# Best-effort read of the Windows "now playing" media session (System Media
|
||||||
|
# Transport Controls) so the watch can show the current track. Pure local WinRT —
|
||||||
|
# no network, no token — so it works even when the rate-limit data is unavailable.
|
||||||
|
# Any failure degrades to {"np": 0} (nothing playing) and never disturbs the loop.
|
||||||
|
NP_MAX_LEN = 60 # truncate title/artist so the BLE payload stays comfortably small
|
||||||
|
|
||||||
|
|
||||||
|
async def read_now_playing() -> dict:
|
||||||
|
"""Return compact now-playing fields for the BLE payload::
|
||||||
|
|
||||||
|
{"np": 0|1|2, "nt": <title>, "na": <artist>}
|
||||||
|
|
||||||
|
np: 0 = nothing playing, 1 = playing, 2 = paused. nt/na are omitted when
|
||||||
|
empty. Never raises — the daemon must keep polling even if WinRT hiccups
|
||||||
|
(and the winrt media package may simply be absent on some installs).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from winrt.windows.media.control import (
|
||||||
|
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
|
||||||
|
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PB,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
return {"np": 0}
|
||||||
|
|
||||||
|
try:
|
||||||
|
mgr = await MediaManager.request_async()
|
||||||
|
sess = mgr.get_current_session()
|
||||||
|
if sess is None:
|
||||||
|
return {"np": 0}
|
||||||
|
status = sess.get_playback_info().playback_status
|
||||||
|
if status == PB.PLAYING:
|
||||||
|
np = 1
|
||||||
|
elif status == PB.PAUSED:
|
||||||
|
np = 2
|
||||||
|
else:
|
||||||
|
np = 0 # stopped / closed / changing -> treat as nothing playing
|
||||||
|
out: dict = {"np": np}
|
||||||
|
if np:
|
||||||
|
info = await sess.try_get_media_properties_async()
|
||||||
|
title = (info.title or "").strip()
|
||||||
|
artist = (info.artist or "").strip()
|
||||||
|
if title:
|
||||||
|
out["nt"] = title[:NP_MAX_LEN]
|
||||||
|
if artist:
|
||||||
|
out["na"] = artist[:NP_MAX_LEN]
|
||||||
|
return out
|
||||||
|
except Exception as e: # WinRT can surface assorted OSError/RuntimeError types
|
||||||
|
log(f"Now-playing read failed: {e!r}")
|
||||||
|
return {"np": 0}
|
||||||
|
|
||||||
|
|
||||||
async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
|
async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
|
||||||
"""Return when any of `events` is set, or after `timeout` seconds.
|
"""Return when any of `events` is set, or after `timeout` seconds.
|
||||||
|
|
||||||
@@ -655,6 +707,13 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
|||||||
else:
|
else:
|
||||||
payload["ok"] = False # rate-limit unknown -> watch usage view goes idle
|
payload["ok"] = False # rate-limit unknown -> watch usage view goes idle
|
||||||
|
|
||||||
|
# Now Playing (Phase 5) — best-effort Windows media session. Local
|
||||||
|
# only, so it's attached regardless of the rate-limit "ok" above.
|
||||||
|
try:
|
||||||
|
payload.update(await read_now_playing())
|
||||||
|
except Exception as e: # never let media reading break the poll loop
|
||||||
|
log(f"Now-playing skipped: {e!r}")
|
||||||
|
|
||||||
if await session.write_payload(payload):
|
if await session.write_payload(payload):
|
||||||
last_poll = time.time()
|
last_poll = time.time()
|
||||||
used_successfully = True
|
used_successfully = True
|
||||||
|
|||||||
@@ -16,4 +16,9 @@ struct UsageData {
|
|||||||
long long output_today; // output tokens today (what Claude generated)
|
long long output_today; // output tokens today (what Claude generated)
|
||||||
int cost_cents_today; // equivalent API cost today, US cents
|
int cost_cents_today; // equivalent API cost today, US cents
|
||||||
int messages_today; // assistant requests (API turns) today
|
int messages_today; // assistant requests (API turns) today
|
||||||
|
|
||||||
|
// Now Playing (Phase 5 — daemon relays the Windows media session).
|
||||||
|
int np_state; // 0 = nothing playing, 1 = playing, 2 = paused
|
||||||
|
char np_title[64]; // current track title (UTF-8, may be empty)
|
||||||
|
char np_artist[64]; // current track artist (UTF-8, may be empty)
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -116,6 +116,9 @@ static bool parse_json(const char* json, UsageData* out) {
|
|||||||
out->output_today = doc["to"] | (long long)0;
|
out->output_today = doc["to"] | (long long)0;
|
||||||
out->cost_cents_today = doc["tc"] | 0;
|
out->cost_cents_today = doc["tc"] | 0;
|
||||||
out->messages_today = doc["tn"] | 0;
|
out->messages_today = doc["tn"] | 0;
|
||||||
|
out->np_state = doc["np"] | 0;
|
||||||
|
strlcpy(out->np_title, doc["nt"] | "", sizeof(out->np_title));
|
||||||
|
strlcpy(out->np_artist, doc["na"] | "", sizeof(out->np_artist));
|
||||||
out->valid = true;
|
out->valid = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-1
@@ -14,6 +14,10 @@ LV_FONT_DECLARE(font_styrene_24);
|
|||||||
LV_FONT_DECLARE(font_styrene_20);
|
LV_FONT_DECLARE(font_styrene_20);
|
||||||
LV_FONT_DECLARE(font_styrene_16);
|
LV_FONT_DECLARE(font_styrene_16);
|
||||||
LV_FONT_DECLARE(font_styrene_14);
|
LV_FONT_DECLARE(font_styrene_14);
|
||||||
|
// Composite Styrene (Latin) + Montserrat (Cyrillic) — used where user content
|
||||||
|
// (media titles) can be non-Latin; brand Styrene has no Cyrillic glyphs.
|
||||||
|
LV_FONT_DECLARE(font_styrene_cyr_28);
|
||||||
|
LV_FONT_DECLARE(font_styrene_cyr_20);
|
||||||
LV_FONT_DECLARE(font_mono_32);
|
LV_FONT_DECLARE(font_mono_32);
|
||||||
|
|
||||||
// Layout values computed from the active board's geometry. Populated once
|
// Layout values computed from the active board's geometry. Populated once
|
||||||
@@ -134,6 +138,11 @@ static lv_obj_t* sess_cost_lbl;
|
|||||||
static lv_obj_t* sess_tokens_lbl;
|
static lv_obj_t* sess_tokens_lbl;
|
||||||
static lv_obj_t* sess_gen_lbl;
|
static lv_obj_t* sess_gen_lbl;
|
||||||
static lv_obj_t* sess_msgs_lbl;
|
static lv_obj_t* sess_msgs_lbl;
|
||||||
|
static lv_obj_t* nowplaying_container; // media now-playing (Phase 5)
|
||||||
|
static lv_obj_t* np_icon_lbl; // play / pause / music glyph
|
||||||
|
static lv_obj_t* np_title_lbl; // track title (scrolls if long)
|
||||||
|
static lv_obj_t* np_artist_lbl; // track artist
|
||||||
|
static lv_obj_t* np_status_lbl; // "Playing" / "Paused"
|
||||||
|
|
||||||
// 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
|
||||||
@@ -734,6 +743,53 @@ static void init_session_screen(lv_obj_t* scr) {
|
|||||||
lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now Playing: the Windows media session relayed by the daemon. A play/pause glyph
|
||||||
|
// and status line reflect playback; the title scrolls if it overflows. Independent
|
||||||
|
// of rate-limit data, so it stays live even when the OAuth token is unavailable.
|
||||||
|
static void init_nowplaying_screen(lv_obj_t* scr) {
|
||||||
|
nowplaying_container = lv_obj_create(scr);
|
||||||
|
lv_obj_set_size(nowplaying_container, L.scr_w, L.scr_h);
|
||||||
|
lv_obj_set_pos(nowplaying_container, 0, 0);
|
||||||
|
lv_obj_set_style_bg_opa(nowplaying_container, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(nowplaying_container, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(nowplaying_container, 0, 0);
|
||||||
|
lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
make_screen_title(nowplaying_container, "Now Playing");
|
||||||
|
|
||||||
|
np_icon_lbl = lv_label_create(nowplaying_container);
|
||||||
|
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
|
||||||
|
lv_obj_set_style_text_font(np_icon_lbl, &lv_font_montserrat_28, 0);
|
||||||
|
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
|
||||||
|
lv_obj_align(np_icon_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 35);
|
||||||
|
|
||||||
|
np_title_lbl = lv_label_create(nowplaying_container);
|
||||||
|
lv_obj_set_width(np_title_lbl, L.content_w);
|
||||||
|
lv_label_set_long_mode(np_title_lbl, LV_LABEL_LONG_SCROLL_CIRCULAR);
|
||||||
|
lv_obj_set_style_text_align(np_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
lv_obj_set_style_text_font(np_title_lbl, &font_styrene_cyr_28, 0);
|
||||||
|
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
|
||||||
|
lv_label_set_text(np_title_lbl, "Nothing playing");
|
||||||
|
lv_obj_align(np_title_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 120);
|
||||||
|
|
||||||
|
np_artist_lbl = lv_label_create(nowplaying_container);
|
||||||
|
lv_obj_set_width(np_artist_lbl, L.content_w);
|
||||||
|
lv_label_set_long_mode(np_artist_lbl, LV_LABEL_LONG_DOT);
|
||||||
|
lv_obj_set_style_text_align(np_artist_lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
lv_obj_set_style_text_font(np_artist_lbl, &font_styrene_cyr_20, 0);
|
||||||
|
lv_obj_set_style_text_color(np_artist_lbl, COL_DIM, 0);
|
||||||
|
lv_label_set_text(np_artist_lbl, "");
|
||||||
|
lv_obj_align(np_artist_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 170);
|
||||||
|
|
||||||
|
np_status_lbl = lv_label_create(nowplaying_container);
|
||||||
|
lv_obj_set_style_text_font(np_status_lbl, &font_styrene_16, 0);
|
||||||
|
lv_obj_set_style_text_color(np_status_lbl, COL_DIM, 0);
|
||||||
|
lv_label_set_text(np_status_lbl, "");
|
||||||
|
lv_obj_align(np_status_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 215);
|
||||||
|
|
||||||
|
lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
// ======== Public API ========
|
// ======== Public API ========
|
||||||
|
|
||||||
void ui_init(void) {
|
void ui_init(void) {
|
||||||
@@ -759,6 +815,7 @@ void ui_init(void) {
|
|||||||
init_bt_screen(scr);
|
init_bt_screen(scr);
|
||||||
init_soundpad_screen(scr);
|
init_soundpad_screen(scr);
|
||||||
init_session_screen(scr);
|
init_session_screen(scr);
|
||||||
|
init_nowplaying_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);
|
||||||
@@ -801,6 +858,27 @@ void ui_update(const UsageData* data) {
|
|||||||
lv_label_set_text_fmt(sess_msgs_lbl, "%d requests", data->messages_today);
|
lv_label_set_text_fmt(sess_msgs_lbl, "%d requests", data->messages_today);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now Playing — local media session relayed by the daemon. Like Session, it's
|
||||||
|
// independent of the rate-limit data, so refresh it before the ok==false bail.
|
||||||
|
if (nowplaying_container) {
|
||||||
|
if (data->np_state == 0) {
|
||||||
|
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
|
||||||
|
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
|
||||||
|
lv_label_set_text(np_title_lbl, "Nothing playing");
|
||||||
|
lv_obj_set_style_text_color(np_title_lbl, COL_DIM, 0);
|
||||||
|
lv_label_set_text(np_artist_lbl, "");
|
||||||
|
lv_label_set_text(np_status_lbl, "");
|
||||||
|
} else {
|
||||||
|
bool playing = (data->np_state == 1);
|
||||||
|
lv_label_set_text(np_icon_lbl, playing ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE);
|
||||||
|
lv_obj_set_style_text_color(np_icon_lbl, COL_ACCENT, 0);
|
||||||
|
lv_label_set_text(np_title_lbl, data->np_title[0] ? data->np_title : "(no title)");
|
||||||
|
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
|
||||||
|
lv_label_set_text(np_artist_lbl, data->np_artist);
|
||||||
|
lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rate-limit utilization (5h / 7d). The daemon sets ok=false when this data
|
// Rate-limit utilization (5h / 7d). The daemon sets ok=false when this data
|
||||||
// is unavailable (expired OAuth token, API down). Skip the bars and DON'T
|
// is unavailable (expired OAuth token, API down). Skip the bars and DON'T
|
||||||
// bump the freshness clock then, so the usage view falls back to its idle
|
// bump the freshness clock then, so the usage view falls back to its idle
|
||||||
@@ -934,6 +1012,7 @@ void ui_show_screen(screen_t screen) {
|
|||||||
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);
|
if (session_container) lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (nowplaying_container) lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
splash_hide();
|
splash_hide();
|
||||||
|
|
||||||
switch (screen) {
|
switch (screen) {
|
||||||
@@ -942,8 +1021,8 @@ void ui_show_screen(screen_t screen) {
|
|||||||
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_SESSION: lv_obj_clear_flag(session_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||||
|
case SCREEN_NOWPLAYING: lv_obj_clear_flag(nowplaying_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_NOWPLAYING:
|
|
||||||
case SCREEN_HOMEASSIST:
|
case SCREEN_HOMEASSIST:
|
||||||
stub_show_for(screen);
|
stub_show_for(screen);
|
||||||
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Patch lv_font_conv output for LVGL 9.
|
||||||
|
|
||||||
|
`lv_font_conv` emits font .c files wrapped in `#if LVGL_VERSION_MAJOR` guards and
|
||||||
|
(for v8) a `.cache` field. On LVGL 9 the project wants the plain, unguarded struct
|
||||||
|
with `.release_glyph` / `.kerning` / `.static_bitmap` present (see the "LVGL 9 font
|
||||||
|
patching" note in README.md). Without this the font compiles but renders invisible.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/patch_lvgl9_font.py firmware/src/font_foo.c [more.c ...]
|
||||||
|
|
||||||
|
Idempotent: running it on an already-patched file is a no-op.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SUBS = [
|
||||||
|
# drop the v8-only glyph cache declaration
|
||||||
|
(r'#if LVGL_VERSION_MAJOR == 8\n/\*Store all the custom data of the font\*/\n'
|
||||||
|
r'static lv_font_fmt_txt_glyph_cache_t cache;\n#endif\n\n', ''),
|
||||||
|
# collapse the font_dsc storage-class guard
|
||||||
|
(r'#if LVGL_VERSION_MAJOR >= 8\nstatic const lv_font_fmt_txt_dsc_t font_dsc = \{\n'
|
||||||
|
r'#else\nstatic lv_font_fmt_txt_dsc_t font_dsc = \{\n#endif\n',
|
||||||
|
'static const lv_font_fmt_txt_dsc_t font_dsc = {\n'),
|
||||||
|
# drop the v8 .cache member
|
||||||
|
(r'#if LVGL_VERSION_MAJOR == 8\n \.cache = &cache\n#endif\n', ''),
|
||||||
|
# collapse the public-font const guard
|
||||||
|
(r'#if LVGL_VERSION_MAJOR >= 8\n(const lv_font_t \w+ = \{)\n'
|
||||||
|
r'#else\nlv_font_t \w+ = \{\n#endif\n', r'\1\n'),
|
||||||
|
# keep .subpx and add the three LVGL 9 fields
|
||||||
|
(r'#if !\(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0\)\n'
|
||||||
|
r' \.subpx = LV_FONT_SUBPX_NONE,\n#endif\n',
|
||||||
|
' .subpx = LV_FONT_SUBPX_NONE,\n .release_glyph = NULL,\n'
|
||||||
|
' .kerning = 0,\n .static_bitmap = 0,\n'),
|
||||||
|
# unwrap underline fields
|
||||||
|
(r'#if LV_VERSION_CHECK\(7, 4, 0\) \|\| LVGL_VERSION_MAJOR >= 8\n'
|
||||||
|
r'( \.underline_position = -1,\n \.underline_thickness = 2,\n)#endif\n', r'\1'),
|
||||||
|
# unwrap .fallback
|
||||||
|
(r'#if LV_VERSION_CHECK\(8, 2, 0\) \|\| LVGL_VERSION_MAJOR >= 9\n'
|
||||||
|
r'( \.fallback = NULL,\n)#endif\n', r'\1'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def patch(path: str) -> None:
|
||||||
|
s = open(path, encoding="utf-8").read()
|
||||||
|
for pat, repl in SUBS:
|
||||||
|
s = re.sub(pat, repl, s)
|
||||||
|
open(path, "w", encoding="utf-8").write(s)
|
||||||
|
leftover = len(re.findall(r'LVGL_VERSION_MAJOR|LV_VERSION_CHECK|\.cache = &cache', s))
|
||||||
|
print(f"patched {path} leftover_guards={leftover}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
for p in sys.argv[1:]:
|
||||||
|
patch(p)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture a screenshot from the watch over USB serial (Windows).
|
||||||
|
|
||||||
|
The Unix `screenshot.sh` shells out to ffmpeg; this is a self-contained Windows
|
||||||
|
equivalent that needs only pyserial + Pillow (both in the daemon venv). The
|
||||||
|
firmware's `screenshot` serial command dumps the LVGL framebuffer as raw RGB565LE
|
||||||
|
between `SCREENSHOT_START w h size` and `SCREENSHOT_END`.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/screenshot_win.py [COM_PORT] [out.png] (defaults: COM7 shot.png)
|
||||||
|
"""
|
||||||
|
import array
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import serial
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
port_name = sys.argv[1] if len(sys.argv) > 1 else "COM7"
|
||||||
|
out = sys.argv[2] if len(sys.argv) > 2 else "shot.png"
|
||||||
|
|
||||||
|
port = serial.Serial(port_name, 115200, timeout=3)
|
||||||
|
time.sleep(0.3)
|
||||||
|
port.reset_input_buffer()
|
||||||
|
port.write(b"screenshot\n")
|
||||||
|
port.flush()
|
||||||
|
|
||||||
|
w = h = raw_size = 0
|
||||||
|
deadline = time.time() + 15
|
||||||
|
while time.time() < deadline:
|
||||||
|
line = port.readline().decode("utf-8", errors="replace").strip()
|
||||||
|
if line.startswith("SCREENSHOT_START"):
|
||||||
|
_, sw, sh, ss = line.split()
|
||||||
|
w, h, raw_size = int(sw), int(sh), int(ss)
|
||||||
|
break
|
||||||
|
if line in ("SCREENSHOT_ERR", "SCREENSHOT_UNSUPPORTED"):
|
||||||
|
sys.exit(f"device error: {line}")
|
||||||
|
if not raw_size:
|
||||||
|
sys.exit("no SCREENSHOT_START (is the device booted and on COM port?)")
|
||||||
|
|
||||||
|
data = b""
|
||||||
|
while len(data) < raw_size:
|
||||||
|
chunk = port.read(min(8192, raw_size - len(data)))
|
||||||
|
if not chunk:
|
||||||
|
sys.exit(f"timeout: got {len(data)} of {raw_size} bytes")
|
||||||
|
data += chunk
|
||||||
|
port.close()
|
||||||
|
|
||||||
|
# RGB565 little-endian -> RGB888. array 'H' is native (LE on x86) == RGB565LE.
|
||||||
|
px = array.array("H")
|
||||||
|
px.frombytes(data)
|
||||||
|
rgb = bytearray(len(px) * 3)
|
||||||
|
for i, v in enumerate(px):
|
||||||
|
r = (v >> 11) & 0x1F
|
||||||
|
g = (v >> 5) & 0x3F
|
||||||
|
b = v & 0x1F
|
||||||
|
rgb[i * 3] = (r << 3) | (r >> 2)
|
||||||
|
rgb[i * 3 + 1] = (g << 2) | (g >> 4)
|
||||||
|
rgb[i * 3 + 2] = (b << 3) | (b >> 2)
|
||||||
|
Image.frombytes("RGB", (w, h), bytes(rgb)).save(out)
|
||||||
|
print(f"saved {out} {w}x{h} ({len(data)} bytes)")
|
||||||
Reference in New Issue
Block a user