From f291c616769e5c9acfb3b5d9053067d2cc0a712b Mon Sep 17 00:00:00 2001 From: agent Date: Fri, 24 Jul 2026 11:01:44 +0000 Subject: [PATCH] =?UTF-8?q?feat(ppclock):=20=E5=80=92=E8=AE=A1=E6=97=B6?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E6=96=87=E5=AD=97=E4=BD=8D=E5=9B=BE=EF=BC=88?= =?UTF-8?q?clock=20=E6=8B=86=E5=8C=85/calendar=20990=20=E5=BA=8F=E5=88=97?= =?UTF-8?q?=EF=BC=89+=20CLI=20--prefix/--show-calendar=EF=BC=8C102=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analysis/apk/decomp/z7_g0.java | 96 ++++++++++++++++++++++++++++++++++ analysis/apk/decomp/z7_h0.java | 74 ++++++++++++++++++++++++++ analysis/apk/decomp/z7_n0.java | 31 +++++++++++ src/ppclock/cli.py | 14 ++++- src/ppclock/commands.py | 17 +++++- src/ppclock/textbitmap.py | 65 +++++++++++++++++++++++ tests/test_textbitmap.py | 73 ++++++++++++++++++++++++++ 7 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 analysis/apk/decomp/z7_g0.java create mode 100644 analysis/apk/decomp/z7_h0.java create mode 100644 analysis/apk/decomp/z7_n0.java create mode 100644 src/ppclock/textbitmap.py create mode 100644 tests/test_textbitmap.py diff --git a/analysis/apk/decomp/z7_g0.java b/analysis/apk/decomp/z7_g0.java new file mode 100644 index 0000000..3c09b09 --- /dev/null +++ b/analysis/apk/decomp/z7_g0.java @@ -0,0 +1,96 @@ +package z7; +public final class g0 { + public final java.util.List a; + public final z7.n0 b; + + public g0(java.util.List p1, z7.n0 p2) + { + this.a = p1; + this.b = p2; + return; + } + + public final java.util.ArrayList a() + { + java.util.ArrayList v0_1 = new java.util.ArrayList(); + java.util.Iterator v1_1 = this.a.iterator(); + while (v1_1.hasNext()) { + Object v2_0 = v1_1.next(); + if (((z7.l0) v2_0).c == z7.m0.e) { + v0_1.add(v2_0); + } + } + return v0_1; + } + + public final java.util.ArrayList b() + { + java.util.ArrayList v0_1 = new java.util.ArrayList(); + java.util.Iterator v1_1 = this.a.iterator(); + while (v1_1.hasNext()) { + Object v2_0 = v1_1.next(); + if (((z7.l0) v2_0).c == z7.m0.c) { + v0_1.add(v2_0); + } + } + return v0_1; + } + + public final java.util.ArrayList c() + { + java.util.ArrayList v0_1 = new java.util.ArrayList(); + java.util.Iterator v1_1 = this.a.iterator(); + while (v1_1.hasNext()) { + Object v2_0 = v1_1.next(); + if (((z7.l0) v2_0).c == z7.m0.d) { + v0_1.add(v2_0); + } + } + return v0_1; + } + + public final boolean d() + { + if (this.b().size() != 0) { + return 0; + } else { + return 1; + } + } + + public final boolean equals(Object p5) + { + if (this != p5) { + if ((p5 instanceof z7.g0)) { + if (kotlin.jvm.internal.s.b(this.a, ((z7.g0) p5).a)) { + if (this.b == ((z7.g0) p5).b) { + return 1; + } else { + return 0; + } + } else { + return 0; + } + } else { + return 0; + } + } else { + return 1; + } + } + + public final int hashCode() + { + return (this.b.hashCode() + (this.a.hashCode() * 31)); + } + + public final String toString() + { + String v0_2 = new StringBuilder("ExecutionState(tasks="); + v0_2.append(this.a); + v0_2.append(", taskType="); + v0_2.append(this.b); + v0_2.append(")"); + return v0_2.toString(); + } +} diff --git a/analysis/apk/decomp/z7_h0.java b/analysis/apk/decomp/z7_h0.java new file mode 100644 index 0000000..32fa90d --- /dev/null +++ b/analysis/apk/decomp/z7_h0.java @@ -0,0 +1,74 @@ +package z7; +public final class h0 { + public final byte[] a; + public final int b; + public final int c; + + public h0(byte[] p4, int p5, int p6) + { + if ((p6 & 4) != 0) { + p5 = 20; + } + kotlin.jvm.internal.s.f(p4, "rawBytes"); + int v0_2 = 0; + int v1_1 = 0; + while (v0_2 < p4.length) { + v1_1 ^= (p4[v0_2] & 255); + v0_2++; + } + int v6_2 = ((byte) v1_1); + int v0_0 = p4.length; + byte[] v4_1 = java.util.Arrays.copyOf(p4, (v0_0 + 1)); + v4_1[v0_0] = v6_2; + this.a = v4_1; + int v6_4 = Math.max(240, p5); + if (v6_4 > v4_1.length) { + v6_4 = v4_1.length; + } + if (p5 > v6_4) { + p5 = v6_4; + } + this.c = p5; + this.b = v6_4; + return; + } + + public final java.util.ArrayList a(int p5) + { + java.util.ArrayList v0_0 = this.b; + byte[] v5_1 = (p5 * v0_0); + int v1_2 = this.a; + byte[] v5_2 = q6.n.k0(v1_2, v5_1, Math.min((v0_0 + v5_1), v1_2.length)); + java.util.ArrayList v0_1 = new java.util.ArrayList(); + int v1_0 = 0; + while (v1_0 < v5_2.length) { + int v2_2 = (this.c + v1_0); + v0_1.add(q6.n.k0(v5_2, v1_0, Math.min(v2_2, v5_2.length))); + v1_0 = v2_2; + } + return v0_1; + } + + public final String b() + { + String v1_0 = this.a; + kotlin.jvm.internal.s.f(v1_0, ""); + if (v1_0.length == 0) { + throw new java.util.NoSuchElementException("Array is empty."); + } else { + java.util.NoSuchElementException v0_3 = (v1_0[(v1_0.length - 1)] & 255); + c7.a.l(16); + java.util.NoSuchElementException v0_4 = Integer.toString(v0_3, 16); + kotlin.jvm.internal.s.e(v0_4, "toString(...)"); + java.util.NoSuchElementException v0_5 = v0_4.toUpperCase(java.util.Locale.ROOT); + kotlin.jvm.internal.s.e(v0_5, "toUpperCase(...)"); + return p.p1.j("0x", i7.i.B0(2, v0_5)); + } + } + + public final int c() + { + int v1 = this.b; + return (((this.a.length + v1) - 1) / v1); + } +} diff --git a/analysis/apk/decomp/z7_n0.java b/analysis/apk/decomp/z7_n0.java new file mode 100644 index 0000000..07b56e6 --- /dev/null +++ b/analysis/apk/decomp/z7_n0.java @@ -0,0 +1,31 @@ +package z7; +public final enum class n0 extends java.lang.Enum { + public static final enum z7.n0 c; + public static final enum z7.n0 d; + public static final enum z7.n0 e; + public static final synthetic z7.n0[] f; + + static n0() + { + z7.n0[] v0_2 = new z7.n0("OTA", 0); + z7.n0.c = v0_2; + z7.n0 v1_2 = new z7.n0("CMD", 1); + z7.n0.d = v1_2; + z7.n0 v2_1 = new z7.n0("ACTIVATE", 2); + z7.n0.e = v2_1; + z7.n0[] v0_1 = new z7.n0[] {v0_2, v1_2, v2_1}); + z7.n0.f = v0_1; + u3.a.a(v0_1); + return; + } + + public static z7.n0 valueOf(String p1) + { + return ((z7.n0) Enum.valueOf(z7.n0, p1)); + } + + public static z7.n0[] values() + { + return ((z7.n0[]) z7.n0.f.clone()); + } +} diff --git a/src/ppclock/cli.py b/src/ppclock/cli.py index 75e217a..ab85501 100644 --- a/src/ppclock/cli.py +++ b/src/ppclock/cli.py @@ -126,6 +126,9 @@ def build_parser() -> argparse.ArgumentParser: sp = sub.add_parser("countdown", help="倒计时") sp.add_argument("date", nargs="?", help="YYYY-MM-DD") sp.add_argument("--mode", default="clock", choices=["clock", "calendar"]) + sp.add_argument("--prefix", help="前缀文字(时钟≤8单位/日历≤4字)") + sp.add_argument("--show-calendar", action="store_true", + help="设置日历中文字并切日历模式一(upload_rili 序列)") sp.add_argument("--off", action="store_true") sp = sub.add_parser("sleep", help="休眠时段") @@ -190,11 +193,18 @@ async def _dispatch(args) -> dict | None: if args.off: await C.countdown_off(t) return {"countdown": "off"} + if args.show_calendar: + if not args.prefix: + raise ValueError("--show-calendar 需要 --prefix 提供日历文字") + await C.countdown_set(t, None, args.mode, prefix=args.prefix, + show_calendar=True) + return {"calendar_text": args.prefix} if not args.date: raise ValueError("缺少日期(YYYY-MM-DD)或 --off") d = datetime.date.fromisoformat(args.date) - await C.countdown_set(t, d, args.mode) - return {"date": d.isoformat(), "mode": args.mode} + await C.countdown_set(t, d, args.mode, prefix=args.prefix) + return {"date": d.isoformat(), "mode": args.mode, + "prefix": args.prefix} if c == "sleep": on = args.on or not args.off await C.sleep(t, on, args.start, args.end) diff --git a/src/ppclock/commands.py b/src/ppclock/commands.py index 14ecf73..53d2b96 100644 --- a/src/ppclock/commands.py +++ b/src/ppclock/commands.py @@ -51,7 +51,22 @@ async def upload_image(t, bw: bytes, red: bytes | None, *, small: bool, await t.write_epd(P.end_frame(small=small)) -async def countdown_set(t, d: datetime.date, mode: str): +async def countdown_set(t, d: datetime.date, mode: str, prefix: str | None = None, + show_calendar: bool = False): + """倒计时:前缀文字位图(可选)→ 日期帧 → E2。 + show_calendar=True 走 upload_rili 序列:990* → FA → e10103 → E2(timedjs2.js:308-312)。""" + from . import textbitmap as TB + if prefix: + for frame in TB.prefix_frames(prefix, "calendar" if show_calendar else mode): + await t.write_rxtx(frame) + await t.delay(1.0) + if show_calendar: + await t.write_rxtx(bytes([0xFA])) + await t.delay(1.0) + await t.write_rxtx(P.mode_frame("calendar1")) + await t.delay(1.0) + await t.write_rxtx(P.simple_command("commit")) + return await t.write_rxtx(P.countdown_date_frame(d, mode)) await t.delay(1.0) await t.write_rxtx(P.simple_command("commit")) diff --git a/src/ppclock/textbitmap.py b/src/ppclock/textbitmap.py new file mode 100644 index 0000000..40213b9 --- /dev/null +++ b/src/ppclock/textbitmap.py @@ -0,0 +1,65 @@ +"""倒计时前缀文字位图(证据 analysis/web/timedjs2.js:180-236)。 +与 JS 差异:JS 帧有 off-by-one(丢末字节),本实现发完整位图(设备缓冲兼容),见 DECISIONS.md。 +""" +from __future__ import annotations + +from PIL import Image, ImageDraw + +from .templates import _font + +GRID_CLOCK = 16 # 时钟界面字格 16px,≤8 单位(中文≤4+英文),>4 拆两包 +GRID_CALENDAR = 24 # 日历界面字格 24px,≤4 单位,每字一包 + + +def char_units(text: str) -> float: + """中文=1 单位,其他=0.5(timedjs2.js charCount 语义)。""" + return sum(1.0 if ord(ch) > 0x2E7F else 0.5 for ch in text) + + +def render_text_bitmap(text: str, grid: int) -> bytes: + """单行文本 → 1bpp MSB-first 行主序位图。宽度=units*grid,高度=grid。""" + if grid not in (GRID_CLOCK, GRID_CALENDAR): + raise ValueError("grid 必须是 16 或 24") + units = char_units(text) + if units != int(units): + raise ValueError("中英混排产生 0.5 单位,JS 端会生成非法帧,CLI 拒绝") + w, h = int(units) * grid, grid + if w == 0: + raise ValueError("空文本") + img = Image.new("L", (w, h), 0) + d = ImageDraw.Draw(img) + font = _font(int(grid * 0.875)) # 16px格→14px 字 + d.text((w // 2, h // 2), text, font=font, fill=255, anchor="mm") + px = img.load() + out = bytearray() + for y in range(h): + for xb in range(w // 8): + byte = 0 + for bit in range(8): + if px[xb * 8 + bit, y] > 128: + byte |= 1 << (7 - bit) + out.append(byte) + return bytes(out) + + +def prefix_frames(text: str, mode: str) -> list[bytes]: + """前缀文字 → 帧列表。 + clock: ≤4 单位单包 0{n}+位图;>4 两包 04+前128B / 0{n}+余量。 + calendar: 每字一包 990{j}+72B。""" + units = char_units(text) + if units != int(units): + raise ValueError("中英混排产生 0.5 单位,不支持") + n = int(units) + if mode == "clock": + if n > 8: + raise ValueError("时钟界面最多 8 单位(纯中文 4 字)") + bmp = render_text_bitmap(text, GRID_CLOCK) + if n <= 4: + return [bytes([n]) + bmp] + return [bytes([0x04]) + bmp[:128], bytes([n]) + bmp[128:]] + if mode == "calendar": + if n > 4: + raise ValueError("日历界面最多 4 字") + bmp = render_text_bitmap(text, GRID_CALENDAR) + return [bytes([0x99, j]) + bmp[j * 72:(j + 1) * 72] for j in range(n)] + raise ValueError("mode 必须是 clock 或 calendar") diff --git a/tests/test_textbitmap.py b/tests/test_textbitmap.py new file mode 100644 index 0000000..57d1349 --- /dev/null +++ b/tests/test_textbitmap.py @@ -0,0 +1,73 @@ +"""倒计时前缀文字位图测试(证据 analysis/web/timedjs2.js:180-236)。""" +import pytest + +from ppclock import textbitmap as TB + + +class TestCharUnits: + def test_chinese_counts_one(self): + assert TB.char_units("距离") == 2.0 + + def test_english_counts_half(self): + assert TB.char_units("ab") == 1.0 + assert TB.char_units("ab中") == 2.0 + assert TB.char_units("a中") == 1.5 + + +class TestBitmap: + def test_clock_bitmap_size(self): + """16px 格:n 个单位 → n*32 字节""" + bmp = TB.render_text_bitmap("中考", grid=16) + assert len(bmp) == 2 * 32 + + def test_calendar_bitmap_size(self): + """24px 格:n 个单位 → n*72 字节""" + bmp = TB.render_text_bitmap("中考", grid=24) + assert len(bmp) == 2 * 72 + + def test_bitmap_not_blank(self): + bmp = TB.render_text_bitmap("试", grid=16) + assert any(bmp) + + def test_grid_must_be_16_or_24(self): + with pytest.raises(ValueError): + TB.render_text_bitmap("x", grid=12) + + +class TestFrames: + def test_clock_single_pack(self): + """≤4 单位:单包 0{n}+位图""" + frames = TB.prefix_frames("目标", "clock") + assert len(frames) == 1 + assert frames[0][0:1] == bytes([0x02]) # '0'+2 + assert len(frames[0]) == 1 + 2 * 32 + + def test_clock_two_packs(self): + """>4 单位:首包 04+128B,次包 0{n}+余量""" + frames = TB.prefix_frames("距离中考还有", "clock") # 6 单位 + assert len(frames) == 2 + assert frames[0][0] == 0x04 + assert len(frames[0]) == 1 + 128 + assert frames[1][0] == 0x06 + assert len(frames[1]) == 1 + 2 * 32 + + def test_clock_over_8_units_rejected(self): + with pytest.raises(ValueError): + TB.prefix_frames("一二三四五六七八九", "clock") + + def test_calendar_per_char_pack(self): + """日历模式:每字一包 990{j}+72B""" + frames = TB.prefix_frames("中考", "calendar") + assert len(frames) == 2 + assert frames[0][:2] == bytes([0x99, 0x00]) + assert len(frames[0]) == 2 + 72 + assert frames[1][:2] == bytes([0x99, 0x01]) + + def test_calendar_over_4_units_rejected(self): + with pytest.raises(ValueError): + TB.prefix_frames("一二三四五", "calendar") + + def test_fractional_units_rejected(self): + """0.5 单位(英文混排)JS 会产生非法 hex——CLI 拒绝""" + with pytest.raises(ValueError): + TB.prefix_frames("a中", "clock")