diff --git a/s4e-charge/assets/cover.png b/s4e-charge/assets/cover.png index 23c1cf8..332e427 100644 Binary files a/s4e-charge/assets/cover.png and b/s4e-charge/assets/cover.png differ diff --git a/s4e-charge/dist/S4Chg.face b/s4e-charge/dist/S4-Pt3.face similarity index 99% rename from s4e-charge/dist/S4Chg.face rename to s4e-charge/dist/S4-Pt3.face index 0b633f5..7a96d0f 100644 Binary files a/s4e-charge/dist/S4Chg.face and b/s4e-charge/dist/S4-Pt3.face differ diff --git a/s4e-charge/dist/S4ChgRestore.face b/s4e-charge/dist/S4ChgRestore.face index fc71083..16320a7 100644 Binary files a/s4e-charge/dist/S4ChgRestore.face and b/s4e-charge/dist/S4ChgRestore.face differ diff --git a/s4e-charge/docs/logo_preview.png b/s4e-charge/docs/logo_preview.png new file mode 100644 index 0000000..8f23708 Binary files /dev/null and b/s4e-charge/docs/logo_preview.png differ diff --git a/s4e-charge/main.lua b/s4e-charge/main.lua index b4687d5..b62ba30 100644 --- a/s4e-charge/main.lua +++ b/s4e-charge/main.lua @@ -557,7 +557,14 @@ local function buildHome() local p = newPage() if not p then return nil end clockLabel(p) - pageTitle(p, ROLE == 'restore' and 'S4Chg还原' or 'S4Chg', 159, 158) + -- 盘内标题 = 显示名(不再用内部代号 S4Chg);x 按真 MiSans 30px 量出的宽度居中: + -- 'S4e Pt.3' = 123 -> x = (466-123)/2 = 171.5 -> 172 + -- 'S4e Pt.3 · 还原' = 220 -> x = (466-220)/2 = 123 + if ROLE == 'restore' then + pageTitle(p, 'S4e Pt.3 · 还原', 123, 222) + else + pageTitle(p, 'S4e Pt.3', 172, 125) + end listItem(p, 85, 'ic_help', '快速帮助', function() show(pages.help) end) listItem(p, 181, 'ic_replace', ROLE == 'restore' and '开始还原' or '开始替换', function() pendingVariant = 'replace' @@ -577,22 +584,30 @@ local function buildHelp() setClickable(t) bind(t, function() show(pages.home) end) - listItem(p, 85, 'ic_check', '环境预检查', function() doCheck() end) - desc1 = lab(p, { x = 65, y = 176, w = 338, h = 72, + -- 竖排 4 个元素的 y:76 / 166 / 236 / 326。 + -- 原来 85 / 176 / 256 / 344 时,末尾那句 2 行块的**第 2 行**(y 377..410)已经越过圆边 + -- (仓库外 check_clip_real.py 实测:replace 盘 47 越界像素,s4e-PAnim 甚至 229 像素), + -- 因为整页内容 88+66+88+66=308px,而 466 圆在 x=65 处最多只给到 y<=394。 + -- 4 个元素整体上移 9~22px 后:整页 replace/restore 都是 0 越界、0 溢框(最远像素 228.2 < 233)。 + listItem(p, 76, 'ic_check', '环境预检查', function() doCheck() end) + desc1 = lab(p, { x = 65, y = 166, w = 338, h = 72, text = '检查原始资源状态、分区可写情况等。', text_color = C_GRAY, font_size = 24, text_font = F_BODY }) -- 拆盘之后:本盘只装一份负载,「回滚/恢复」由另一份盘负责 - listItem(p, 256, 'ic_rollback', ROLE == 'restore' and '恢复修改' or '回滚修改', function() + listItem(p, 236, 'ic_rollback', ROLE == 'restore' and '恢复修改' or '回滚修改', function() if ROLE == 'restore' then - setDesc1('本盘是还原盘。要重新换回动画,请安装 S4Chg.face。', C_GRAY) + setDesc1('本盘是还原盘。要重新换回动画,请安装 S4e Pt.3。', C_GRAY) else - setDesc1('本盘只负责替换。要还原原厂动画,请安装 S4ChgRestore.face。', C_GRAY) + setDesc1('本盘只负责替换。要还原原厂动画,请安装 S4e Pt.3 · 还原。', C_GRAY) end end) - lab(p, { x = 65, y = 344, w = 334, h = 72, + -- 换行写死成 2 行:24px 行高 33 -> 2 行 66 才放得进 h=72; + -- 原来 w=334 时这句会被折成 3 行(第 3 行整个在圆外),所以框宽 334 -> 366 + -- (x=65 不动;实测行宽 360/306)。 + lab(p, { x = 65, y = 326, w = 366, h = 72, text = ROLE == 'restore' and '把充电动画写回原厂样式。' - or '本盘不含回滚数据(体积减半),还原请装 S4ChgRestore.face。', + or '本盘不含回滚数据(体积减半),\n还原请装 S4e Pt.3 · 还原。', text_color = C_GRAY, font_size = 24, text_font = F_BODY }) return p end @@ -652,9 +667,15 @@ local function buildAbout() if IMG.logo then pcall(function() p:Image { src = IMG.logo, x = 194, y = 108 } end) end - -- "OS4 Icons" 28px 量得 138px -> 居中 x = 66 + (334-138)/2 = 164 - lab(p, { x = 164, y = 192, w = 138, h = 38, text = ROLE == 'restore' and 'S4Chg还原' or 'S4Chg', - text_color = C_WHITE, font_size = 28, text_font = F_ITEM }) + -- 28px 量得:'S4e Pt.3' = 114 -> 居中 x = 233 - 57 = 176 + -- 'S4e Pt.3 · 还原' = 204 -> x = 233 - 102 = 131 + if ROLE == 'restore' then + lab(p, { x = 131, y = 192, w = 206, h = 38, text = 'S4e Pt.3 · 还原', + text_color = C_WHITE, font_size = 28, text_font = F_ITEM }) + else + lab(p, { x = 176, y = 192, w = 116, h = 38, text = 'S4e Pt.3', + text_color = C_WHITE, font_size = 28, text_font = F_ITEM }) + end -- "1.0.0" 20px 量得 45px -> 居中 x = 66 + (334-45)/2 = 210 lab(p, { x = 210, y = 227, w = 45, h = 27, text = '1.0.0', text_color = C_GRAY, font_size = 20, text_font = F_VER }) diff --git a/s4e-charge/tools/build_faces.py b/s4e-charge/tools/build_faces.py index 8d11496..5239483 100644 --- a/s4e-charge/tools/build_faces.py +++ b/s4e-charge/tools/build_faces.py @@ -38,6 +38,13 @@ def prepare(name): shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', name + '.fprj'), os.path.join(dst, 'watchface', 'fprj', name + '.fprj')) + # projectName 现在就是「手表上显示的表盘名」(见 make_overlays.py 的 DISPLAY_NAME)。 + # 官方 scripts/build_face.ps1 先跑 sync_watchface_config.ps1:它按 projectName 找 + # watchface/fprj/.fprj,找不到就把目录里**唯一**那份 .fprj 改名成它, + # 并把该 fprj 的 覆盖成 projectName —— 所以这条流水线里真正进 .face 的 + # 显示名就是这个 projectName。注意 fprj 目录里必须**只有一份** .fprj:Compiler.exe 会 + # 按目录里每份 .fprj 把资源各打包一遍,多放一份副本会让 .face 直接翻倍。 + disp = cfg['projectName'] shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'images', 'preview.png'), os.path.join(dst, 'watchface', 'fprj', 'images', 'preview.png')) shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'app', 'lua', 'main.lua'), @@ -52,18 +59,54 @@ def prepare(name): shutil.rmtree(tgt) shutil.copytree(os.path.join(ovimg, d), tgt) print(' %s: app/images/%s/ -> %d files' % (name, d, len(os.listdir(tgt)))) - return dst + return dst, cfg['projectName'] -def build(dst): +def workspace_fprj(dst): + """把工作区里的 .fprj 归一成 .fprj,并返回它的路径。 + + 我们拷进去时是 .fprj,但 vendor 的 build_face.ps1 会先跑 + sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名** S4e Pt.3)找 + .fprj,找不到就把目录里**唯一**那份改名过去( 也被改写成 projectName)。 + 编完这里再改回 .fprj —— 工作区里始终只有一份 .fprj(放两份会被 Compiler.exe + 并成双倍资源),文件名保持项目标识不变,下游 finalize_faces.compile_raw / gen_ui_lua + 仍能按 fprj/.fprj 找到它。 + 多于一份 / 一份都没有都直接报错,不静默放过。 + """ + name = os.path.basename(dst) + d = os.path.join(dst, 'watchface', 'fprj') + fs = sorted(f for f in os.listdir(d) if f.endswith('.fprj')) + if len(fs) != 1: + raise SystemExit('%s: watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % (name, fs)) + want = os.path.join(d, name + '.fprj') + if fs[0] != name + '.fprj': + os.replace(os.path.join(d, fs[0]), want) + return want + + +def build(dst, name, disp): ps = os.path.join(dst, 'scripts', 'build_face.ps1') - r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps], + # 显式指定产物名:build_face.ps1 默认按 projectName(现在 = 显示名,可能带空格/点)命名产物, + # 这里仍然用 NAME,保证 bin/.face、dist/.face 这些标识路径不变。 + r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps, + '-FaceName', name + '.face'], capture_output=True, text=True, errors='replace') + # sync 已经把 .fprj 改名成 <显示名>.fprj(里面的 也写成了显示名); + # 这里改回 NAME,好让下游 finalize_faces.compile_raw 仍按 fprj/.fprj 找它。 + fprj = workspace_fprj(dst) + print(' fprj 归一:%s' % os.path.relpath(fprj, dst)) for l in [x for x in (r.stdout or '').splitlines() if x.strip()][-6:]: print(' |', l) if r.returncode != 0: print(' !! build failed, rc=', r.returncode) - return r.returncode == 0 + return False + # 官方脚本按 projectName 命名产物(bin/<显示名>.face);-FaceName 已经把它压成 NAME, + # 万一没生效,这里再兜一次(内容一样,只差文件名)。 + face = os.path.join(dst, 'bin', name + '.face') + alt = os.path.join(dst, 'bin', disp + '.face') + if disp != name and os.path.exists(alt) and not os.path.exists(face): + os.replace(alt, face) + return True if __name__ == '__main__': @@ -72,8 +115,8 @@ if __name__ == '__main__': if only and name != only: continue print('== %s (id=%s) ==' % (name, TARGETS[name][0])) - dst = prepare(name) - build(dst) + dst, disp = prepare(name) + build(dst, name, disp) face = os.path.join(dst, 'bin', name + '.face') if os.path.exists(face): print(' OK %s %d bytes' % (face, os.path.getsize(face))) diff --git a/s4e-charge/tools/finalize_faces.py b/s4e-charge/tools/finalize_faces.py index 566cded..aee1a44 100644 --- a/s4e-charge/tools/finalize_faces.py +++ b/s4e-charge/tools/finalize_faces.py @@ -6,7 +6,9 @@ 体积原因:本盘各 ~16 MB,不再额外复制工具链变体(要的话去掉下面的 SKIP_TOOLCHAIN 即可) """ import os, sys, shutil, subprocess, hashlib +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from paths import WORK, DIST +from build_faces import workspace_fprj # 工作区 .fprj 归一(vendor 会把它改名成显示名) BUILD = os.path.join(WORK, 'build') TARGETS = {'S4Chg': '362150103', 'S4ChgRestore': '362150103'} @@ -19,7 +21,9 @@ def compile_raw(name): if not os.path.exists(exe): print(' %s: 跳过(没有构建树,先跑 build_faces.py %s)' % (name, name)) return None - fprj = os.path.join(w, 'watchface', 'fprj', name + '.fprj') + # vendor 的 sync_watchface_config.ps1 会把工作区 .fprj 改名成显示名(S4e Pt.3); + # 这里取工作区**唯一**那份 .fprj 并归一回 .fprj,再交给 Compiler.exe 裸编译。 + fprj = workspace_fprj(w) out = os.path.join(WORK, 'raw_' + name) if os.path.exists(out): shutil.rmtree(out) diff --git a/s4e-charge/tools/gen_ui_lua.py b/s4e-charge/tools/gen_ui_lua.py index 7648291..1b88f03 100644 --- a/s4e-charge/tools/gen_ui_lua.py +++ b/s4e-charge/tools/gen_ui_lua.py @@ -12,6 +12,7 @@ rb 表里放的是**另一侧字节前 64 B 的指纹**(只给版本校验用 """ import os, re, sys, json, subprocess from paths import PROJ, WORK +from build_faces import workspace_fprj # 工作区 .fprj 归一(vendor 会把它改名成显示名) ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() NAME = 'S4Chg' if ROLE == 'replace' else 'S4ChgRestore' @@ -62,6 +63,10 @@ def main(): print('第 1 遍:用占位符编一次,量出资源在 resource.bin 里的位置') run('make_overlays.py', ROLE) run('build_faces.py', NAME) + # vendor 的 build_face.ps1 会把工作区 .fprj 改名成 projectName(= 显示名); + # 编完立刻取**唯一**那份并归一回 .fprj,后面的 finalize_faces 才有得可读。 + print(' fprj 归一:%s' % os.path.relpath( + workspace_fprj(os.path.join(WORK, 'build', NAME)), WORK)) face = open(FACE, 'rb').read() print(' face %d bytes' % len(face)) @@ -97,6 +102,10 @@ def main(): open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved) print('第 2 遍:回填后的 Lua(%d bytes),重新编译' % len(resolved)) run('build_faces.py', NAME) + # 同上:finalize_faces.compile_raw 是按 fprj/.fprj 路径找工作区 fprj 的, + # vendor 刚把它改名成显示名,这里必须归一(并断言只有一份)后再往下走。 + print(' fprj 归一:%s' % os.path.relpath( + workspace_fprj(os.path.join(WORK, 'build', NAME)), WORK)) print(run('finalize_faces.py', NAME).strip()) face2 = open(FACE, 'rb').read() diff --git a/s4e-charge/tools/make_overlays.py b/s4e-charge/tools/make_overlays.py index 6f43631..3fda76a 100644 --- a/s4e-charge/tools/make_overlays.py +++ b/s4e-charge/tools/make_overlays.py @@ -5,6 +5,10 @@ from paths import PROJ, WORK ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() NAME = 'S4Chg' if ROLE == 'replace' else 'S4ChgRestore' +# 手表上显示的表盘名:replace / restore 两张盘共用同一个名字(写进 .fprj 的 , +# 同时就是 watchface.config.json 的 projectName)。只是「显示名」—— NAME、.fprj 文件名、 +# dist/.face、.work/build/、表盘 id 这些路径/标识全部不变。 +DISPLAY_NAME = 'S4e Pt.3' OUT = os.path.join(WORK, 'overlay') ASSETS = os.path.join(PROJ, 'assets') @@ -49,12 +53,12 @@ def build(name, role): os.makedirs(os.path.join(fprj, 'images'), exist_ok=True) os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True) - cfg = {"projectName": name, "watchfaceId": WF_ID, "power_consumption": "3", + cfg = {"projectName": DISPLAY_NAME, "watchfaceId": WF_ID, "power_consumption": "3", "resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE", "input": "watchface/fprj/images/preview.png", "name": "preview"}} json.dump(cfg, open(os.path.join(base, 'watchface.config.json'), 'w', encoding='utf-8'), indent=4, ensure_ascii=False) - write_fprj(os.path.join(fprj, f'{name}.fprj'), name) + write_fprj(os.path.join(fprj, f'{name}.fprj'), DISPLAY_NAME) make_preview(os.path.join(fprj, 'images', 'preview.png')) shutil.copyfile(os.path.join(PROJ, 'main.lua'), os.path.join(fprj, 'app', 'lua', 'main.lua')) diff --git a/s4e-charge/ui_images/logo.bin b/s4e-charge/ui_images/logo.bin index 701c108..6285ca1 100644 Binary files a/s4e-charge/ui_images/logo.bin and b/s4e-charge/ui_images/logo.bin differ diff --git a/s4e-icons/assets/cover.png b/s4e-icons/assets/cover.png index 23c1cf8..b1069ea 100644 Binary files a/s4e-icons/assets/cover.png and b/s4e-icons/assets/cover.png differ diff --git a/s4e-icons/dist/S5Icons.face b/s4e-icons/dist/S4-Pt1.face similarity index 99% rename from s4e-icons/dist/S5Icons.face rename to s4e-icons/dist/S4-Pt1.face index 0aecf7c..ab52297 100644 Binary files a/s4e-icons/dist/S5Icons.face and b/s4e-icons/dist/S4-Pt1.face differ diff --git a/s4e-icons/dist/S5Icons_toolchain.face b/s4e-icons/dist/S5Icons_toolchain.face index 2fa3a3e..152bdd7 100644 Binary files a/s4e-icons/dist/S5Icons_toolchain.face and b/s4e-icons/dist/S5Icons_toolchain.face differ diff --git a/s4e-icons/docs/logo_preview.png b/s4e-icons/docs/logo_preview.png new file mode 100644 index 0000000..512e4bf Binary files /dev/null and b/s4e-icons/docs/logo_preview.png differ diff --git a/s4e-icons/main.lua b/s4e-icons/main.lua index 91b801e..b914780 100644 --- a/s4e-icons/main.lua +++ b/s4e-icons/main.lua @@ -464,7 +464,8 @@ local function buildHome() local p = newPage() if not p then return nil end clockLabel(p) - pageTitle(p, 'OS4 Icons', 159, 158) + -- 盘内标题 = 显示名:'S4e Pt.1' 30px 量得 118 -> x = (466-118)/2 = 174 + pageTitle(p, 'S4e Pt.1', 174, 120) listItem(p, 85, 'ic_help', '快速帮助', function() show(pages.help) end) listItem(p, 181, 'ic_replace', '开始替换', function() pendingVariant = 'replace' @@ -556,8 +557,8 @@ local function buildAbout() if IMG.logo then pcall(function() p:Image { src = IMG.logo, x = 194, y = 108 } end) end - -- "OS4 Icons" 28px 量得 138px -> 居中 x = 66 + (334-138)/2 = 164 - lab(p, { x = 164, y = 192, w = 138, h = 38, text = 'OS4 Icons', + -- 'S4e Pt.1' 28px 量得 109 -> 居中 x = 233 - 109/2 = 178.5 -> 178 + lab(p, { x = 178, y = 192, w = 111, h = 38, text = 'S4e Pt.1', text_color = C_WHITE, font_size = 28, text_font = F_ITEM }) -- "1.0.0" 20px 量得 45px -> 居中 x = 66 + (334-45)/2 = 210 lab(p, { x = 210, y = 227, w = 45, h = 27, text = '1.0.0', diff --git a/s4e-icons/tools/build_faces.py b/s4e-icons/tools/build_faces.py index 6ec566c..2fb1532 100644 --- a/s4e-icons/tools/build_faces.py +++ b/s4e-icons/tools/build_faces.py @@ -37,6 +37,13 @@ def prepare(name): shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', f'{name}.fprj'), os.path.join(dst, 'watchface', 'fprj', f'{name}.fprj')) + # projectName 现在就是「手表上显示的表盘名」(见 make_overlays.py 的 DISPLAY_NAME)。 + # 官方 scripts/build_face.ps1 先跑 sync_watchface_config.ps1:它按 projectName 找 + # watchface/fprj/.fprj,找不到就把目录里**唯一**那份 .fprj 改名成它, + # 并把该 fprj 的 覆盖成 projectName —— 所以这条流水线里真正进 .face 的 + # 显示名就是这个 projectName。注意 fprj 目录里必须**只有一份** .fprj:Compiler.exe 会 + # 按目录里每份 .fprj 把资源各打包一遍,多放一份副本会让 .face 直接翻倍。 + disp = cfg['projectName'] shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'images', 'preview.png'), os.path.join(dst, 'watchface', 'fprj', 'images', 'preview.png')) shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'app', 'lua', 'main.lua'), @@ -51,18 +58,34 @@ def prepare(name): shutil.rmtree(tgt) shutil.copytree(os.path.join(ovimg, d), tgt) print(f' {name}: app/images/{d}/ -> {len(os.listdir(tgt))} files') - return dst + return dst, cfg['projectName'] -def build(dst): +def build(dst, name, disp): ps = os.path.join(dst, 'scripts', 'build_face.ps1') - r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps], + # 显式指定产物名:build_face.ps1 默认按 projectName(现在 = 显示名,可能带空格/点)命名产物, + # 这里仍然用 NAME,保证 bin/.face、dist/.face 这些标识路径不变。 + r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps, + '-FaceName', f'{name}.face'], capture_output=True, text=True, errors='replace') for l in [x for x in (r.stdout or '').splitlines() if x.strip()][-6:]: print(' |', l) if r.returncode != 0: print(' !! build failed, rc=', r.returncode) - return r.returncode == 0 + return False + # sync 已经把 .fprj 改名成 <显示名>.fprj(里面的 也写成了显示名); + # 这里改回 NAME,好让下游 finalize_faces.compile_raw 仍按 fprj/.fprj 找它。 + fprj_ident = os.path.join(dst, 'watchface', 'fprj', f'{name}.fprj') + fprj_disp = os.path.join(dst, 'watchface', 'fprj', f'{disp}.fprj') + if disp != name and os.path.exists(fprj_disp) and not os.path.exists(fprj_ident): + os.replace(fprj_disp, fprj_ident) + # 官方脚本按 projectName 命名产物(bin/<显示名>.face);-FaceName 已经把它压成 NAME, + # 万一没生效,这里再兜一次(内容一样,只差文件名)。 + face = os.path.join(dst, 'bin', f'{name}.face') + alt = os.path.join(dst, 'bin', f'{disp}.face') + if disp != name and os.path.exists(alt) and not os.path.exists(face): + os.replace(alt, face) + return True if __name__ == '__main__': @@ -71,8 +94,8 @@ if __name__ == '__main__': if only and name != only: continue print(f'== {name} (id={TARGETS[name][0]}) ==') - dst = prepare(name) - ok = build(dst) + dst, disp = prepare(name) + build(dst, name, disp) face = os.path.join(dst, 'bin', f'{name}.face') if os.path.exists(face): print(f' OK {face} {os.path.getsize(face)} bytes') diff --git a/s4e-icons/tools/make_overlays.py b/s4e-icons/tools/make_overlays.py index fcee2f3..da81786 100644 --- a/s4e-icons/tools/make_overlays.py +++ b/s4e-icons/tools/make_overlays.py @@ -10,6 +10,9 @@ DEVICE_TYPE = "362" # 与官方模板一致(362 = S4 家族) ENTRY_WIDGET = "app_lua%2Fmain.lua" PREVIEW_SIZE = 326 # Compiler.exe 对 362 设备强制 326x326 SCREEN_SIZE = 466 +# 手表上显示的表盘名:写进 .fprj 的 ,同样也是 watchface.config.json 的 projectName。 +# 只是「显示名」—— 路径/标识(NAME、.fprj 文件名、dist/.face、表盘 id)全部不变。 +DISPLAY_NAME = 'S4e Pt.1' WF_ID = "491552801" FONTS = [r'C:\Windows\Fonts\msyhbd.ttc', r'C:\Windows\Fonts\msyh.ttc', r'C:\Windows\Fonts\arialbd.ttf', r'C:\Windows\Fonts\arial.ttf'] @@ -48,12 +51,12 @@ def build(name): os.makedirs(os.path.join(fprj, 'images'), exist_ok=True) os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True) - cfg = {"projectName": name, "watchfaceId": WF_ID, "power_consumption": "3", + cfg = {"projectName": DISPLAY_NAME, "watchfaceId": WF_ID, "power_consumption": "3", "resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE", "input": "watchface/fprj/images/preview.png", "name": "preview"}} json.dump(cfg, open(os.path.join(base, 'watchface.config.json'), 'w', encoding='utf-8'), indent=4, ensure_ascii=False) - write_fprj(os.path.join(fprj, f'{name}.fprj'), name) + write_fprj(os.path.join(fprj, f'{name}.fprj'), DISPLAY_NAME) make_preview(os.path.join(fprj, 'images', 'preview.png')) shutil.copyfile(os.path.join(PROJ, 'main.lua'), os.path.join(fprj, 'app', 'lua', 'main.lua')) diff --git a/s4e-icons/tools/pages.py b/s4e-icons/tools/pages.py index cb8f259..d67e315 100644 --- a/s4e-icons/tools/pages.py +++ b/s4e-icons/tools/pages.py @@ -75,7 +75,7 @@ def big_button(y=365, text='确认替换'): # ---------------------------------------------------------------- 1. 首页 PAGE_HOME = [ clock(), - title('OS4 Icons'), + title('S4e Pt.1'), *item(85, 'MdiLightbulbOutline.png', '快速帮助'), *item(181, 'MdiBandage.png', '开始替换'), *item(277, 'MdiInformationOutline.png', '关于'), @@ -162,7 +162,7 @@ PAGE_ABOUT = [ dict(kind='rect', x=52, y=79, w=362, h=204, bg=PLATE, radius=30), dict(kind='image', x=194, y=108, w=78, h=78, src=os.path.join(DOCS, 'logo_preview.png')), - dict(kind='label', x=164, y=192, w=138, h=38, text='OS4 Icons', + dict(kind='label', x=178, y=192, w=111, h=38, text='S4e Pt.1', size=28, weight=S500, color=WHITE, align='left', vtop=True, lh=38), dict(kind='label', x=210, y=227, w=45, h=27, text='1.0.0', size=20, weight=S500, color=GRAY, align='left', vtop=True, lh=27), diff --git a/s4e-icons/ui_images/logo.bin b/s4e-icons/ui_images/logo.bin index 701c108..b538da3 100644 Binary files a/s4e-icons/ui_images/logo.bin and b/s4e-icons/ui_images/logo.bin differ diff --git a/s4e-pressure-anim/assets/cover.png b/s4e-pressure-anim/assets/cover.png index 23c1cf8..4cf883b 100644 Binary files a/s4e-pressure-anim/assets/cover.png and b/s4e-pressure-anim/assets/cover.png differ diff --git a/s4e-pressure-anim/dist/S4PAnim.face b/s4e-pressure-anim/dist/S4-Pt2.face similarity index 99% rename from s4e-pressure-anim/dist/S4PAnim.face rename to s4e-pressure-anim/dist/S4-Pt2.face index 64661e0..7f584df 100644 Binary files a/s4e-pressure-anim/dist/S4PAnim.face and b/s4e-pressure-anim/dist/S4-Pt2.face differ diff --git a/s4e-pressure-anim/dist/S4PAnimRestore.face b/s4e-pressure-anim/dist/S4PAnimRestore.face index a0ddb91..35f918d 100644 Binary files a/s4e-pressure-anim/dist/S4PAnimRestore.face and b/s4e-pressure-anim/dist/S4PAnimRestore.face differ diff --git a/s4e-pressure-anim/docs/logo_preview.png b/s4e-pressure-anim/docs/logo_preview.png new file mode 100644 index 0000000..3fee18c Binary files /dev/null and b/s4e-pressure-anim/docs/logo_preview.png differ diff --git a/s4e-pressure-anim/main.lua b/s4e-pressure-anim/main.lua index e5b444e..6515e38 100644 --- a/s4e-pressure-anim/main.lua +++ b/s4e-pressure-anim/main.lua @@ -557,7 +557,14 @@ local function buildHome() local p = newPage() if not p then return nil end clockLabel(p) - pageTitle(p, ROLE == 'restore' and 'S4PAnim还原' or 'S4PAnim', 159, 158) + -- 盘内标题 = 显示名(不再用内部代号 S4PAnim);x 按真 MiSans 30px 量出的宽度居中: + -- 'S4e Pt.2' = 123 -> x = (466-123)/2 = 171.5 -> 172 + -- 'S4e Pt.2 · 还原' = 220 -> x = (466-220)/2 = 123 + if ROLE == 'restore' then + pageTitle(p, 'S4e Pt.2 · 还原', 123, 222) + else + pageTitle(p, 'S4e Pt.2', 172, 125) + end listItem(p, 85, 'ic_help', '快速帮助', function() show(pages.help) end) listItem(p, 181, 'ic_replace', ROLE == 'restore' and '开始还原' or '开始替换', function() pendingVariant = 'replace' @@ -577,22 +584,30 @@ local function buildHelp() setClickable(t) bind(t, function() show(pages.home) end) - listItem(p, 85, 'ic_check', '环境预检查', function() doCheck() end) - desc1 = lab(p, { x = 65, y = 176, w = 338, h = 72, + -- 竖排 4 个元素的 y:76 / 166 / 236 / 326。 + -- 原来 85 / 176 / 256 / 344 时,末尾那句 2 行块的**第 2 行**(y 377..410)已经越过圆边 + -- (仓库外 check_clip_real.py 实测:replace 盘 229 越界像素,restore 盘 23 像素), + -- 因为整页内容 88+66+88+66=308px,而 466 圆在 x=65 处最多只给到 y<=394。 + -- 4 个元素整体上移 9~22px 后:整页 replace/restore 都是 0 越界、0 溢框(最远像素 228.2 < 233)。 + listItem(p, 76, 'ic_check', '环境预检查', function() doCheck() end) + desc1 = lab(p, { x = 65, y = 166, w = 338, h = 72, text = '检查原始资源状态、分区可写情况等。', text_color = C_GRAY, font_size = 24, text_font = F_BODY }) -- 拆盘之后:本盘只装一份负载,「回滚/恢复」由另一份盘负责 - listItem(p, 256, 'ic_rollback', ROLE == 'restore' and '恢复修改' or '回滚修改', function() + listItem(p, 236, 'ic_rollback', ROLE == 'restore' and '恢复修改' or '回滚修改', function() if ROLE == 'restore' then - setDesc1('本盘是还原盘。要重新换回动画,请安装 S4PAnim.face。', C_GRAY) + setDesc1('本盘是还原盘。要重新换回动画,请安装 S4e Pt.2。', C_GRAY) else - setDesc1('本盘只负责替换。要还原原厂动画,请安装 S4PAnimRestore.face。', C_GRAY) + setDesc1('本盘只负责替换。要还原原厂动画,请安装 S4e Pt.2 · 还原。', C_GRAY) end end) - lab(p, { x = 65, y = 344, w = 334, h = 72, + -- 换行写死成 2 行:24px 行高 33 -> 2 行 66 才放得进 h=72; + -- 原来 w=334 时这句会被折成 3 行(第 3 行整个在圆外),所以框宽 334 -> 366 + -- (x=65 不动;实测行宽 360/306)。 + lab(p, { x = 65, y = 326, w = 366, h = 72, text = ROLE == 'restore' and '把压力检测动画写回原厂样式。' - or '本盘不含回滚数据(体积减半),还原请装 S4PAnimRestore.face。', + or '本盘不含回滚数据(体积减半),\n还原请装 S4e Pt.2 · 还原。', text_color = C_GRAY, font_size = 24, text_font = F_BODY }) return p end @@ -652,9 +667,15 @@ local function buildAbout() if IMG.logo then pcall(function() p:Image { src = IMG.logo, x = 194, y = 108 } end) end - -- "OS4 Icons" 28px 量得 138px -> 居中 x = 66 + (334-138)/2 = 164 - lab(p, { x = 164, y = 192, w = 138, h = 38, text = ROLE == 'restore' and 'S4PAnim还原' or 'S4PAnim', - text_color = C_WHITE, font_size = 28, text_font = F_ITEM }) + -- 28px 量得:'S4e Pt.2' = 113 -> 居中 x = 233 - 56.5 = 176.5 -> 176 + -- 'S4e Pt.2 · 还原' = 203 -> x = 233 - 101.5 = 131.5 -> 132 + if ROLE == 'restore' then + lab(p, { x = 132, y = 192, w = 205, h = 38, text = 'S4e Pt.2 · 还原', + text_color = C_WHITE, font_size = 28, text_font = F_ITEM }) + else + lab(p, { x = 176, y = 192, w = 115, h = 38, text = 'S4e Pt.2', + text_color = C_WHITE, font_size = 28, text_font = F_ITEM }) + end -- "1.0.0" 20px 量得 45px -> 居中 x = 66 + (334-45)/2 = 210 lab(p, { x = 210, y = 227, w = 45, h = 27, text = '1.0.0', text_color = C_GRAY, font_size = 20, text_font = F_VER }) diff --git a/s4e-pressure-anim/tools/build_faces.py b/s4e-pressure-anim/tools/build_faces.py index e8122ee..4c775d3 100644 --- a/s4e-pressure-anim/tools/build_faces.py +++ b/s4e-pressure-anim/tools/build_faces.py @@ -38,6 +38,13 @@ def prepare(name): shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', name + '.fprj'), os.path.join(dst, 'watchface', 'fprj', name + '.fprj')) + # projectName 现在就是「手表上显示的表盘名」(见 make_overlays.py 的 DISPLAY_NAME)。 + # 官方 scripts/build_face.ps1 先跑 sync_watchface_config.ps1:它按 projectName 找 + # watchface/fprj/.fprj,找不到就把目录里**唯一**那份 .fprj 改名成它, + # 并把该 fprj 的 覆盖成 projectName —— 所以这条流水线里真正进 .face 的 + # 显示名就是这个 projectName。注意 fprj 目录里必须**只有一份** .fprj:Compiler.exe 会 + # 按目录里每份 .fprj 把资源各打包一遍,多放一份副本会让 .face 直接翻倍。 + disp = cfg['projectName'] shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'images', 'preview.png'), os.path.join(dst, 'watchface', 'fprj', 'images', 'preview.png')) shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'app', 'lua', 'main.lua'), @@ -52,18 +59,54 @@ def prepare(name): shutil.rmtree(tgt) shutil.copytree(os.path.join(ovimg, d), tgt) print(' %s: app/images/%s/ -> %d files' % (name, d, len(os.listdir(tgt)))) - return dst + return dst, cfg['projectName'] -def build(dst): +def workspace_fprj(dst): + """把工作区里的 .fprj 归一成 .fprj,并返回它的路径。 + + 我们拷进去时是 .fprj,但 vendor 的 build_face.ps1 会先跑 + sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名** S4e Pt.2)找 + .fprj,找不到就把目录里**唯一**那份改名过去( 也被改写成 + projectName)。编完这里再改回 .fprj —— 工作区里始终只有一份 .fprj(放两份会被 + Compiler.exe 并成双倍资源),文件名保持项目标识不变,下游 finalize_faces.compile_raw / + gen_ui_lua 仍能按 fprj/.fprj 找到它。 + 多于一份 / 一份都没有都直接报错,不静默放过。 + """ + name = os.path.basename(dst) + d = os.path.join(dst, 'watchface', 'fprj') + fs = sorted(f for f in os.listdir(d) if f.endswith('.fprj')) + if len(fs) != 1: + raise SystemExit('%s: watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % (name, fs)) + want = os.path.join(d, name + '.fprj') + if fs[0] != name + '.fprj': + os.replace(os.path.join(d, fs[0]), want) + return want + + +def build(dst, name, disp): ps = os.path.join(dst, 'scripts', 'build_face.ps1') - r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps], + # 显式指定产物名:build_face.ps1 默认按 projectName(现在 = 显示名,可能带空格/点)命名产物, + # 这里仍然用 NAME,保证 bin/.face、dist/.face 这些标识路径不变。 + r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps, + '-FaceName', name + '.face'], capture_output=True, text=True, errors='replace') + # sync 已经把 .fprj 改名成 <显示名>.fprj(里面的 也写成了显示名); + # 这里改回 NAME,好让下游 finalize_faces.compile_raw 仍按 fprj/.fprj 找它。 + fprj = workspace_fprj(dst) + print(' fprj 归一:%s' % os.path.relpath(fprj, dst)) for l in [x for x in (r.stdout or '').splitlines() if x.strip()][-6:]: print(' |', l) if r.returncode != 0: print(' !! build failed, rc=', r.returncode) - return r.returncode == 0 + return False + # 官方脚本按 projectName 命名产物(bin/<显示名>.face);-FaceName 已经把它压成 NAME, + # 万一没生效,这里再兜一次(内容一样,只差文件名)。 + face = os.path.join(dst, 'bin', name + '.face') + alt = os.path.join(dst, 'bin', disp + '.face') + if disp != name and os.path.exists(alt) and not os.path.exists(face): + os.replace(alt, face) + return True if __name__ == '__main__': @@ -72,8 +115,8 @@ if __name__ == '__main__': if only and name != only: continue print('== %s (id=%s) ==' % (name, TARGETS[name][0])) - dst = prepare(name) - build(dst) + dst, disp = prepare(name) + build(dst, name, disp) face = os.path.join(dst, 'bin', name + '.face') if os.path.exists(face): print(' OK %s %d bytes' % (face, os.path.getsize(face))) diff --git a/s4e-pressure-anim/tools/finalize_faces.py b/s4e-pressure-anim/tools/finalize_faces.py index cfb30b3..99bccc8 100644 --- a/s4e-pressure-anim/tools/finalize_faces.py +++ b/s4e-pressure-anim/tools/finalize_faces.py @@ -6,7 +6,9 @@ 体积原因:本盘各 ~16 MB,不再额外复制工具链变体(要的话去掉下面的 SKIP_TOOLCHAIN 即可) """ import os, sys, shutil, subprocess, hashlib +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from paths import WORK, DIST +from build_faces import workspace_fprj # 工作区 .fprj 归一(vendor 会把它改名成显示名) BUILD = os.path.join(WORK, 'build') TARGETS = {'S4PAnim': '362150102', 'S4PAnimRestore': '362150102'} @@ -19,7 +21,9 @@ def compile_raw(name): if not os.path.exists(exe): print(' %s: 跳过(没有构建树,先跑 build_faces.py %s)' % (name, name)) return None - fprj = os.path.join(w, 'watchface', 'fprj', name + '.fprj') + # vendor 的 sync_watchface_config.ps1 会把工作区 .fprj 改名成显示名(S4e Pt.2); + # 这里取工作区**唯一**那份 .fprj 并归一回 .fprj,再交给 Compiler.exe 裸编译。 + fprj = workspace_fprj(w) out = os.path.join(WORK, 'raw_' + name) if os.path.exists(out): shutil.rmtree(out) diff --git a/s4e-pressure-anim/tools/gen_ui_lua.py b/s4e-pressure-anim/tools/gen_ui_lua.py index 4f54589..74c5b23 100644 --- a/s4e-pressure-anim/tools/gen_ui_lua.py +++ b/s4e-pressure-anim/tools/gen_ui_lua.py @@ -12,6 +12,7 @@ rb 表里放的是**另一侧字节前 64 B 的指纹**(只给版本校验用 """ import os, re, sys, json, subprocess from paths import PROJ, WORK +from build_faces import workspace_fprj # 工作区 .fprj 归一(vendor 会把它改名成显示名) ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() NAME = 'S4PAnim' if ROLE == 'replace' else 'S4PAnimRestore' @@ -62,6 +63,10 @@ def main(): print('第 1 遍:用占位符编一次,量出资源在 resource.bin 里的位置') run('make_overlays.py', ROLE) run('build_faces.py', NAME) + # vendor 的 build_face.ps1 会把工作区 .fprj 改名成 projectName(= 显示名); + # 编完立刻取**唯一**那份并归一回 .fprj,后面的 finalize_faces 才有得可读。 + print(' fprj 归一:%s' % os.path.relpath( + workspace_fprj(os.path.join(WORK, 'build', NAME)), WORK)) face = open(FACE, 'rb').read() print(' face %d bytes' % len(face)) @@ -97,6 +102,10 @@ def main(): open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved) print('第 2 遍:回填后的 Lua(%d bytes),重新编译' % len(resolved)) run('build_faces.py', NAME) + # 同上:finalize_faces.compile_raw 是按 fprj/.fprj 路径找工作区 fprj 的, + # vendor 刚把它改名成显示名,这里必须归一(并断言只有一份)后再往下走。 + print(' fprj 归一:%s' % os.path.relpath( + workspace_fprj(os.path.join(WORK, 'build', NAME)), WORK)) print(run('finalize_faces.py', NAME).strip()) face2 = open(FACE, 'rb').read() diff --git a/s4e-pressure-anim/tools/make_overlays.py b/s4e-pressure-anim/tools/make_overlays.py index 5c2adf2..17289d2 100644 --- a/s4e-pressure-anim/tools/make_overlays.py +++ b/s4e-pressure-anim/tools/make_overlays.py @@ -5,6 +5,10 @@ from paths import PROJ, WORK ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() NAME = 'S4PAnim' if ROLE == 'replace' else 'S4PAnimRestore' +# 手表上显示的表盘名:replace / restore 两张盘共用同一个名字(写进 .fprj 的 , +# 同时就是 watchface.config.json 的 projectName)。只是「显示名」—— NAME、.fprj 文件名、 +# dist/.face、.work/build/、表盘 id 这些路径/标识全部不变。 +DISPLAY_NAME = 'S4e Pt.2' OUT = os.path.join(WORK, 'overlay') ASSETS = os.path.join(PROJ, 'assets') @@ -49,12 +53,12 @@ def build(name, role): os.makedirs(os.path.join(fprj, 'images'), exist_ok=True) os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True) - cfg = {"projectName": name, "watchfaceId": WF_ID, "power_consumption": "3", + cfg = {"projectName": DISPLAY_NAME, "watchfaceId": WF_ID, "power_consumption": "3", "resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE", "input": "watchface/fprj/images/preview.png", "name": "preview"}} json.dump(cfg, open(os.path.join(base, 'watchface.config.json'), 'w', encoding='utf-8'), indent=4, ensure_ascii=False) - write_fprj(os.path.join(fprj, f'{name}.fprj'), name) + write_fprj(os.path.join(fprj, f'{name}.fprj'), DISPLAY_NAME) make_preview(os.path.join(fprj, 'images', 'preview.png')) shutil.copyfile(os.path.join(PROJ, 'main.lua'), os.path.join(fprj, 'app', 'lua', 'main.lua')) diff --git a/s4e-pressure-anim/ui_images/logo.bin b/s4e-pressure-anim/ui_images/logo.bin index 701c108..a134b2e 100644 Binary files a/s4e-pressure-anim/ui_images/logo.bin and b/s4e-pressure-anim/ui_images/logo.bin differ diff --git a/s5e-charge/.gitignore b/s5e-charge/.gitignore new file mode 100644 index 0000000..13a0346 --- /dev/null +++ b/s5e-charge/.gitignore @@ -0,0 +1,10 @@ +# 大文件 / 中间产物:不进仓库 +.work/ +inputs/ +vendor/ +__pycache__/ +*.pyc +payload/ +payload_restore/ +blob_replace/ +blob_restore/ diff --git a/s5e-charge/README.md b/s5e-charge/README.md new file mode 100644 index 0000000..4dd876a --- /dev/null +++ b/s5e-charge/README.md @@ -0,0 +1,228 @@ +# S5Chg —— 把 S5 41mm(q63) 的充电动画搬到 S5 eSIM 46mm + +把 **Xiaomi Watch S5 41mm(q63)** `vela_system.bin` 里的 `charging/charge0..60.bin`(**464×464**), +逐帧**等长覆盖**写进 **Xiaomi Watch S5 eSIM 46mm(p62lte)** `/dev/system` 的同名 61 个槽位 +(**480×480**,I8 + RLE)。 + +> 关系:`s4e-charge` 是「S5 41mm → S4 eSIM」的母项目(只读参考)。本项目沿用它的全部结构 +> (两遍构建回填偏移、指纹表拆盘、逐字节等长覆盖、独立校验),只把目标换成 S5 eSIM、 +> 帧数改成 61→61、界面重做成 480×480、放大从"同尺寸"改成"464→480 放大"。 + +## 改了什么 + +| | | +|---|---| +| 目标 | `inputs/target/vela_system.bin`(S5 eSIM 46mm `/dev/system`)→ `charging/charge0..60.bin`,**61 帧 480×480** I8+RLE,槽位 143,832 ~ 213,511 B(**各不相同**) | +| 源 | `inputs/source/vela_system.bin`(q63 S5 41mm)→ `charging/charge0..60.bin`,**61 帧 464×464** | +| 映射 | **帧号 1:1(n → n)**。两边都是 61 帧,**没有** S4e 那版的 `round(n/56*60)` 比例重采样 | +| 放大 | 源帧 RGB 用 **Lanczos** 放大到 480×480,再把每个像素**吸附回源帧自己的 256 色调色板**(最近的欧氏距离)→ 颜色零新增、零量化,空间上比最近邻放大平滑 | +| 本次 | **61/61 全换**,**每一帧都是"原调色板 + Lanczos"**(没有一帧被降级:没有暗区合并、没有降色数、没有留原厂) | +| 编码 | 压缩 8.69 MB,槽位共 11.27 MB → **还剩 2.57 MB 余量**(最紧的一帧 `charge47` 也余 10,488 B) | +| 校验 | 61/61 回解**逐像素**等于计划图、RLE 恰好解出 usize、inode 表/superblock 逐位不变、槽位外 0 字节变化、回放一致 | + +等长的做法(硬约束,一步都不能省):`解码源帧 → Lanczos 放大 480×480 → 吸附回源调色板 → +LVGL RLE 压缩 → 用合法空指令补齐到原 csize`。补齐用 `00 00`(重复 0 次)这类**产出 0 像素** +的指令;另外 `usize_tail()` 会在解码长度比 `usize` 少时**重复最后一个字节**补足 +(踩过的坑:RLE 输出比 `usize` 少 1 个像素,设备端整帧不画)。 + +## 交付物 + +| 文件 | 字节数 | id / b5 | sha256 | +|---|---|---|---| +| `dist/S5Chg.face` | **11,986,752** | `462150103` / b5=0 | `577147341d363811965b51f46088ed98ea62c946cc8b7d205264577fab4d8066` | +| `dist/S5ChgRestore.face` | **11,986,752** | `462150103` / b5=0 | `8dbfc87acde8b54543fa7f2b54c1b0d7360634c83d6cf139a78df762ff078f2c` | + +两份盘**共用同一个表盘 id**(`462150103`):装其中一份就会顶掉另一份,设备上始终只占一份空间。 +- `S5Chg.face`:`pay` = 替换后的 61 帧,`rb` = **原厂**前 64 B 指纹(只给版本校验用) +- `S5ChgRestore.face`:`pay` = **原厂**61 帧(逐字节),`rb` = 替换后前 64 B 指纹 + +预览与报告: +- `docs/decode_preview.png` —— **直接从交付物 .face 里解出来的 61 帧**总览(最硬的证据) +- `docs/face_before_after.png` —— 抽样 5 帧:上排 q63 原帧(464),下排交付物解出的帧(480) +- `docs/before_after.png` —— 生成负载时的对照图(上=源帧,下=磁盘上改后镜像解出的帧) +- `docs/ui_*.png` / `docs/ui_all.png` —— 480×480 界面(9 个规格页 + 8 个还原盘文案变体) +- `docs/selfcheck_report.txt` —— `tools/verify_all.py` 的完整输出(33/33) +- `docs/selfcheck_run.txt` —— **整条重建流水线的原始输出**(8 步,逐步 EXIT=0) + +## 真机怎么做 + +1. 装 `dist/S5Chg.face` +2. 表盘「快速帮助 → 环境预检查」(**只读**,一个字节都不写):确认 `/dev/system` 可读、 + 卷名是 `system`、61 条负载不越界,并对首/中/尾三条做抽样版本校验(`原厂✓` / `已替换✓`) +3. 首页「开始替换」→ 确认页 → **确认替换** → 等 `成功 61 失败 0`(61 条 ≈ 11.3 MB) +4. 「重启手表」→ 插上充电器看整段动画 +5. 还原:装 `dist/S5ChgRestore.face` →「开始还原」→ 重启 + +输出怎么读(「正在替换」页):每 10 条一行 `> 写入 n/61 成功 x 失败 y`;结束时 +`> 共 61 条,成功 x 失败 y`。 + +**判断成功的唯一标准是 `成功 61 / 失败 0`**;如果出现 `> 版本不符跳过 n 条`,说明设备上的 +`/dev/system` 不是算偏移时用的那一版固件 —— 此时**不要重启**,装对应版本的盘或先还原。 + +写入前每条都做版本校验:读设备上该偏移的前 64 B,必须等于「原厂前 64 B」或「替换后前 64 B」, +**两边都不是就判「版本不符」并跳过 `/dev/system` 剩余记录**(绝不把字节写进别人的文件里)。 +写入后整条回读比对,失败自动重试 3 次 + 退化成整条一次 dd。 + +## 重建 + +```powershell +# 需要 Python 3 + Pillow + numpy;inputs/ 与 vendor/ 要自己准备(见下) +python tools\make_charge_payload.py all # 61 帧负载 + 整分区镜像(自带 4 项镜像级自证) +python tools\make_dd_payload.py replace # 负载 + 指纹 + blob_replace/ +python tools\gen_ui_lua.py replace # 两遍构建 -> dist\S5Chg.face(0 漂移校验) +python tools\verify_anim.py replace # 逐盘校验(11 项) +python tools\make_dd_payload.py restore +python tools\gen_ui_lua.py restore # -> dist\S5ChgRestore.face +python tools\verify_anim.py restore +python tools\verify_all.py # 独立总校验(33 项)+ 预览图 + selfcheck_report.txt +``` + +或者一条命令跑完全部 8 步(逐步校验、任一步失败即中止,输出留档到 `docs/selfcheck_run.txt`): + +```powershell +& ".\tools\selfcheck.ps1" +``` + +`make_charge_payload.py` 不加 `all` 时只跑 `[0,14,28,42,56]` 这 5 帧(轻量试跑)。 + +界面相关(改了 `tools/pages.py` / `main.lua` 之后): + +```powershell +python tools\make_ui_images.py # assets/ -> ui_images/*.bin(480 档尺寸) +python tools\render_ui.py # 480×480 预览(有标签溢框会直接报出来) +python tools\check_clip.py # 真实渲染像素判圆屏切边(0 越界才算过) +``` + +### `inputs/` 与 `vendor/` 不进仓库 + +`.gitignore` 里已经排除(本项目 `.gitignore` 原样沿用,未改动): + +| 路径 | 是什么 | +|---|---| +| `inputs/target/vela_system.bin` | 目标固件 p62lte 的 system 分区镜像(84,429,824 B) | +| `inputs/source/vela_system.bin` | 源固件 q63 的 system 分区镜像(100,630,528 B) | +| `inputs/fonts/MiSans-*.ttf` | 可选;只用于本机渲染预览(缺了就退回系统字体,只影响预览) | +| `vendor/LuaDevTemplate-main/` | 官方模板(含 `Compiler.exe`)—— 用默认路径即可,**不要设 `ICON_EDIT_VENDOR`** | + +固件分区镜像就是官方 OTA 包(ZIP)里的 `vela_system.bin` 条目,解压出来放到上表位置即可。 + +## 自证输出(全部真实执行过) + +**① 61 条负载长度逐条 == 原槽位长度** +``` +目标 S5 eSIM(p62lte) 61 帧(0..60,槽位合计 11.27 MB) +源 S5 41mm(q63) 61 帧(0..60,10.63 MB) +映射:n -> n(1:1) 输出分辨率:480x480(源 464x464 -> Lanczos 放大后吸附回源调色板) + 帧 47 <- q63 第 47 帧 槽位 144563 B 编码 134051 B(留白 10488) ... 原调色板+Lanczos +回解自检通过 61 / 61(解码长度恰好 usize 且逐像素等于计划图) +编码方式分布:{'原调色板+Lanczos': 61} +``` +`tools/verify_all.py`:`[OK] replace:61 条长度 == 原厂槽位长度 不符 0 条` / +`[OK] restore:61 条长度 == 原厂槽位长度 不符 0 条`; +`tools/verify_anim.py`:`pay 检查 61 条:长度全对 True` + +**② 每条负载解出来都是 480×480(不能解码失败)** +``` +[OK] replace:61 条都解出 480x480 且 RLE 恰好 usize 不符 0 条 +[OK] restore:61 条都解出 480x480 且 RLE 恰好 usize 不符 0 条 +``` +(`usize` 必须**恰好** 231,424 = 1024 + 480×480,多一字节设备端 INVALID、少一字节不画) + +**③ 改后整分区镜像的 inode 表 / superblock 逐位不变** +``` +inode 表逐位不变:True(2150 条) +superblock 一致:True(sb_size=84429200 / 84429200,sb_cksum=3275251810 / 3275251810) +逐字节差异:共 11585280 字节,全部落在 61 个槽位内:True(槽位外 0 字节) +[OK] inode 表逐位不变(2150 条) / [OK] superblock 逐位不变 / [OK] 除 61 个槽位外没有任何字节变化 +``` + +**④ 独立校验脚本全绿** +``` +=== tools/verify_all.py(只读 dist/*.face + inputs/*/vela_system.bin,不读 .work/)=== +61 帧平均差:mean 0.189 max 0.260(第 17 帧) <- 与"独立做的 Lanczos 放大"比对 +最像的源帧号与自身不一致的帧:0 个 <- 证明第 n 帧就是 q63 第 n 帧 +[OK] replace 盘 rb == 原厂前 64 B(对固件镜像比对) 不符 0 条 +[OK] restore 盘 pay == 原厂整条字节(逐字节) 不符 0 条 +[OK] restore 盘 rb == replace 盘 pay 前 64 B(两份交付物互证) 不符 0 条 +RESULT: 全部通过 —— 33 项通过,0 项失败 + +=== tools/verify_anim.py replace / restore === +S5Chg:pay 61 条,rb 61 条 pay 检查:长度全对 True,可解码 True,字节一致 True +全部通过 —— 11 项通过,0 项失败 (restore 同样 11/11) + +=== tools/check_clip.py(480×480 圆屏,含 replace/restore 两套文案)=== +检查了 17 个页面(含 replace/restore 两套文案):越界页面 0 +RESULT: PASS(0 越界、0 折行) +``` + +**⑤ 两张 .face 的 sha256** +``` +S5Chg.face 11986752 b4=0 b5=0 id=b'462150103\x00' sha256[:16]=577147341d363811 +S5ChgRestore.face 11986752 b4=0 b5=0 id=b'462150103\x00' sha256[:16]=8dbfc87acde8b545 +S5Chg.face sha256=577147341d363811965b51f46088ed98ea62c946cc8b7d205264577fab4d8066 +S5ChgRestore.face sha256=8dbfc87acde8b54543fa7f2b54c1b0d7360634c83d6cf139a78df762ff078f2c +``` +完整重建 + 自证的原始输出(8 步全部 EXIT=0)留档在 `docs/selfcheck_run.txt`, +显式自证清单在 `docs/selfcheck_report.txt`(33/33)。重跑整条流水线: + +```powershell +& ".\tools\selfcheck.ps1" # = 上面 8 条命令,逐步校验,任一步失败即中止 +``` + +## 已知边界 / 没做的部分 + +1. **没有一帧颜色数被降级**,也没有一帧保留原厂:61/61 都是「原调色板 + Lanczos」。 + 全帧色数 251~256(即源调色板的实际用量),吸附误差 0.189/255(最大 0.260,第 17 帧)。 +2. **没做锐化**:放大只用 Lanczos + 调色板吸附。这是刻意的 —— 锐化会增加索引噪点、 + 直接吃掉 RLE 余量(最紧的 `charge47` 只剩 10,488 B)。 +3. **真机未验证 S5 eSIM 的 `/dev/system` 可写**:这一点在 S4 eSIM 上实测通过(同源平台、 + 同一套 dd 流程),S5 eSIM 上只能先靠只读的「环境预检查」确认可读 + 偏移对版。 + `system/drain/*`、`ota/anim*`、`setupwizard/*` 同理可改,但同样未在本机验证。 +4. **偏移锁固件版本**:换固件必须重跑 `make_charge_payload.py all` → `make_dd_payload.py` → + `gen_ui_lua.py`。用错版本时表盘会判「版本不符」并跳过 `/dev/system` 剩余记录, + 不会把字节写进别人的文件里。 +5. **`Compiler.exe` 的设备表里没有 480 档**(枚举 DeviceType 只有 17 个已知值,圆表且预览 + 326×326 的只有 362 / 462)→ 这里用 `DeviceType=462` + 326×326 预览图,预览只是表盘列表 + 里的缩略图,与实际 480×480 画面无关(界面尺寸由 `main.lua` 与 `.fprj` 的 480 决定)。 +6. **负载文件必须原样连续放在 resource.bin 里**,偏移才等于 `文件基址 + 魔数长度 + 相对偏移`; + `gen_ui_lua.py` 会断言这一点并做两遍 0 漂移校验。魔数长度是 15 B(曾把 15 写成 16 导致全部偏移差 1)。 +7. **写入速度**:偏移都是 512 对齐,所以 195 KB 的记录按 `bs=512` + `bs=4` + `bs=1` 三段写, + 不是逐字节;`sync` + 整条回读比对,失败重试 3 次后改用整条一次 dd。 +8. **没动的**:`inode` 表、目录项、superblock、任何非 `charging/charge*.bin` 的文件; + 仓库里其它项目(`s4e-*`、`s5e-icons`、`s5e-pressure-anim`)与 git(未 commit、未改 `.gitignore`)。 + +## 目录 + +``` +main.lua 表盘源码(含 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 占位符,构建时回填) +tools/ 全部脚本(见下表) +ui_images/ 界面素材(LVGL v9 .bin,8 张,480 档尺寸) +assets/ 设计素材 PNG +dist/ 交付的 .face +docs/ 预览图、对照图、selfcheck_report.txt +inputs/ 固件分区镜像 + 字体(不进仓库,见上) +vendor/ LuaDevTemplate-main(含 Compiler.exe,不进仓库) +.work/ 构建工作区(产物,可删) +``` + +| 脚本 | 作用 | 相对 s4e-charge | +|---|---|---| +| `tools/paths.py` | 所有路径的唯一入口 | 沿用(未改) | +| `tools/romfs.py` | ROMFS 分区解析 | 沿用(未改) | +| `tools/lvgl_bin.py` | LVGL v9 RLE 编解码 + `pad_rle_safe` | 沿用(未改) | +| `tools/iconpack.py` | ARGB8888/I8 图片编码(界面素材用) | 沿用(未改) | +| `tools/lua_balance.py` | Lua 块配平检查 | 沿用(未改) | +| `tools/make_charge_payload.py` | **改**:目标/源换成 S5e + q63,61→61(n→n),输出 480×480,Lanczos+调色板吸附 | 重写 | +| `tools/make_dd_payload.py` | **改**:魔数 `S5C-PAYv1` / `S5C-FPv1`,脚本文案与设备名 | 小改 | +| `tools/make_overlays.py` | **改**:DeviceType 462 / Screen 480 / 预览 326(按首页规格真渲染) | 改 | +| `tools/build_faces.py` | **改**:盘名 S5Chg / S5ChgRestore,id 462150103 | 小改 | +| `tools/finalize_faces.py` | **改**:同上(b5=0 的忠实产物 + 改 id) | 小改 | +| `tools/gen_ui_lua.py` | **改**:盘名/角色名 | 小改 | +| `tools/verify_anim.py` | **改**:盘名 + 480×480 判据 | 小改 | +| `tools/verify_all.py` | **新增**:独立总校验(33 项,不读 `.work/`)+ 预览图 + 报告 | 新增 | +| `tools/selfcheck.ps1` | **新增**:一条命令跑完上面 8 步(逐步校验、失败即中止、输出留档) | 新增 | +| `tools/pages.py` | **改**:480×480 页面规格(含还原盘文案变体) | 重写 | +| `tools/render_ui.py` | **改**:画布 466→480,输出到 `docs/`,报标签溢框 | 小改 | +| `tools/check_clip.py` | **改**:圆屏 240/240,两套文案都过一遍 | 改 | +| `tools/make_ui_images.py` | **改**:素材尺寸换 480 档(31/18×29/80) | 改 | +| `main.lua` | **改**:480 几何、S5Chg 文案、版本不符**跳过分区剩余记录** | 重写 | diff --git a/s5e-charge/assets/MdiBandage.png b/s5e-charge/assets/MdiBandage.png new file mode 100644 index 0000000..26abc5b Binary files /dev/null and b/s5e-charge/assets/MdiBandage.png differ diff --git a/s5e-charge/assets/MdiClipboardTextSearchOutline.png b/s5e-charge/assets/MdiClipboardTextSearchOutline.png new file mode 100644 index 0000000..85ea2da Binary files /dev/null and b/s5e-charge/assets/MdiClipboardTextSearchOutline.png differ diff --git a/s5e-charge/assets/MdiInformationOutline.png b/s5e-charge/assets/MdiInformationOutline.png new file mode 100644 index 0000000..3af2a29 Binary files /dev/null and b/s5e-charge/assets/MdiInformationOutline.png differ diff --git a/s5e-charge/assets/MdiLightbulbOutline.png b/s5e-charge/assets/MdiLightbulbOutline.png new file mode 100644 index 0000000..7d7819c Binary files /dev/null and b/s5e-charge/assets/MdiLightbulbOutline.png differ diff --git a/s5e-charge/assets/MdiReplyAllOutline.png b/s5e-charge/assets/MdiReplyAllOutline.png new file mode 100644 index 0000000..f419de0 Binary files /dev/null and b/s5e-charge/assets/MdiReplyAllOutline.png differ diff --git a/s5e-charge/assets/backarrow.png b/s5e-charge/assets/backarrow.png new file mode 100644 index 0000000..9e55859 Binary files /dev/null and b/s5e-charge/assets/backarrow.png differ diff --git a/s5e-charge/assets/cover.png b/s5e-charge/assets/cover.png new file mode 100644 index 0000000..195e7e1 Binary files /dev/null and b/s5e-charge/assets/cover.png differ diff --git a/s5e-charge/assets/override/activities.png b/s5e-charge/assets/override/activities.png new file mode 100644 index 0000000..ae5c552 Binary files /dev/null and b/s5e-charge/assets/override/activities.png differ diff --git a/s5e-charge/assets/override/interconnect.png b/s5e-charge/assets/override/interconnect.png new file mode 100644 index 0000000..5739b3b Binary files /dev/null and b/s5e-charge/assets/override/interconnect.png differ diff --git a/s5e-charge/assets/override/sports_course.png b/s5e-charge/assets/override/sports_course.png new file mode 100644 index 0000000..4b95e98 Binary files /dev/null and b/s5e-charge/assets/override/sports_course.png differ diff --git a/s5e-charge/assets/override/voice_aivs.png b/s5e-charge/assets/override/voice_aivs.png new file mode 100644 index 0000000..228914f Binary files /dev/null and b/s5e-charge/assets/override/voice_aivs.png differ diff --git a/s5e-charge/assets/qq.png b/s5e-charge/assets/qq.png new file mode 100644 index 0000000..3982762 Binary files /dev/null and b/s5e-charge/assets/qq.png differ diff --git a/s5e-charge/assets/walkie_talkie/background.png b/s5e-charge/assets/walkie_talkie/background.png new file mode 100644 index 0000000..879c98f Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/background.png differ diff --git a/s5e-charge/assets/walkie_talkie/cancel_btn.png b/s5e-charge/assets/walkie_talkie/cancel_btn.png new file mode 100644 index 0000000..cc56188 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/cancel_btn.png differ diff --git a/s5e-charge/assets/walkie_talkie/check.png b/s5e-charge/assets/walkie_talkie/check.png new file mode 100644 index 0000000..bc89d28 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/check.png differ diff --git a/s5e-charge/assets/walkie_talkie/checked.png b/s5e-charge/assets/walkie_talkie/checked.png new file mode 100644 index 0000000..64bf2b9 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/checked.png differ diff --git a/s5e-charge/assets/walkie_talkie/close_btn.png b/s5e-charge/assets/walkie_talkie/close_btn.png new file mode 100644 index 0000000..a872247 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/close_btn.png differ diff --git a/s5e-charge/assets/walkie_talkie/confirm_btn.png b/s5e-charge/assets/walkie_talkie/confirm_btn.png new file mode 100644 index 0000000..932d711 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/confirm_btn.png differ diff --git a/s5e-charge/assets/walkie_talkie/delete_icon.png b/s5e-charge/assets/walkie_talkie/delete_icon.png new file mode 100644 index 0000000..b3d948d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/delete_icon.png differ diff --git a/s5e-charge/assets/walkie_talkie/guidance/finish_guidance.png b/s5e-charge/assets/walkie_talkie/guidance/finish_guidance.png new file mode 100644 index 0000000..932d711 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/guidance/finish_guidance.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim0.png b/s5e-charge/assets/walkie_talkie/history_anim/anim0.png new file mode 100644 index 0000000..cdbf172 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim0.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim1.png b/s5e-charge/assets/walkie_talkie/history_anim/anim1.png new file mode 100644 index 0000000..9eb6f8b Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim1.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim10.png b/s5e-charge/assets/walkie_talkie/history_anim/anim10.png new file mode 100644 index 0000000..927d24a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim10.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim11.png b/s5e-charge/assets/walkie_talkie/history_anim/anim11.png new file mode 100644 index 0000000..38d3985 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim11.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim12.png b/s5e-charge/assets/walkie_talkie/history_anim/anim12.png new file mode 100644 index 0000000..32c452c Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim12.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim13.png b/s5e-charge/assets/walkie_talkie/history_anim/anim13.png new file mode 100644 index 0000000..b4338f7 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim13.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim14.png b/s5e-charge/assets/walkie_talkie/history_anim/anim14.png new file mode 100644 index 0000000..6dd42f1 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim14.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim15.png b/s5e-charge/assets/walkie_talkie/history_anim/anim15.png new file mode 100644 index 0000000..e4795f0 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim15.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim16.png b/s5e-charge/assets/walkie_talkie/history_anim/anim16.png new file mode 100644 index 0000000..5cdb8ad Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim16.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim17.png b/s5e-charge/assets/walkie_talkie/history_anim/anim17.png new file mode 100644 index 0000000..9eb6f8b Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim17.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim18.png b/s5e-charge/assets/walkie_talkie/history_anim/anim18.png new file mode 100644 index 0000000..cdbf172 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim18.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim2.png b/s5e-charge/assets/walkie_talkie/history_anim/anim2.png new file mode 100644 index 0000000..5cdb8ad Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim2.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim3.png b/s5e-charge/assets/walkie_talkie/history_anim/anim3.png new file mode 100644 index 0000000..e4795f0 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim3.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim4.png b/s5e-charge/assets/walkie_talkie/history_anim/anim4.png new file mode 100644 index 0000000..6dd42f1 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim4.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim5.png b/s5e-charge/assets/walkie_talkie/history_anim/anim5.png new file mode 100644 index 0000000..b4338f7 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim5.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim6.png b/s5e-charge/assets/walkie_talkie/history_anim/anim6.png new file mode 100644 index 0000000..32c452c Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim6.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim7.png b/s5e-charge/assets/walkie_talkie/history_anim/anim7.png new file mode 100644 index 0000000..38d3985 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim7.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim8.png b/s5e-charge/assets/walkie_talkie/history_anim/anim8.png new file mode 100644 index 0000000..927d24a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim8.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim/anim9.png b/s5e-charge/assets/walkie_talkie/history_anim/anim9.png new file mode 100644 index 0000000..6d5503a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim/anim9.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim0.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim0.png new file mode 100644 index 0000000..cdbf172 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim0.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim1.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim1.png new file mode 100644 index 0000000..5d99f04 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim1.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim10.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim10.png new file mode 100644 index 0000000..493b920 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim10.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim11.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim11.png new file mode 100644 index 0000000..e596207 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim11.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim12.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim12.png new file mode 100644 index 0000000..44ccabf Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim12.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim13.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim13.png new file mode 100644 index 0000000..cc6d96c Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim13.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim14.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim14.png new file mode 100644 index 0000000..709fe42 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim14.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim15.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim15.png new file mode 100644 index 0000000..08c909e Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim15.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim16.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim16.png new file mode 100644 index 0000000..5b5a34a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim16.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim17.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim17.png new file mode 100644 index 0000000..5d99f04 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim17.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim18.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim18.png new file mode 100644 index 0000000..cdbf172 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim18.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim2.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim2.png new file mode 100644 index 0000000..5b5a34a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim2.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim3.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim3.png new file mode 100644 index 0000000..08c909e Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim3.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim4.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim4.png new file mode 100644 index 0000000..709fe42 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim4.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim5.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim5.png new file mode 100644 index 0000000..cc6d96c Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim5.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim6.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim6.png new file mode 100644 index 0000000..44ccabf Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim6.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim7.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim7.png new file mode 100644 index 0000000..e596207 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim7.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim8.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim8.png new file mode 100644 index 0000000..493b920 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim8.png differ diff --git a/s5e-charge/assets/walkie_talkie/history_anim_right/anim9.png b/s5e-charge/assets/walkie_talkie/history_anim_right/anim9.png new file mode 100644 index 0000000..2286ae4 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/history_anim_right/anim9.png differ diff --git a/s5e-charge/assets/walkie_talkie/icon_plus.png b/s5e-charge/assets/walkie_talkie/icon_plus.png new file mode 100644 index 0000000..a1117ab Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/icon_plus.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker0.png b/s5e-charge/assets/walkie_talkie/image/Speaker0.png new file mode 100644 index 0000000..fc91da7 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker0.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker1.png b/s5e-charge/assets/walkie_talkie/image/Speaker1.png new file mode 100644 index 0000000..66fc559 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker1.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker10.png b/s5e-charge/assets/walkie_talkie/image/Speaker10.png new file mode 100644 index 0000000..2bb9b59 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker10.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker100.png b/s5e-charge/assets/walkie_talkie/image/Speaker100.png new file mode 100644 index 0000000..521ed3e Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker100.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker101.png b/s5e-charge/assets/walkie_talkie/image/Speaker101.png new file mode 100644 index 0000000..9b955dd Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker101.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker102.png b/s5e-charge/assets/walkie_talkie/image/Speaker102.png new file mode 100644 index 0000000..7825c03 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker102.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker11.png b/s5e-charge/assets/walkie_talkie/image/Speaker11.png new file mode 100644 index 0000000..c4415dd Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker11.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker12.png b/s5e-charge/assets/walkie_talkie/image/Speaker12.png new file mode 100644 index 0000000..844818b Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker12.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker13.png b/s5e-charge/assets/walkie_talkie/image/Speaker13.png new file mode 100644 index 0000000..89029fd Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker13.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker14.png b/s5e-charge/assets/walkie_talkie/image/Speaker14.png new file mode 100644 index 0000000..221f19a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker14.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker15.png b/s5e-charge/assets/walkie_talkie/image/Speaker15.png new file mode 100644 index 0000000..4423df7 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker15.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker16.png b/s5e-charge/assets/walkie_talkie/image/Speaker16.png new file mode 100644 index 0000000..bd8aa8a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker16.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker17.png b/s5e-charge/assets/walkie_talkie/image/Speaker17.png new file mode 100644 index 0000000..9ea2e8b Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker17.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker18.png b/s5e-charge/assets/walkie_talkie/image/Speaker18.png new file mode 100644 index 0000000..fb5a10b Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker18.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker19.png b/s5e-charge/assets/walkie_talkie/image/Speaker19.png new file mode 100644 index 0000000..3ad3bf7 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker19.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker2.png b/s5e-charge/assets/walkie_talkie/image/Speaker2.png new file mode 100644 index 0000000..8601636 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker2.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker20.png b/s5e-charge/assets/walkie_talkie/image/Speaker20.png new file mode 100644 index 0000000..b6ea25b Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker20.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker21.png b/s5e-charge/assets/walkie_talkie/image/Speaker21.png new file mode 100644 index 0000000..14200fd Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker21.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker22.png b/s5e-charge/assets/walkie_talkie/image/Speaker22.png new file mode 100644 index 0000000..3d9c48f Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker22.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker23.png b/s5e-charge/assets/walkie_talkie/image/Speaker23.png new file mode 100644 index 0000000..e91fd3d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker23.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker24.png b/s5e-charge/assets/walkie_talkie/image/Speaker24.png new file mode 100644 index 0000000..393fc07 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker24.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker25.png b/s5e-charge/assets/walkie_talkie/image/Speaker25.png new file mode 100644 index 0000000..fa9e68e Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker25.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker26.png b/s5e-charge/assets/walkie_talkie/image/Speaker26.png new file mode 100644 index 0000000..6d43f7d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker26.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker27.png b/s5e-charge/assets/walkie_talkie/image/Speaker27.png new file mode 100644 index 0000000..0a65418 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker27.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker28.png b/s5e-charge/assets/walkie_talkie/image/Speaker28.png new file mode 100644 index 0000000..b0160e4 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker28.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker29.png b/s5e-charge/assets/walkie_talkie/image/Speaker29.png new file mode 100644 index 0000000..1a8c275 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker29.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker3.png b/s5e-charge/assets/walkie_talkie/image/Speaker3.png new file mode 100644 index 0000000..142da0d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker3.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker30.png b/s5e-charge/assets/walkie_talkie/image/Speaker30.png new file mode 100644 index 0000000..c07d68b Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker30.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker31.png b/s5e-charge/assets/walkie_talkie/image/Speaker31.png new file mode 100644 index 0000000..b5bf877 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker31.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker32.png b/s5e-charge/assets/walkie_talkie/image/Speaker32.png new file mode 100644 index 0000000..c7635aa Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker32.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker33.png b/s5e-charge/assets/walkie_talkie/image/Speaker33.png new file mode 100644 index 0000000..55c5c23 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker33.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker34.png b/s5e-charge/assets/walkie_talkie/image/Speaker34.png new file mode 100644 index 0000000..8291262 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker34.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker35.png b/s5e-charge/assets/walkie_talkie/image/Speaker35.png new file mode 100644 index 0000000..7299baa Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker35.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker36.png b/s5e-charge/assets/walkie_talkie/image/Speaker36.png new file mode 100644 index 0000000..ab8b08e Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker36.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker37.png b/s5e-charge/assets/walkie_talkie/image/Speaker37.png new file mode 100644 index 0000000..5f76111 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker37.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker38.png b/s5e-charge/assets/walkie_talkie/image/Speaker38.png new file mode 100644 index 0000000..41cb88e Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker38.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker39.png b/s5e-charge/assets/walkie_talkie/image/Speaker39.png new file mode 100644 index 0000000..72a5863 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker39.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker4.png b/s5e-charge/assets/walkie_talkie/image/Speaker4.png new file mode 100644 index 0000000..c7ad479 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker4.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker40.png b/s5e-charge/assets/walkie_talkie/image/Speaker40.png new file mode 100644 index 0000000..81c7391 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker40.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker41.png b/s5e-charge/assets/walkie_talkie/image/Speaker41.png new file mode 100644 index 0000000..9881ad6 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker41.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker42.png b/s5e-charge/assets/walkie_talkie/image/Speaker42.png new file mode 100644 index 0000000..7e9ec32 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker42.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker43.png b/s5e-charge/assets/walkie_talkie/image/Speaker43.png new file mode 100644 index 0000000..216afb2 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker43.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker44.png b/s5e-charge/assets/walkie_talkie/image/Speaker44.png new file mode 100644 index 0000000..873728e Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker44.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker45.png b/s5e-charge/assets/walkie_talkie/image/Speaker45.png new file mode 100644 index 0000000..70535de Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker45.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker46.png b/s5e-charge/assets/walkie_talkie/image/Speaker46.png new file mode 100644 index 0000000..43563a3 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker46.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker47.png b/s5e-charge/assets/walkie_talkie/image/Speaker47.png new file mode 100644 index 0000000..f7bd1b7 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker47.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker48.png b/s5e-charge/assets/walkie_talkie/image/Speaker48.png new file mode 100644 index 0000000..6a942f9 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker48.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker49.png b/s5e-charge/assets/walkie_talkie/image/Speaker49.png new file mode 100644 index 0000000..f65235c Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker49.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker5.png b/s5e-charge/assets/walkie_talkie/image/Speaker5.png new file mode 100644 index 0000000..8681f10 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker5.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker50.png b/s5e-charge/assets/walkie_talkie/image/Speaker50.png new file mode 100644 index 0000000..be90a63 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker50.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker51.png b/s5e-charge/assets/walkie_talkie/image/Speaker51.png new file mode 100644 index 0000000..d6de73f Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker51.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker52.png b/s5e-charge/assets/walkie_talkie/image/Speaker52.png new file mode 100644 index 0000000..8b58b2d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker52.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker53.png b/s5e-charge/assets/walkie_talkie/image/Speaker53.png new file mode 100644 index 0000000..7b3b206 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker53.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker54.png b/s5e-charge/assets/walkie_talkie/image/Speaker54.png new file mode 100644 index 0000000..2367008 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker54.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker55.png b/s5e-charge/assets/walkie_talkie/image/Speaker55.png new file mode 100644 index 0000000..dbdf016 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker55.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker56.png b/s5e-charge/assets/walkie_talkie/image/Speaker56.png new file mode 100644 index 0000000..1d3eacb Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker56.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker57.png b/s5e-charge/assets/walkie_talkie/image/Speaker57.png new file mode 100644 index 0000000..97c19e3 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker57.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker58.png b/s5e-charge/assets/walkie_talkie/image/Speaker58.png new file mode 100644 index 0000000..a7195d0 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker58.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker59.png b/s5e-charge/assets/walkie_talkie/image/Speaker59.png new file mode 100644 index 0000000..3e36cba Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker59.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker6.png b/s5e-charge/assets/walkie_talkie/image/Speaker6.png new file mode 100644 index 0000000..2503467 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker6.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker60.png b/s5e-charge/assets/walkie_talkie/image/Speaker60.png new file mode 100644 index 0000000..cb0e1b9 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker60.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker61.png b/s5e-charge/assets/walkie_talkie/image/Speaker61.png new file mode 100644 index 0000000..93b8905 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker61.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker62.png b/s5e-charge/assets/walkie_talkie/image/Speaker62.png new file mode 100644 index 0000000..d171dab Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker62.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker63.png b/s5e-charge/assets/walkie_talkie/image/Speaker63.png new file mode 100644 index 0000000..3bdfd0f Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker63.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker64.png b/s5e-charge/assets/walkie_talkie/image/Speaker64.png new file mode 100644 index 0000000..392093f Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker64.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker65.png b/s5e-charge/assets/walkie_talkie/image/Speaker65.png new file mode 100644 index 0000000..e26ca7c Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker65.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker66.png b/s5e-charge/assets/walkie_talkie/image/Speaker66.png new file mode 100644 index 0000000..57aff14 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker66.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker67.png b/s5e-charge/assets/walkie_talkie/image/Speaker67.png new file mode 100644 index 0000000..2b7281a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker67.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker68.png b/s5e-charge/assets/walkie_talkie/image/Speaker68.png new file mode 100644 index 0000000..cdb5592 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker68.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker69.png b/s5e-charge/assets/walkie_talkie/image/Speaker69.png new file mode 100644 index 0000000..dc7eba5 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker69.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker7.png b/s5e-charge/assets/walkie_talkie/image/Speaker7.png new file mode 100644 index 0000000..054a489 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker7.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker70.png b/s5e-charge/assets/walkie_talkie/image/Speaker70.png new file mode 100644 index 0000000..6bbe93d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker70.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker71.png b/s5e-charge/assets/walkie_talkie/image/Speaker71.png new file mode 100644 index 0000000..bc451fb Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker71.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker72.png b/s5e-charge/assets/walkie_talkie/image/Speaker72.png new file mode 100644 index 0000000..69b5ee7 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker72.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker73.png b/s5e-charge/assets/walkie_talkie/image/Speaker73.png new file mode 100644 index 0000000..909de00 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker73.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker74.png b/s5e-charge/assets/walkie_talkie/image/Speaker74.png new file mode 100644 index 0000000..a2d65b2 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker74.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker75.png b/s5e-charge/assets/walkie_talkie/image/Speaker75.png new file mode 100644 index 0000000..77d3cf6 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker75.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker76.png b/s5e-charge/assets/walkie_talkie/image/Speaker76.png new file mode 100644 index 0000000..4bacc59 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker76.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker77.png b/s5e-charge/assets/walkie_talkie/image/Speaker77.png new file mode 100644 index 0000000..3527380 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker77.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker78.png b/s5e-charge/assets/walkie_talkie/image/Speaker78.png new file mode 100644 index 0000000..f9cfe31 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker78.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker79.png b/s5e-charge/assets/walkie_talkie/image/Speaker79.png new file mode 100644 index 0000000..4583948 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker79.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker8.png b/s5e-charge/assets/walkie_talkie/image/Speaker8.png new file mode 100644 index 0000000..6b885ec Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker8.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker80.png b/s5e-charge/assets/walkie_talkie/image/Speaker80.png new file mode 100644 index 0000000..6dbe5d3 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker80.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker81.png b/s5e-charge/assets/walkie_talkie/image/Speaker81.png new file mode 100644 index 0000000..cdff971 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker81.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker82.png b/s5e-charge/assets/walkie_talkie/image/Speaker82.png new file mode 100644 index 0000000..304ccea Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker82.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker83.png b/s5e-charge/assets/walkie_talkie/image/Speaker83.png new file mode 100644 index 0000000..fe62e25 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker83.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker84.png b/s5e-charge/assets/walkie_talkie/image/Speaker84.png new file mode 100644 index 0000000..263f40c Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker84.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker85.png b/s5e-charge/assets/walkie_talkie/image/Speaker85.png new file mode 100644 index 0000000..060ae88 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker85.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker86.png b/s5e-charge/assets/walkie_talkie/image/Speaker86.png new file mode 100644 index 0000000..c8fec2a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker86.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker87.png b/s5e-charge/assets/walkie_talkie/image/Speaker87.png new file mode 100644 index 0000000..053ee6d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker87.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker88.png b/s5e-charge/assets/walkie_talkie/image/Speaker88.png new file mode 100644 index 0000000..15505f5 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker88.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker89.png b/s5e-charge/assets/walkie_talkie/image/Speaker89.png new file mode 100644 index 0000000..162e637 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker89.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker9.png b/s5e-charge/assets/walkie_talkie/image/Speaker9.png new file mode 100644 index 0000000..03b9b1e Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker9.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker90.png b/s5e-charge/assets/walkie_talkie/image/Speaker90.png new file mode 100644 index 0000000..ceec461 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker90.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker91.png b/s5e-charge/assets/walkie_talkie/image/Speaker91.png new file mode 100644 index 0000000..b3b35ae Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker91.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker92.png b/s5e-charge/assets/walkie_talkie/image/Speaker92.png new file mode 100644 index 0000000..3464b7d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker92.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker93.png b/s5e-charge/assets/walkie_talkie/image/Speaker93.png new file mode 100644 index 0000000..de800f6 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker93.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker94.png b/s5e-charge/assets/walkie_talkie/image/Speaker94.png new file mode 100644 index 0000000..51e6c2a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker94.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker95.png b/s5e-charge/assets/walkie_talkie/image/Speaker95.png new file mode 100644 index 0000000..a22dffe Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker95.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker96.png b/s5e-charge/assets/walkie_talkie/image/Speaker96.png new file mode 100644 index 0000000..f7151aa Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker96.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker97.png b/s5e-charge/assets/walkie_talkie/image/Speaker97.png new file mode 100644 index 0000000..eae1262 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker97.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker98.png b/s5e-charge/assets/walkie_talkie/image/Speaker98.png new file mode 100644 index 0000000..328931c Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker98.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/Speaker99.png b/s5e-charge/assets/walkie_talkie/image/Speaker99.png new file mode 100644 index 0000000..11a494e Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/Speaker99.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/go_next.png b/s5e-charge/assets/walkie_talkie/image/go_next.png new file mode 100644 index 0000000..e9a1d81 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/go_next.png differ diff --git a/s5e-charge/assets/walkie_talkie/image/stop.png b/s5e-charge/assets/walkie_talkie/image/stop.png new file mode 100644 index 0000000..b611709 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/image/stop.png differ diff --git a/s5e-charge/assets/walkie_talkie/launcher.png b/s5e-charge/assets/walkie_talkie/launcher.png new file mode 100644 index 0000000..9a611a9 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/launcher.png differ diff --git a/s5e-charge/assets/walkie_talkie/launcher1.png b/s5e-charge/assets/walkie_talkie/launcher1.png new file mode 100644 index 0000000..60aa469 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/launcher1.png differ diff --git a/s5e-charge/assets/walkie_talkie/listen.png b/s5e-charge/assets/walkie_talkie/listen.png new file mode 100644 index 0000000..e06f591 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/listen.png differ diff --git a/s5e-charge/assets/walkie_talkie/login_success.png b/s5e-charge/assets/walkie_talkie/login_success.png new file mode 100644 index 0000000..6e8c826 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/login_success.png differ diff --git a/s5e-charge/assets/walkie_talkie/next_btn.png b/s5e-charge/assets/walkie_talkie/next_btn.png new file mode 100644 index 0000000..415abc0 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/next_btn.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/default_icon.png b/s5e-charge/assets/walkie_talkie/poc/default_icon.png new file mode 100644 index 0000000..d5c33f0 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/default_icon.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/group_icon1.png b/s5e-charge/assets/walkie_talkie/poc/group_icon1.png new file mode 100644 index 0000000..9f22404 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/group_icon1.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/group_icon2.png b/s5e-charge/assets/walkie_talkie/poc/group_icon2.png new file mode 100644 index 0000000..0adef53 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/group_icon2.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/group_icon3.png b/s5e-charge/assets/walkie_talkie/poc/group_icon3.png new file mode 100644 index 0000000..4f1560b Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/group_icon3.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/group_icon4.png b/s5e-charge/assets/walkie_talkie/poc/group_icon4.png new file mode 100644 index 0000000..5bb064a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/group_icon4.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/group_icon5.png b/s5e-charge/assets/walkie_talkie/poc/group_icon5.png new file mode 100644 index 0000000..abd170d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/group_icon5.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/member_icon.png b/s5e-charge/assets/walkie_talkie/poc/member_icon.png new file mode 100644 index 0000000..d5c33f0 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/member_icon.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/no_sound.png b/s5e-charge/assets/walkie_talkie/poc/no_sound.png new file mode 100644 index 0000000..2244475 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/no_sound.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/say_button_mic_icon.png b/s5e-charge/assets/walkie_talkie/poc/say_button_mic_icon.png new file mode 100644 index 0000000..64e5ea1 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/say_button_mic_icon.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/sound.png b/s5e-charge/assets/walkie_talkie/poc/sound.png new file mode 100644 index 0000000..9b7b453 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/sound.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/talk_listen.png b/s5e-charge/assets/walkie_talkie/poc/talk_listen.png new file mode 100644 index 0000000..c9a9688 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/talk_listen.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/toast_listen.png b/s5e-charge/assets/walkie_talkie/poc/toast_listen.png new file mode 100644 index 0000000..c9a9688 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/toast_listen.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/toast_say.png b/s5e-charge/assets/walkie_talkie/poc/toast_say.png new file mode 100644 index 0000000..f5b5dec Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/toast_say.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/tools.png b/s5e-charge/assets/walkie_talkie/poc/tools.png new file mode 100644 index 0000000..e7cf9be Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/tools.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/voice_icon.png b/s5e-charge/assets/walkie_talkie/poc/voice_icon.png new file mode 100644 index 0000000..85e366d Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/voice_icon.png differ diff --git a/s5e-charge/assets/walkie_talkie/poc/voice_icon_rt.png b/s5e-charge/assets/walkie_talkie/poc/voice_icon_rt.png new file mode 100644 index 0000000..501320a Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/poc/voice_icon_rt.png differ diff --git a/s5e-charge/assets/walkie_talkie/speak.png b/s5e-charge/assets/walkie_talkie/speak.png new file mode 100644 index 0000000..1b715e3 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/speak.png differ diff --git a/s5e-charge/assets/walkie_talkie/walkie_talkie.png b/s5e-charge/assets/walkie_talkie/walkie_talkie.png new file mode 100644 index 0000000..9a611a9 Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/walkie_talkie.png differ diff --git a/s5e-charge/assets/walkie_talkie/walkie_toast_icon.png b/s5e-charge/assets/walkie_talkie/walkie_toast_icon.png new file mode 100644 index 0000000..81f4e3f Binary files /dev/null and b/s5e-charge/assets/walkie_talkie/walkie_toast_icon.png differ diff --git a/s5e-charge/dist/S5-Pt3.face b/s5e-charge/dist/S5-Pt3.face new file mode 100644 index 0000000..5e21b0d Binary files /dev/null and b/s5e-charge/dist/S5-Pt3.face differ diff --git a/s5e-charge/dist/S5ChgRestore.face b/s5e-charge/dist/S5ChgRestore.face new file mode 100644 index 0000000..a149336 Binary files /dev/null and b/s5e-charge/dist/S5ChgRestore.face differ diff --git a/s5e-charge/docs/before_after.png b/s5e-charge/docs/before_after.png new file mode 100644 index 0000000..f915420 Binary files /dev/null and b/s5e-charge/docs/before_after.png differ diff --git a/s5e-charge/docs/decode_preview.png b/s5e-charge/docs/decode_preview.png new file mode 100644 index 0000000..2391033 Binary files /dev/null and b/s5e-charge/docs/decode_preview.png differ diff --git a/s5e-charge/docs/face_before_after.png b/s5e-charge/docs/face_before_after.png new file mode 100644 index 0000000..b9d1f19 Binary files /dev/null and b/s5e-charge/docs/face_before_after.png differ diff --git a/s5e-charge/docs/logo_preview_480.png b/s5e-charge/docs/logo_preview_480.png new file mode 100644 index 0000000..d3b1071 Binary files /dev/null and b/s5e-charge/docs/logo_preview_480.png differ diff --git a/s5e-charge/docs/selfcheck_report.txt b/s5e-charge/docs/selfcheck_report.txt new file mode 100644 index 0000000..074aa08 --- /dev/null +++ b/s5e-charge/docs/selfcheck_report.txt @@ -0,0 +1,67 @@ +============================================================================== +S5eChg 独立总校验(只读 dist/*.face + inputs/*/vela_system.bin) +============================================================================== +目标镜像 vela_system.bin:84429824 字节,charging/ 槽位 61 个 +源镜像 vela_system.bin:61 帧 + +--- A 交付物形态 --- + S5Chg.face 11987588 字节 sha256=83e7ac4d7f83f685b68632a3608ac6739d58e9d33d911d92fa495f4c5bb53882 + sha256[:16]=83e7ac4d7f83f685 + [OK ] replace 魔数正确 + [OK ] replace id == 462150103 b'462150103\x00' + [OK ] replace b5 == 0 b5=0 + [OK ] replace 体积 < 20MB + [OK ] replace 占位符已回填 + S5ChgRestore.face 11987588 字节 sha256=098cb9570436079d492fe96fe841ae03903431f379feeb22c1230660d780284f + sha256[:16]=098cb9570436079d + [OK ] restore 魔数正确 + [OK ] restore id == 462150103 b'462150103\x00' + [OK ] restore b5 == 0 b5=0 + [OK ] restore 体积 < 20MB + [OK ] restore 占位符已回填 + +--- B Lua 表(从 .face 反解)--- + [OK ] replace 找到 pay / rb 表 + replace:pay 61 条 / rb 61 条 + [OK ] replace pay 条数 == 61 61 + [OK ] replace rb 条数 == 61 61 + [OK ] replace Lua 里的 ROLE == replace + [OK ] restore 找到 pay / rb 表 + restore:pay 61 条 / rb 61 条 + [OK ] restore pay 条数 == 61 61 + [OK ] restore rb 条数 == 61 61 + [OK ] restore Lua 里的 ROLE == restore + +--- C 等长 + 目标偏移 / D 可解码(480x480,恰好 usize)--- + [OK ] replace:61 条长度 == 原厂槽位长度 不符 0 条 + [OK ] replace:目标偏移一一对应 chargeN 错位 0 条 + [OK ] replace:61 条都解出 480x480 且 RLE 恰好 usize 不符 0 条 + [OK ] restore:61 条长度 == 原厂槽位长度 不符 0 条 + [OK ] restore:目标偏移一一对应 chargeN 错位 0 条 + [OK ] restore:61 条都解出 480x480 且 RLE 恰好 usize 不符 0 条 + +--- E 帧内容:与 q63 源帧的独立 Lanczos 放大比对 --- + 61 帧平均差:mean 0.189 max 0.260(第 17 帧) + [OK ] 每一帧与独立 Lanczos 基准的平均差 < 1.0/255 max=0.260 + 最像的源帧号与自身不一致的帧:0 个 + [OK ] 交付物第 n 帧就是 q63 第 n 帧(最多容忍 2 帧近邻歧义) 0 个不一致 + +--- F 版本指纹 / 还原字节 --- + [OK ] replace 盘 rb == 原厂前 64 B(对固件镜像比对) 不符 0 条 + [OK ] restore 盘 pay == 原厂整条字节(逐字节) 不符 0 条 + [OK ] restore 盘 rb == replace 盘 pay 前 64 B(两份交付物互证) 不符 0 条 + +--- G 整镜像(用 replace 盘负载重建改后镜像)--- + [OK ] inode 表逐位不变(2150 条) + [OK ] superblock 逐位不变 size=84429200 cksum=3275251810 + [OK ] 分区长度不变 + [OK ] 除 61 个槽位外没有任何字节变化 差异 11585280 字节,槽位外 0 字节 + [info] 与构建中间产物逐字节一致:True + +--- H 预览图(交付物解出来的帧)--- + 61 帧总览:D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-charge\docs\decode_preview.png + 抽样对照:D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-charge\docs\face_before_after.png + +============================================================================== +RESULT: 全部通过 —— 33 项通过,0 项失败 +============================================================================== diff --git a/s5e-charge/docs/selfcheck_run.txt b/s5e-charge/docs/selfcheck_run.txt new file mode 100644 index 0000000..6752de1 Binary files /dev/null and b/s5e-charge/docs/selfcheck_run.txt differ diff --git a/s5e-charge/docs/ui_about.png b/s5e-charge/docs/ui_about.png new file mode 100644 index 0000000..40b0796 Binary files /dev/null and b/s5e-charge/docs/ui_about.png differ diff --git a/s5e-charge/docs/ui_all.png b/s5e-charge/docs/ui_all.png new file mode 100644 index 0000000..55471a3 Binary files /dev/null and b/s5e-charge/docs/ui_all.png differ diff --git a/s5e-charge/docs/ui_alt_about.png b/s5e-charge/docs/ui_alt_about.png new file mode 100644 index 0000000..85c6972 Binary files /dev/null and b/s5e-charge/docs/ui_alt_about.png differ diff --git a/s5e-charge/docs/ui_alt_confirm.png b/s5e-charge/docs/ui_alt_confirm.png new file mode 100644 index 0000000..f500e25 Binary files /dev/null and b/s5e-charge/docs/ui_alt_confirm.png differ diff --git a/s5e-charge/docs/ui_alt_confirm_btn.png b/s5e-charge/docs/ui_alt_confirm_btn.png new file mode 100644 index 0000000..86923bd Binary files /dev/null and b/s5e-charge/docs/ui_alt_confirm_btn.png differ diff --git a/s5e-charge/docs/ui_alt_help_d1.png b/s5e-charge/docs/ui_alt_help_d1.png new file mode 100644 index 0000000..b0c9b0f Binary files /dev/null and b/s5e-charge/docs/ui_alt_help_d1.png differ diff --git a/s5e-charge/docs/ui_alt_help_tip.png b/s5e-charge/docs/ui_alt_help_tip.png new file mode 100644 index 0000000..3406280 Binary files /dev/null and b/s5e-charge/docs/ui_alt_help_tip.png differ diff --git a/s5e-charge/docs/ui_alt_home.png b/s5e-charge/docs/ui_alt_home.png new file mode 100644 index 0000000..cbe649a Binary files /dev/null and b/s5e-charge/docs/ui_alt_home.png differ diff --git a/s5e-charge/docs/ui_alt_home_item2.png b/s5e-charge/docs/ui_alt_home_item2.png new file mode 100644 index 0000000..9bb88ad Binary files /dev/null and b/s5e-charge/docs/ui_alt_home_item2.png differ diff --git a/s5e-charge/docs/ui_alt_running.png b/s5e-charge/docs/ui_alt_running.png new file mode 100644 index 0000000..2c3be6c Binary files /dev/null and b/s5e-charge/docs/ui_alt_running.png differ diff --git a/s5e-charge/docs/ui_confirm.png b/s5e-charge/docs/ui_confirm.png new file mode 100644 index 0000000..d431aed Binary files /dev/null and b/s5e-charge/docs/ui_confirm.png differ diff --git a/s5e-charge/docs/ui_help.png b/s5e-charge/docs/ui_help.png new file mode 100644 index 0000000..5ea872f Binary files /dev/null and b/s5e-charge/docs/ui_help.png differ diff --git a/s5e-charge/docs/ui_help_err.png b/s5e-charge/docs/ui_help_err.png new file mode 100644 index 0000000..280f198 Binary files /dev/null and b/s5e-charge/docs/ui_help_err.png differ diff --git a/s5e-charge/docs/ui_help_ok.png b/s5e-charge/docs/ui_help_ok.png new file mode 100644 index 0000000..e0431d3 Binary files /dev/null and b/s5e-charge/docs/ui_help_ok.png differ diff --git a/s5e-charge/docs/ui_help_tip.png b/s5e-charge/docs/ui_help_tip.png new file mode 100644 index 0000000..8984672 Binary files /dev/null and b/s5e-charge/docs/ui_help_tip.png differ diff --git a/s5e-charge/docs/ui_home.png b/s5e-charge/docs/ui_home.png new file mode 100644 index 0000000..6453c98 Binary files /dev/null and b/s5e-charge/docs/ui_home.png differ diff --git a/s5e-charge/docs/ui_running.png b/s5e-charge/docs/ui_running.png new file mode 100644 index 0000000..8d42334 Binary files /dev/null and b/s5e-charge/docs/ui_running.png differ diff --git a/s5e-charge/docs/ui_running_end.png b/s5e-charge/docs/ui_running_end.png new file mode 100644 index 0000000..e4340cb Binary files /dev/null and b/s5e-charge/docs/ui_running_end.png differ diff --git a/s5e-charge/flash.sh b/s5e-charge/flash.sh new file mode 100644 index 0000000..aff5738 --- /dev/null +++ b/s5e-charge/flash.sh @@ -0,0 +1,319 @@ +#!/bin/sh +# S5 41mm 充电动画 -> S5 eSIM 46mm(只写负载) +# 把 flash.sh 与 payload/ 放到同一个目录,例如 /data/s5panim/ +# 然后: sh /data/s5panim/flash.sh 最后: reboot +# 还原用 restore.sh 或安装还原盘 S5ChgRestore.face + +DEV=/dev/system +BASE=/data/s5panim/payload + +echo "target check" +ls -l $DEV + +echo "== charge0 == +dd if=$BASE/000_charge0.bin of=$DEV bs=1 seek=84231168 count=197959 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=84231168 count=197959 +cmp /tmp/chk.bin $BASE/000_charge0.bin + +echo "== charge1 == +dd if=$BASE/001_charge1.bin of=$DEV bs=4 seek=21008000 count=49712 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=21008000 count=49712 +cmp /tmp/chk.bin $BASE/001_charge1.bin + +echo "== charge2 == +dd if=$BASE/002_charge2.bin of=$DEV bs=4 seek=20459008 count=49835 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20459008 count=49835 +cmp /tmp/chk.bin $BASE/002_charge2.bin + +echo "== charge3 == +dd if=$BASE/003_charge3.bin of=$DEV bs=1 seek=79569408 count=198921 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=79569408 count=198921 +cmp /tmp/chk.bin $BASE/003_charge3.bin + +echo "== charge4 == +dd if=$BASE/004_charge4.bin of=$DEV bs=4 seek=19341952 count=49966 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19341952 count=49966 +cmp /tmp/chk.bin $BASE/004_charge4.bin + +echo "== charge5 == +dd if=$BASE/005_charge5.bin of=$DEV bs=1 seek=75496960 count=199227 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=75496960 count=199227 +cmp /tmp/chk.bin $BASE/005_charge5.bin + +echo "== charge6 == +dd if=$BASE/006_charge6.bin of=$DEV bs=1 seek=73385984 count=197829 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73385984 count=197829 +cmp /tmp/chk.bin $BASE/006_charge6.bin + +echo "== charge7 == +dd if=$BASE/007_charge7.bin of=$DEV bs=1 seek=72987648 count=196511 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=72987648 count=196511 +cmp /tmp/chk.bin $BASE/007_charge7.bin + +echo "== charge8 == +dd if=$BASE/008_charge8.bin of=$DEV bs=4 seek=18197888 count=48897 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=18197888 count=48897 +cmp /tmp/chk.bin $BASE/008_charge8.bin + +echo "== charge9 == +dd if=$BASE/009_charge9.bin of=$DEV bs=1 seek=72595456 count=196017 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=72595456 count=196017 +cmp /tmp/chk.bin $BASE/009_charge9.bin + +echo "== charge10 == +dd if=$BASE/010_charge10.bin of=$DEV bs=4 seek=20958080 count=49823 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20958080 count=49823 +cmp /tmp/chk.bin $BASE/010_charge10.bin + +echo "== charge11 == +dd if=$BASE/011_charge11.bin of=$DEV bs=4 seek=20908800 count=49236 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20908800 count=49236 +cmp /tmp/chk.bin $BASE/011_charge11.bin + +echo "== charge12 == +dd if=$BASE/012_charge12.bin of=$DEV bs=4 seek=20860032 count=48700 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20860032 count=48700 +cmp /tmp/chk.bin $BASE/012_charge12.bin + +echo "== charge13 == +dd if=$BASE/013_charge13.bin of=$DEV bs=1 seek=83244032 count=195933 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=83244032 count=195933 +cmp /tmp/chk.bin $BASE/013_charge13.bin + +echo "== charge14 == +dd if=$BASE/014_charge14.bin of=$DEV bs=4 seek=20761856 count=49022 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20761856 count=49022 +cmp /tmp/chk.bin $BASE/014_charge14.bin + +echo "== charge15 == +dd if=$BASE/015_charge15.bin of=$DEV bs=4 seek=20713088 count=48723 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20713088 count=48723 +cmp /tmp/chk.bin $BASE/015_charge15.bin + +echo "== charge16 == +dd if=$BASE/016_charge16.bin of=$DEV bs=1 seek=82649088 count=203070 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=82649088 count=203070 +cmp /tmp/chk.bin $BASE/016_charge16.bin + +echo "== charge17 == +dd if=$BASE/017_charge17.bin of=$DEV bs=1 seek=82446336 count=202394 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=82446336 count=202394 +cmp /tmp/chk.bin $BASE/017_charge17.bin + +echo "== charge18 == +dd if=$BASE/018_charge18.bin of=$DEV bs=1 seek=82244608 count=201551 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=82244608 count=201551 +cmp /tmp/chk.bin $BASE/018_charge18.bin + +echo "== charge19 == +dd if=$BASE/019_charge19.bin of=$DEV bs=1 seek=82035712 count=208701 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=82035712 count=208701 +cmp /tmp/chk.bin $BASE/019_charge19.bin + +echo "== charge20 == +dd if=$BASE/020_charge20.bin of=$DEV bs=1 seek=81623040 count=212929 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=81623040 count=212929 +cmp /tmp/chk.bin $BASE/020_charge20.bin + +echo "== charge21 == +dd if=$BASE/021_charge21.bin of=$DEV bs=1 seek=81409024 count=213511 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=81409024 count=213511 +cmp /tmp/chk.bin $BASE/021_charge21.bin + +echo "== charge22 == +dd if=$BASE/022_charge22.bin of=$DEV bs=4 seek=20299520 count=52636 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20299520 count=52636 +cmp /tmp/chk.bin $BASE/022_charge22.bin + +echo "== charge23 == +dd if=$BASE/023_charge23.bin of=$DEV bs=1 seek=80989696 count=208113 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=80989696 count=208113 +cmp /tmp/chk.bin $BASE/023_charge23.bin + +echo "== charge24 == +dd if=$BASE/024_charge24.bin of=$DEV bs=1 seek=80780288 count=209043 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=80780288 count=209043 +cmp /tmp/chk.bin $BASE/024_charge24.bin + +echo "== charge25 == +dd if=$BASE/025_charge25.bin of=$DEV bs=4 seek=20144128 count=50926 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20144128 count=50926 +cmp /tmp/chk.bin $BASE/025_charge25.bin + +echo "== charge26 == +dd if=$BASE/026_charge26.bin of=$DEV bs=4 seek=20094080 count=50034 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20094080 count=50034 +cmp /tmp/chk.bin $BASE/026_charge26.bin + +echo "== charge27 == +dd if=$BASE/027_charge27.bin of=$DEV bs=1 seek=80176128 count=199722 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=80176128 count=199722 +cmp /tmp/chk.bin $BASE/027_charge27.bin + +echo "== charge28 == +dd if=$BASE/028_charge28.bin of=$DEV bs=4 seek=19993344 count=50579 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19993344 count=50579 +cmp /tmp/chk.bin $BASE/028_charge28.bin + +echo "== charge29 == +dd if=$BASE/029_charge29.bin of=$DEV bs=1 seek=79768576 count=204746 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=79768576 count=204746 +cmp /tmp/chk.bin $BASE/029_charge29.bin + +echo "== charge30 == +dd if=$BASE/030_charge30.bin of=$DEV bs=4 seek=19840896 count=51412 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19840896 count=51412 +cmp /tmp/chk.bin $BASE/030_charge30.bin + +echo "== charge31 == +dd if=$BASE/031_charge31.bin of=$DEV bs=1 seek=79160320 count=203001 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=79160320 count=203001 +cmp /tmp/chk.bin $BASE/031_charge31.bin + +echo "== charge32 == +dd if=$BASE/032_charge32.bin of=$DEV bs=1 seek=78957056 count=203131 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=78957056 count=203131 +cmp /tmp/chk.bin $BASE/032_charge32.bin + +echo "== charge33 == +dd if=$BASE/033_charge33.bin of=$DEV bs=4 seek=19687680 count=51562 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19687680 count=51562 +cmp /tmp/chk.bin $BASE/033_charge33.bin + +echo "== charge34 == +dd if=$BASE/034_charge34.bin of=$DEV bs=4 seek=19636864 count=50739 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19636864 count=50739 +cmp /tmp/chk.bin $BASE/034_charge34.bin + +echo "== charge35 == +dd if=$BASE/035_charge35.bin of=$DEV bs=4 seek=19587328 count=49490 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19587328 count=49490 +cmp /tmp/chk.bin $BASE/035_charge35.bin + +echo "== charge36 == +dd if=$BASE/036_charge36.bin of=$DEV bs=4 seek=19537792 count=49525 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19537792 count=49525 +cmp /tmp/chk.bin $BASE/036_charge36.bin + +echo "== charge37 == +dd if=$BASE/037_charge37.bin of=$DEV bs=1 seek=77954048 count=196965 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=77954048 count=196965 +cmp /tmp/chk.bin $BASE/037_charge37.bin + +echo "== charge38 == +dd if=$BASE/038_charge38.bin of=$DEV bs=4 seek=19439616 count=48761 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19439616 count=48761 +cmp /tmp/chk.bin $BASE/038_charge38.bin + +echo "== charge39 == +dd if=$BASE/039_charge39.bin of=$DEV bs=1 seek=77568000 count=189981 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=77568000 count=189981 +cmp /tmp/chk.bin $BASE/039_charge39.bin + +echo "== charge40 == +dd if=$BASE/040_charge40.bin of=$DEV bs=1 seek=77182976 count=184335 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=77182976 count=184335 +cmp /tmp/chk.bin $BASE/040_charge40.bin + +echo "== charge41 == +dd if=$BASE/041_charge41.bin of=$DEV bs=1 seek=76999680 count=183226 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=76999680 count=183226 +cmp /tmp/chk.bin $BASE/041_charge41.bin + +echo "== charge42 == +dd if=$BASE/042_charge42.bin of=$DEV bs=4 seek=19204736 count=45059 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19204736 count=45059 +cmp /tmp/chk.bin $BASE/042_charge42.bin + +echo "== charge43 == +dd if=$BASE/043_charge43.bin of=$DEV bs=1 seek=76642304 count=176325 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=76642304 count=176325 +cmp /tmp/chk.bin $BASE/043_charge43.bin + +echo "== charge44 == +dd if=$BASE/044_charge44.bin of=$DEV bs=4 seek=19117696 count=42805 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19117696 count=42805 +cmp /tmp/chk.bin $BASE/044_charge44.bin + +echo "== charge45 == +dd if=$BASE/045_charge45.bin of=$DEV bs=1 seek=76307968 count=162630 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=76307968 count=162630 +cmp /tmp/chk.bin $BASE/045_charge45.bin + +echo "== charge46 == +dd if=$BASE/046_charge46.bin of=$DEV bs=4 seek=19041024 count=35958 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19041024 count=35958 +cmp /tmp/chk.bin $BASE/046_charge46.bin + +echo "== charge47 == +dd if=$BASE/047_charge47.bin of=$DEV bs=1 seek=76019200 count=144563 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=76019200 count=144563 +cmp /tmp/chk.bin $BASE/047_charge47.bin + +echo "== charge48 == +dd if=$BASE/048_charge48.bin of=$DEV bs=1 seek=75868672 count=150019 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=75868672 count=150019 +cmp /tmp/chk.bin $BASE/048_charge48.bin + +echo "== charge49 == +dd if=$BASE/049_charge49.bin of=$DEV bs=1 seek=75696640 count=171665 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=75696640 count=171665 +cmp /tmp/chk.bin $BASE/049_charge49.bin + +echo "== charge50 == +dd if=$BASE/050_charge50.bin of=$DEV bs=4 seek=18829824 count=44284 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=18829824 count=44284 +cmp /tmp/chk.bin $BASE/050_charge50.bin + +echo "== charge51 == +dd if=$BASE/051_charge51.bin of=$DEV bs=1 seek=75135488 count=183445 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=75135488 count=183445 +cmp /tmp/chk.bin $BASE/051_charge51.bin + +echo "== charge52 == +dd if=$BASE/052_charge52.bin of=$DEV bs=4 seek=18737280 count=46476 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=18737280 count=46476 +cmp /tmp/chk.bin $BASE/052_charge52.bin + +echo "== charge53 == +dd if=$BASE/053_charge53.bin of=$DEV bs=1 seek=74759680 count=189246 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=74759680 count=189246 +cmp /tmp/chk.bin $BASE/053_charge53.bin + +echo "== charge54 == +dd if=$BASE/054_charge54.bin of=$DEV bs=1 seek=74567680 count=191681 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=74567680 count=191681 +cmp /tmp/chk.bin $BASE/054_charge54.bin + +echo "== charge55 == +dd if=$BASE/055_charge55.bin of=$DEV bs=1 seek=74374656 count=192782 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=74374656 count=192782 +cmp /tmp/chk.bin $BASE/055_charge55.bin + +echo "== charge56 == +dd if=$BASE/056_charge56.bin of=$DEV bs=1 seek=74179584 count=195037 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=74179584 count=195037 +cmp /tmp/chk.bin $BASE/056_charge56.bin + +echo "== charge57 == +dd if=$BASE/057_charge57.bin of=$DEV bs=1 seek=73982464 count=197081 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73982464 count=197081 +cmp /tmp/chk.bin $BASE/057_charge57.bin + +echo "== charge58 == +dd if=$BASE/058_charge58.bin of=$DEV bs=1 seek=73783808 count=198594 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73783808 count=198594 +cmp /tmp/chk.bin $BASE/058_charge58.bin + +echo "== charge59 == +dd if=$BASE/059_charge59.bin of=$DEV bs=1 seek=73584128 count=199473 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73584128 count=199473 +cmp /tmp/chk.bin $BASE/059_charge59.bin + +echo "== charge60 == +dd if=$BASE/060_charge60.bin of=$DEV bs=1 seek=73184256 count=201487 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73184256 count=201487 +cmp /tmp/chk.bin $BASE/060_charge60.bin + +rm /tmp/chk.bin +echo "all done - run reboot to apply" diff --git a/s5e-charge/main.lua b/s5e-charge/main.lua new file mode 100644 index 0000000..e56bc46 --- /dev/null +++ b/s5e-charge/main.lua @@ -0,0 +1,774 @@ +--[[---------------------------------------------------------------------------- + S5Chg —— S5 41mm(q63) 充电动画 -> S5 eSIM 46mm(p62lte) + + 写入目标:/dev/system(ROMFS,卷名 system)里的 charging/charge0..60.bin + 61 帧、480x480、I8 + RLE(cf=0x0a, flags=0x08),每帧槽位长度各不相同 + (143,832 ~ 213,511 B)—— 负载**逐字节等长**,所以 inode 表与后面所有文件的 + 绝对偏移一个字节都不动。 + + 界面按 480x480 设计稿落地(tools/pages.py,本机用真 MiSans 渲染 + 真实像素判 + 圆屏切边,17 个页面含 replace/restore 两套文案:0 越界、0 折行)。 + + 两份盘共用同一个 watchface id 462150103: + S5Chg.face 只装"替换后"的 61 帧 + 原厂 64B 指纹 + S5ChgRestore.face 只装"原厂"的 61 帧 + 替换后 64B 指纹 + 装其中一份就顶掉另一份,设备上始终只占一份空间。 + + 真机已验证的底层能力(沿用 S5->S4 那个表盘): + * 宿主不调用 ui.init -> 加载期建界面 + * SCRIPT_PATH 为空 -> 用 debug.getinfo 反推目录 + * .face 不解包,资源仍在 resource.bin 里 -> 按偏移读出来用 + * /dev/system 可写,dd 读写校验通过 + * 没有 utf8 标准库 -> 自带按位 UTF-8 解码(本文件未用到,留作提醒) +------------------------------------------------------------------------------]] + +local lvgl = require("lvgl") + +local W, H = 480, 480 +local DEV, BLK = "/dev/system", 4 +local TMPH = "/tmp/s5c_head.bin" -- 版本校验读出的前 64 字节 +local TMPR = "/tmp/s5c_back.bin" -- 写完之后回读出来的那一份 +local TMPB = "/tmp/s5c_blob.bin" -- 待写入的负载(从 resource.bin 抠出来) + +-- 等长覆盖只保证「偏移」512 对齐;RLE 素材的长度常常不是 4 的倍数, +-- 那种记录必须退化成 bs=1(偏移本身对齐,所以 bs=1 也精确),否则会多写相邻文件的字节。 +local function ddBlk(off, len) + if off % BLK == 0 and len % BLK == 0 then return BLK end + return 1 +end + +--------------------------------------------------------------------- 自身目录 +local SRC = "" +if debug and debug.getinfo then + local info = debug.getinfo(1, "S") + SRC = (info and info.source) or "" +end +if type(SRC) ~= "string" then SRC = "" end +if SRC:sub(1, 1) == "@" then SRC = SRC:sub(2) end +SRC = SRC:gsub("\\", "/") +local THIS_DIR = SRC:match("^(.*)/[^/]+$") or "" +local SP = THIS_DIR ~= "" and (THIS_DIR .. "/") or "" +local PARENT = SP:gsub("[^/]+/$", "") + +local RES = nil +do + local c = { PARENT .. "resource.bin", SP .. "../resource.bin", SP .. "resource.bin" } + for i = 1, #c do + local f = io.open(c[i], "rb") + if f then f:close(); RES = c[i]; break end + end +end + +--------------------------------------------------------------------- 资源偏移 +-- UI 图片 { resource.bin 内偏移, 长度, 名字 } —— 由 tools/gen_ui_lua.py 回填 +local UIIMG = { +__UIIMG__ +} + +-- 角色:'replace' = 只负责替换(写 q63 的帧);'restore' = 只负责还原(写回原厂字节) +-- 两份盘共用同一个 watchface id,所以设备上只会占一份空间(见 README) +local ROLE = '__ROLE__' + +-- 负载表 { resource 内偏移, /dev/system 内偏移, 长度 } —— 由脚本回填 +-- pay = 本盘要写进去的字节;rb = **另一侧字节前 64 B 的指纹**(只给版本校验用) +local PAYTBL = { +__PAY__ +__RB__ +} + +local function prepImages() + local paths = {} + if not RES then return paths end + local f = io.open(RES, "rb") + if not f then return paths end + for i = 1, #UIIMG do + local e = UIIMG[i] + f:seek("set", e[1]) + local d = f:read(e[2]) + if d and #d == e[2] then + -- 直接写到 /tmp 根下:不能写子目录(目录不存在,且这台机器的 Lua 没有 mkdir) + local p = "/tmp/s5c_ui_" .. e[3] .. ".bin" + local o = io.open(p, "wb") + if o then o:write(d); o:close(); paths[e[3]] = p end + end + end + f:close() + return paths +end + +local IMG = prepImages() + +--------------------------------------------------------------------- 颜色与字体 +local C_GRAY, C_WHITE = '#999999', '#ffffff' +local C_OK, C_ERR = '#43cf7c', '#d43030' +local C_PLATE, C_CIRC = 0x002927, 0x4f4f4f + +local function getFont(name, size) + local f = nil + pcall(function() f = lvgl.Font(name, size) end) + return f +end + +local F_CLOCK = getFont('MiSans-Semibold', 29) +local F_TITLE = getFont('MiSans-Medium', 31) +local F_ITEM = getFont('MiSans-Medium', 29) +local F_BODY = getFont('MiSans-Medium', 25) +local F_VER = getFont('MiSans-Medium', 21) + +--------------------------------------------------------------------- 按下高亮 +-- onPressed / onClicked 是真实的 LVGL 对象方法(就在 onevent 旁边,属性表里可查) +local HL = 0x005149 -- 按下高亮色(比底板 0x002927 明显亮) +local pressed = nil -- { obj = , t = } +local tickCount = 0 + +local function setBg(obj, c) + pcall(function() obj:set { bg_color = c } end) +end + +local function pressRelease() + if pressed then + setBg(pressed.obj, C_PLATE) + pressed = nil + end +end + +--------------------------------------------------------------------- 基础 +local ui = {} +local built = false +local pages = {} +local clocks = {} +local job = { run = false, list = nil, i = 0, ok = 0, errs = 0, vers = 0, + skipRest = false, variant = 'replace' } +-- 从哪个入口进"开始替换"确认页,确认按钮就执行对应操作: +-- 首页「开始替换」 -> 'replace' +-- 帮助页「回滚修改」 -> 'tip'(拆盘后本盘没有回滚数据,只提示装另一份盘) +local pendingVariant = 'replace' +-- 确认页是从哪进来的,返回就回哪: +-- 首页「开始替换」 -> 'home' ; 帮助页「回滚修改」 -> 'help' +local confirmFrom = 'home' + +local function newPage() + local p = nil + pcall(function() + p = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000, + bg_opa = 255, border_width = 0, pad_all = 0 }) + p:clear_flag(lvgl.FLAG.SCROLLABLE) + p:add_flag(lvgl.FLAG.EVENT_BUBBLE) + end) + return p +end + +local function show(p) + if not p then return end + pressRelease() -- 切页时清掉可能残留的高亮 + for k, v in pairs(pages) do + pcall(function() v:add_flag(lvgl.FLAG.HIDDEN) end) + end + pcall(function() p:clear_flag(lvgl.FLAG.HIDDEN) end) +end + +local function bind(obj, fn) + if not obj then return end + -- 成品样本用的是 onevent(lvgl.EVENT.SHORT_CLICKED, fn)(CLICKED 这个常量 + -- 在这台机器上不存在),所以按 SHORT_CLICKED -> onClicked -> CLICKED 依次试 + local ev = lvgl.EVENT + if ev and ev.SHORT_CLICKED then + if pcall(function() obj:onevent(ev.SHORT_CLICKED, function() fn() end) end) then return end + end + if pcall(function() obj:onClicked(function() fn() end) end) then return end + pcall(function() obj:onevent(ev and ev.CLICKED, fn) end) +end + +--------------------------------------------------------------------- 点按 +-- 点按 = 按下高亮 + 抬起恢复 + 执行动作;带超时兜底(手指滑出时 click 不触发) +local function bindTap(obj, onTap) + if not obj then return end + local ok = pcall(function() + obj:onPressed(function() + setBg(obj, HL) + pressed = { obj = obj, t = tickCount } + end) + obj:onClicked(function() + pressRelease() + if onTap then onTap() end + end) + end) + if not ok then bind(obj, onTap) end +end + +local function setClickable(o) + if not o then return end + pcall(function() o:add_flag(lvgl.FLAG.CLICKABLE) end) +end + +local function lab(parent, o) + o.text_font = o.text_font or F_BODY + local l = nil + pcall(function() l = parent:Label(o) end) + return l +end + +-- 时钟:居中不用 text_align(它要的是 lv_text_align_t,传 ALIGN.CENTER 越界会被忽略 +-- -> 实测变成左对齐)。改为按真字体量出的宽度手工算 x:'10:24' 29px 量得 78 +local function clockLabel(parent) + local l = lab(parent, { x = 201, y = 4, w = 78, h = 46, text = '--:--', + text_color = C_GRAY, font_size = 29, text_font = F_CLOCK }) + clocks[#clocks + 1] = l + return l +end + +-- 标题:框 y=36 h=46,字号 31 行高 42 -> 从框顶画即可(LVGL 标签从框顶起排) +local function pageTitle(parent, text, x, w) + return lab(parent, { x = x, y = 36, w = w, h = 46, text = text, + text_color = C_WHITE, font_size = 31, text_font = F_TITLE }) +end + +local function backArrow(parent, x, onTap) + local im = nil + if IMG.back then + pcall(function() im = parent:Image { src = IMG.back, x = x or 167, y = 44 } end) + setClickable(im) + bind(im, onTap) + end + return im +end + +-- 列表项:底板(整块可点) + 圆形遮罩 + 图标 + 标题 +-- pad_all=0 是关键:LVGL 主题会给 Object 加内边距,不清零的话子元素会整体右下偏移 +local function listItem(parent, y, iconkey, title, onTap) + local plate = nil + pcall(function() + plate = parent:Object { x = 50, y = y, w = 379, h = 91, pad_all = 0, + bg_color = C_PLATE, border_width = 0, radius = 35 } + end) + if plate then + if onTap then + setClickable(plate) + bindTap(plate, onTap) + end + pcall(function() + plate:Object { x = 21, y = 20, w = 52, h = 52, + bg_color = C_CIRC, border_width = 0, radius = 26 } + end) + if IMG[iconkey] then + pcall(function() plate:Image { src = IMG[iconkey], x = 31, y = 30 } end) + end + -- 标题 29px 行高 40,框高 40 -> 从框顶画就是垂直居中 + lab(plate, { x = 82, y = 26, w = 214, h = 40, text = title, + text_color = C_WHITE, font_size = 29, text_font = F_ITEM }) + end + return plate +end + +-- 大按钮 258x82 圆角 41:圆角把四角收进圆内(本机像素判据实测 0 越界) +local function bigButton(parent, y, text) + local b = nil + pcall(function() + b = parent:Object { x = 111, y = y, w = 258, h = 82, pad_all = 0, + bg_color = C_PLATE, border_width = 0, radius = 41 } + end) + if b then setClickable(b) end + -- 按钮文字:框 (71,22,126,40) 相对底板;'确认替换'/'重启手表' 29px 量得 116 + -- -> 居中 x = 111 + (258-116)/2 - 111 = 71 + lab(b, { x = 71, y = 22, w = 126, h = 40, text = text, + text_color = C_WHITE, font_size = 29, text_font = F_ITEM }) + return b +end + +local function clockTick() + local t = '--:--' + if os and type(os.date) == "function" then + local ok, s = pcall(os.date, "%H:%M") + if ok and type(s) == "string" then t = s end + end + for i = 1, #clocks do + pcall(function() clocks[i]:set { text = t } end) + end +end + +--------------------------------------------------------------------- 外壳 +local function slurp(p, n) + local f = io.open(p, "rb") + if not f then return nil end + local d = f:read(n or "*a") + f:close() + return d +end + +local function sh(cmd) + if type(os.execute) ~= "function" then return -1 end + local a, b, c = os.execute(cmd) + if type(a) == "number" then return a end + if a == true then return 0 end + if type(c) == "number" then return c end + if type(b) == "number" then return b end + return -1 +end + +-- 从 resource.bin 里取一段(负载 / 另一侧指纹) +local function slice(off, len) + if not RES then return nil end + local f = io.open(RES, "rb") + if not f then return nil end + f:seek("set", off) + local d = f:read(len) + f:close() + return d +end + +-- 读设备上某偏移处的 len 字节(只读)。偏移 512 对齐时用分段大块,快两个数量级。 +local BIG = 512 + +local function segs(off, len) + local out = {} + if off % BIG == 0 then + local head = len - (len % BIG) + if head > 0 then out[#out + 1] = { BIG, 0, head } end + local rest = len - head + local t4 = rest - (rest % 4) + if t4 > 0 then out[#out + 1] = { 4, head, t4 } end + if rest - t4 > 0 then out[#out + 1] = { 1, head + t4, rest - t4 } end + else + out[1] = { 1, 0, len } + end + return out +end + +local function ddRead(off, len) + local parts = {} + for _, s in ipairs(segs(off, len)) do + local b, rel, n = s[1], s[2], s[3] + if os.remove then pcall(os.remove, TMPR) end + sh(string.format("dd if=%s of=%s bs=%d skip=%d count=%d", + DEV, TMPR, b, (off + rel) // b, n // b)) + local d = slurp(TMPR, n) + if not d or #d ~= n then return nil end + parts[#parts + 1] = d + end + return table.concat(parts) +end + +local function ddWrite(off, len, data) + for _, s in ipairs(segs(off, len)) do + local b, rel, n = s[1], s[2], s[3] + -- 每段单独一个临时文件:这样 dd 只需要 seek(输出偏移),不依赖输入侧的 skip + local o = io.open(TMPB, "wb") + if not o then return false end + o:write(data:sub(rel + 1, rel + n)) + o:close() + sh(string.format("dd if=%s of=%s bs=%d seek=%d count=%d", + TMPB, DEV, b, (off + rel) // b, n // b)) + end + return true +end + +--------------------------------------------------------------------- 预检查 +local desc1 = nil + +local function setDesc1(txt, color) + if not desc1 then return end + pcall(function() desc1:set { text = txt, text_color = color } end) +end + +local function doCheck() + if not RES then + setDesc1('找不到 resource.bin,无法操作。', C_ERR) + return false + end + local list = PAYTBL.pay or {} + if #list == 0 then + setDesc1('预检查失败:负载表为空。', C_ERR) + return false + end + local h = ddRead(0, 48) + if not h or #h < 48 then + setDesc1('预检查失败:/dev/system 读不到。', C_ERR) + return false + end + if h:sub(1, 8) ~= "-rom1fs-" then + setDesc1('预检查失败:设备节点不可读\n或分区头不是 ROMFS。', C_ERR) + return false + end + local z = h:find("\0", 17, true) + local vol = h:sub(17, (z or 33) - 1) + if vol ~= "system" then + setDesc1('预检查失败:卷名是 ' .. vol .. '。', C_ERR) + return false + end + -- superblock 的 size 是**大端**(romfs 规范:__be32),所以这里必须用 ">I4"。 + -- (S4e 那版写成 " 越界判断形同虚设;这里修正。) + local size = string.unpack(">I4", h, 9) + local bad, total = 0, 0 + for i = 1, #list do + local r = list[i] + local b = ddBlk(r[2], r[3]) + if (r[2] % b) ~= 0 or (r[3] % b) ~= 0 then bad = bad + 1 end + if (r[2] + r[3]) > size then bad = bad + 1 end + total = total + r[3] + end + if bad > 0 then + setDesc1(string.format('预检查失败:%d 项越界或未对齐。', bad), C_ERR) + return false + end + -- 顺带确认 resource.bin 第一条能读出来 + local probe = slice(list[1][1], list[1][3]) + if not probe or #probe ~= list[1][3] then + setDesc1('预检查失败:resource.bin 读取异常。', C_ERR) + return false + end + -- 抽样版本校验(只读):首 / 中 / 尾三条,比对设备上真实的 64 字节与本盘的 + -- 「要写进去的字节」或「另一侧指纹」—— 能确认负载偏移就是给这一版固件算的。 + local marks = { 1, math.max(1, #list // 2), #list } + local samp, okAll = {}, true + for i = 1, #marks do + local k = marks[i] + local rec, oth = list[k], (PAYTBL.rb or {})[k] + local got = ddRead(rec[2], 64) + local mine = slice(rec[1], 64) + local other = oth and slice(oth[1], 64) or nil + if not got or #got < 64 then + samp[#samp + 1] = '×' + okAll = false + elseif mine and got == mine then + samp[#samp + 1] = '已替换✓' + elseif other and got == other then + samp[#samp + 1] = '原厂✓' + else + samp[#samp + 1] = '抽样×' + okAll = false + end + end + local head = string.format('预检查通过 | %d 条 | %.2fMB', #list, total / 1048576) + setDesc1(head .. '\n/dev/system ' .. table.concat(samp, ' '), okAll and C_OK or C_ERR) + return okAll +end + +--------------------------------------------------------------------- 替换 / 还原 +local logLabel = nil +local rebootBtn = nil +local runningTitle = nil +local logLines = {} + +local function logOut(s) + logLines[#logLines + 1] = s + -- 日志框 h=277、行高 35 -> 最多显示 7 行;留 7 行保证最后一行完整可见 + while #logLines > 7 do table.remove(logLines, 1) end + if logLabel then + pcall(function() logLabel:set { text = table.concat(logLines, "\n") } end) + end +end + +local function showReboot() + if rebootBtn then pcall(function() rebootBtn:clear_flag(lvgl.FLAG.HIDDEN) end) end +end + +-- 一条记录三步:① 版本校验(设备上那段必须等于「原厂」或「替换后」的前 64 B) +-- ② 写入 ③ 整段回读比对 +-- 版本两边都不是 -> 'ver',并且**跳过 /dev/system 剩余记录**(换固件后最安全的行为: +-- 绝不把字节写进别人的文件里)。os.execute 的返回值在这台机器上不可靠,所以判据 +-- 一律是内容比对。 +local function writeOne(rec, want) + local data = slice(rec[1], rec[3]) + if not data or #data ~= rec[3] then return 'err' end + local n = 64 + if want then + if want[3] < n then n = want[3] end + local other = slice(want[1], n) + local cur = ddRead(rec[2], n) + if not cur or #cur ~= n or not other or #other ~= n then return 'err' end + if cur ~= data:sub(1, n) and cur ~= other then return 'ver' end + end + local ok, lastBack = false, nil + for attempt = 1, 3 do + if not ddWrite(rec[2], rec[3], data) then return 'err' end + sh("sync") + lastBack = ddRead(rec[2], rec[3]) + if lastBack and #lastBack == rec[3] and lastBack == data then ok = true break end + logOut(string.format('> 第 %d 条校验不过,重试 %d/3', job.i, attempt)) + end + if not ok then + -- 计划 B:常规「分段写」3 次不过时,改成整条一次 dd(bs=4 或 bs=1)。 + -- 有些驱动对「同一区域被拆成多次小写」比「一次大写」更敏感,换个写法常常就过了。 + local b = (rec[3] % 4 == 0) and 4 or 1 + local o = io.open(TMPB, "wb") + if o then + o:write(data) + o:close() + sh(string.format("dd if=%s of=%s bs=%d seek=%d count=%d", + TMPB, DEV, b, rec[2] // b, rec[3] // b)) + sh("sync") + local back2 = ddRead(rec[2], rec[3]) + if back2 and #back2 == rec[3] and back2 == data then + ok = true + logOut(string.format('> 第 %d 条改用整条 bs=%d 写入后校验通过', job.i, b)) + end + end + end + if not ok then + -- 诊断:把「哪一条 / 目标偏移 / 长度 / 读回多少字节 / 第一个不同的字节」打出来 + local why = '读回 nil' + if lastBack then + if #lastBack ~= rec[3] then + why = string.format('读回 %d 字节', #lastBack) + else + why = '内容全同?' + for i = 1, #lastBack do + if lastBack:byte(i) ~= data:byte(i) then + why = string.format('首差@%d 得%02X 期%02X', i - 1, lastBack:byte(i), data:byte(i)) + break + end + end + end + end + logOut(string.format('> 失败 #%d off=0x%X len=%d %s', job.i, rec[2], rec[3], why)) + return 'err' + end + return 'ok' +end + +local function startJob(variant) + if job.run then return end + local list = PAYTBL.pay or {} + if #list == 0 then + setDesc1('负载表为空,已中止。', C_ERR) + return + end + job.run, job.list, job.i, job.ok, job.errs, job.vers = true, list, 0, 0, 0, 0 + job.skipRest, job.variant = false, variant + logLines = {} + logOut('> 开始替换') + logOut(string.format('> 61 帧充电动画 / 共 %d 条', #list)) + logOut('> 读取 resource.bin') + if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end + show(pages.running) +end + +local function tick() + tickCount = tickCount + 1 + -- 兜底:按下超过 1.6s 没收到 click(手指滑出等)就恢复高亮 + if pressed and (tickCount - pressed.t) > 40 then pressRelease() end + if not job.run or not job.list then return end + job.i = job.i + 1 + local n = #job.list + if job.i > n then + job.run = false + logOut('> 替换完毕,请重启手表。') + logOut(string.format('> 共 %d 条,成功 %d 失败 %d', n, job.ok, job.errs)) + if job.vers > 0 then + logOut(string.format('> 版本不符跳过 %d 条', job.vers)) + logOut('> 请勿重启,先装回匹配版本。') + else + logOut('> 若发现问题,请回滚修改。') + end + showReboot() + return + end + if job.skipRest then + job.vers = job.vers + 1 + else + local rb = PAYTBL.rb and PAYTBL.rb[job.i] + local r = writeOne(job.list[job.i], rb) + if r == 'ver' then + job.vers = job.vers + 1 + job.skipRest = true -- 版本不符:跳过 /dev/system 剩余记录 + logOut('> /dev/system 版本不符,已跳过剩余记录') + elseif r == 'ok' then + job.ok = job.ok + 1 + else + job.errs = job.errs + 1 + end + end + if job.i % 10 == 0 or job.i == n then + logOut(string.format('> 写入 %d/%d 成功 %d 失败 %d', job.i, n, job.ok, job.errs)) + end +end + +--------------------------------------------------------------------- 页面 +local function buildHome() + local p = newPage() + if not p then return nil end + clockLabel(p) + -- 盘内标题 = 显示名(不再用内部代号 S5Chg);x 按真 MiSans 31px 量出的宽度居中: + -- 'S5e Pt.3' = 126 -> x = (480-126)/2 = 177 + -- 'S5e Pt.3 · 还原' = 226 -> x = (480-226)/2 = 127 + if ROLE == 'restore' then + pageTitle(p, 'S5e Pt.3 · 还原', 127, 228) + else + pageTitle(p, 'S5e Pt.3', 177, 128) + end + listItem(p, 88, 'ic_help', '快速帮助', function() show(pages.help) end) + listItem(p, 186, 'ic_replace', ROLE == 'restore' and '开始还原' or '开始替换', function() + pendingVariant = 'replace' + confirmFrom = 'home' + show(pages.confirm) + end) + listItem(p, 285, 'ic_about', '关于', function() show(pages.about) end) + return p +end + +local function buildHelp() + local p = newPage() + if not p then return nil end + clockLabel(p) + backArrow(p, 167, function() show(pages.home) end) + local t = pageTitle(p, '快速帮助', 188, 124) + setClickable(t) + bind(t, function() show(pages.home) end) + + listItem(p, 88, 'ic_check', '环境预检查', function() doCheck() end) + desc1 = lab(p, { x = 60, y = 182, w = 372, h = 76, + text = '检查 /dev/system 分区与\n61 帧充电动画的完整性。', + text_color = C_GRAY, font_size = 25, text_font = F_BODY }) + + -- 拆盘之后:本盘只装一份负载,「回滚/恢复」由另一份盘负责 + listItem(p, 264, 'ic_rollback', '回滚修改', function() + if ROLE == 'restore' then + setDesc1('本盘负责还原,换回动画\n请装 S5e Pt.3。', C_GRAY) + else + setDesc1('本盘只负责替换,请装\nS5e Pt.3 · 还原。', C_GRAY) + end + end) + lab(p, { x = 60, y = 355, w = 372, h = 36, + text = ROLE == 'restore' and '换回动画请装 S5e Pt.3。' + or '还原请装 S5e Pt.3 · 还原。', + text_color = C_GRAY, font_size = 25, text_font = F_BODY }) + return p +end + +local function buildConfirm() + local p = newPage() + if not p then return nil end + clockLabel(p) + -- 确认页的返回:回到进来时的那个页面(首页 或 快速帮助) + local function goBack() show(pages[confirmFrom] or pages.home) end + backArrow(p, 167, goBack) + local t = pageTitle(p, ROLE == 'restore' and '开始还原' or '开始替换', 188, 124) + setClickable(t) + bind(t, goBack) + + -- 多色文本**不能**用 recolor:AP 里那几处 recolor 是图片重着色(bg_img_recolor) + -- 和表盘编辑器的主题重着色,不是文本属性;实测标记会被原样显示出来。 + -- 改为拆成多个标签 + 手工算 x(宽度用真 MiSans 25px 量出),框宽 ≥ 文本宽 + -- -> 保证不折行(折行会让手工算 x 的片段错位),并且整框都在圆内。 + lab(p, { x = 52, y = 93, w = 340, h = 36, + text = '替换充电动画之前,请先确认', + text_color = C_GRAY, font_size = 25, text_font = F_BODY }) + lab(p, { x = 52, y = 126, w = 380, h = 36, + text = '自己的系统版本号为 3.112.035!', + text_color = C_GRAY, font_size = 25, text_font = F_BODY }) + lab(p, { x = 52, y = 208, w = 280, h = 36, text = '请确认自己的手表型号为', + text_color = C_GRAY, font_size = 25, text_font = F_BODY }) + lab(p, { x = 52, y = 241, w = 405, h = 36, text = 'Xiaomi Watch S5 eSIM 46mm !', + text_color = C_WHITE, font_size = 25, text_font = F_BODY }) + lab(p, { x = 52, y = 331, w = 330, h = 36, text = '搞机有风险,变砖后果自负。', + text_color = C_GRAY, font_size = 25, text_font = F_BODY }) + + local b = bigButton(p, 376, ROLE == 'restore' and '确认还原' or '确认替换') + bindTap(b, function() startJob(pendingVariant) end) + return p +end + +local function buildAbout() + local p = newPage() + if not p then return nil end + clockLabel(p) + backArrow(p, 198, function() show(pages.home) end) + local t = pageTitle(p, '关于', 220, 62) + setClickable(t) + bind(t, function() show(pages.home) end) + + -- 信息卡:底板 + 圆形 logo + 大标题 + 版本号 + pcall(function() + p:Object { x = 54, y = 81, w = 373, h = 210, pad_all = 0, + bg_color = C_PLATE, border_width = 0, radius = 31 } + end) + if IMG.logo then + pcall(function() p:Image { src = IMG.logo, x = 200, y = 111 } end) + end + -- 29px 量得:'S5e Pt.3' = 119 -> x = 54 + (373-119)/2 = 181 + -- 'S5e Pt.3 · 还原' = 212 -> x = 54 + (373-212)/2 = 134.5 -> 134 + if ROLE == 'restore' then + lab(p, { x = 134, y = 198, w = 214, h = 40, text = 'S5e Pt.3 · 还原', + text_color = C_WHITE, font_size = 29, text_font = F_ITEM }) + else + lab(p, { x = 181, y = 198, w = 121, h = 40, text = 'S5e Pt.3', + text_color = C_WHITE, font_size = 29, text_font = F_ITEM }) + end + -- '1.0.0' 21px 量得 49 -> 居中 x = 54 + (373-49)/2 = 216 + lab(p, { x = 216, y = 234, w = 49, h = 28, text = '1.0.0', + text_color = C_GRAY, font_size = 21, text_font = F_VER }) + + -- QQ 联系方式:结构与列表项一致(无动作,所以不给它绑点击) + listItem(p, 300, 'ic_qq', '732681995', nil) + return p +end + +local function buildRunning() + local p = newPage() + if not p then return nil end + clockLabel(p) + -- 本页没有返回图标(规格:顶栏同首页),所以标题要屏幕居中: + -- '正在替换' 31px 量得 124 -> x = 240 - 124/2 = 178 + runningTitle = pageTitle(p, '正在替换', 178, 124) + logLabel = lab(p, { x = 67, y = 93, w = 348, h = 277, text = '', + text_color = C_WHITE, font_size = 25, text_font = F_BODY }) + rebootBtn = bigButton(p, 376, '重启手表') + -- 替换过程中不显示,替换完再显示 + if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end + bindTap(rebootBtn, function() + logOut('> 正在重启…') + sh("sync") + sh("reboot") + end) + return p +end + +--------------------------------------------------------------------- 组装 +local function build() + if built then return end + built = true + pages.home = buildHome() + pages.help = buildHelp() + pages.confirm = buildConfirm() + pages.about = buildAbout() + pages.running = buildRunning() + if pages.home then show(pages.home) end + clockTick() + pcall(function() + ui.timer = lvgl.Timer { period = 40, repeat_count = -1, cb = function() tick() end } + if ui.timer and ui.timer.resume then ui.timer:resume() end + end) + pcall(function() + ui.clockTimer = lvgl.Timer { period = 1000, repeat_count = -1, + cb = function() clockTick() end } + if ui.clockTimer and ui.clockTimer.resume then ui.clockTimer:resume() end + end) +end + +local okbuild, errbuild = pcall(build) +if not okbuild then + pcall(function() + local r = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000, + bg_opa = 255, border_width = 0 }) + r:Label { x = 20, y = 20, w = W - 40, h = H - 40, + text = 'BUILD FAILED:\n' .. tostring(errbuild), + text_color = '#ffffff', font_size = 13, text_font = F_BODY } + end) +end + +ui.init = function() + pcall(build) + return ui.root +end + +function pageOnPause() + if ui.timer then pcall(function() ui.timer:pause() end) end + if ui.clockTimer then pcall(function() ui.clockTimer:pause() end) end +end + +function pageOnResume() + if ui.timer then pcall(function() ui.timer:resume() end) end + if ui.clockTimer then pcall(function() ui.clockTimer:resume() end) end +end + +return ui diff --git a/s5e-charge/restore.sh b/s5e-charge/restore.sh new file mode 100644 index 0000000..7b18d49 --- /dev/null +++ b/s5e-charge/restore.sh @@ -0,0 +1,316 @@ +#!/bin/sh +# 把 S5 eSIM 46mm 的充电动画写回原厂(还原盘) + +DEV=/dev/system +BASE=/data/s5panim/payload_restore + +echo "target check" +ls -l $DEV + +echo "== charge0 == +dd if=$BASE/000_charge0.bin of=$DEV bs=1 seek=84231168 count=197959 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=84231168 count=197959 +cmp /tmp/chk.bin $BASE/000_charge0.bin + +echo "== charge1 == +dd if=$BASE/001_charge1.bin of=$DEV bs=4 seek=21008000 count=49712 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=21008000 count=49712 +cmp /tmp/chk.bin $BASE/001_charge1.bin + +echo "== charge2 == +dd if=$BASE/002_charge2.bin of=$DEV bs=4 seek=20459008 count=49835 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20459008 count=49835 +cmp /tmp/chk.bin $BASE/002_charge2.bin + +echo "== charge3 == +dd if=$BASE/003_charge3.bin of=$DEV bs=1 seek=79569408 count=198921 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=79569408 count=198921 +cmp /tmp/chk.bin $BASE/003_charge3.bin + +echo "== charge4 == +dd if=$BASE/004_charge4.bin of=$DEV bs=4 seek=19341952 count=49966 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19341952 count=49966 +cmp /tmp/chk.bin $BASE/004_charge4.bin + +echo "== charge5 == +dd if=$BASE/005_charge5.bin of=$DEV bs=1 seek=75496960 count=199227 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=75496960 count=199227 +cmp /tmp/chk.bin $BASE/005_charge5.bin + +echo "== charge6 == +dd if=$BASE/006_charge6.bin of=$DEV bs=1 seek=73385984 count=197829 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73385984 count=197829 +cmp /tmp/chk.bin $BASE/006_charge6.bin + +echo "== charge7 == +dd if=$BASE/007_charge7.bin of=$DEV bs=1 seek=72987648 count=196511 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=72987648 count=196511 +cmp /tmp/chk.bin $BASE/007_charge7.bin + +echo "== charge8 == +dd if=$BASE/008_charge8.bin of=$DEV bs=4 seek=18197888 count=48897 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=18197888 count=48897 +cmp /tmp/chk.bin $BASE/008_charge8.bin + +echo "== charge9 == +dd if=$BASE/009_charge9.bin of=$DEV bs=1 seek=72595456 count=196017 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=72595456 count=196017 +cmp /tmp/chk.bin $BASE/009_charge9.bin + +echo "== charge10 == +dd if=$BASE/010_charge10.bin of=$DEV bs=4 seek=20958080 count=49823 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20958080 count=49823 +cmp /tmp/chk.bin $BASE/010_charge10.bin + +echo "== charge11 == +dd if=$BASE/011_charge11.bin of=$DEV bs=4 seek=20908800 count=49236 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20908800 count=49236 +cmp /tmp/chk.bin $BASE/011_charge11.bin + +echo "== charge12 == +dd if=$BASE/012_charge12.bin of=$DEV bs=4 seek=20860032 count=48700 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20860032 count=48700 +cmp /tmp/chk.bin $BASE/012_charge12.bin + +echo "== charge13 == +dd if=$BASE/013_charge13.bin of=$DEV bs=1 seek=83244032 count=195933 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=83244032 count=195933 +cmp /tmp/chk.bin $BASE/013_charge13.bin + +echo "== charge14 == +dd if=$BASE/014_charge14.bin of=$DEV bs=4 seek=20761856 count=49022 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20761856 count=49022 +cmp /tmp/chk.bin $BASE/014_charge14.bin + +echo "== charge15 == +dd if=$BASE/015_charge15.bin of=$DEV bs=4 seek=20713088 count=48723 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20713088 count=48723 +cmp /tmp/chk.bin $BASE/015_charge15.bin + +echo "== charge16 == +dd if=$BASE/016_charge16.bin of=$DEV bs=1 seek=82649088 count=203070 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=82649088 count=203070 +cmp /tmp/chk.bin $BASE/016_charge16.bin + +echo "== charge17 == +dd if=$BASE/017_charge17.bin of=$DEV bs=1 seek=82446336 count=202394 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=82446336 count=202394 +cmp /tmp/chk.bin $BASE/017_charge17.bin + +echo "== charge18 == +dd if=$BASE/018_charge18.bin of=$DEV bs=1 seek=82244608 count=201551 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=82244608 count=201551 +cmp /tmp/chk.bin $BASE/018_charge18.bin + +echo "== charge19 == +dd if=$BASE/019_charge19.bin of=$DEV bs=1 seek=82035712 count=208701 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=82035712 count=208701 +cmp /tmp/chk.bin $BASE/019_charge19.bin + +echo "== charge20 == +dd if=$BASE/020_charge20.bin of=$DEV bs=1 seek=81623040 count=212929 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=81623040 count=212929 +cmp /tmp/chk.bin $BASE/020_charge20.bin + +echo "== charge21 == +dd if=$BASE/021_charge21.bin of=$DEV bs=1 seek=81409024 count=213511 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=81409024 count=213511 +cmp /tmp/chk.bin $BASE/021_charge21.bin + +echo "== charge22 == +dd if=$BASE/022_charge22.bin of=$DEV bs=4 seek=20299520 count=52636 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20299520 count=52636 +cmp /tmp/chk.bin $BASE/022_charge22.bin + +echo "== charge23 == +dd if=$BASE/023_charge23.bin of=$DEV bs=1 seek=80989696 count=208113 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=80989696 count=208113 +cmp /tmp/chk.bin $BASE/023_charge23.bin + +echo "== charge24 == +dd if=$BASE/024_charge24.bin of=$DEV bs=1 seek=80780288 count=209043 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=80780288 count=209043 +cmp /tmp/chk.bin $BASE/024_charge24.bin + +echo "== charge25 == +dd if=$BASE/025_charge25.bin of=$DEV bs=4 seek=20144128 count=50926 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20144128 count=50926 +cmp /tmp/chk.bin $BASE/025_charge25.bin + +echo "== charge26 == +dd if=$BASE/026_charge26.bin of=$DEV bs=4 seek=20094080 count=50034 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=20094080 count=50034 +cmp /tmp/chk.bin $BASE/026_charge26.bin + +echo "== charge27 == +dd if=$BASE/027_charge27.bin of=$DEV bs=1 seek=80176128 count=199722 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=80176128 count=199722 +cmp /tmp/chk.bin $BASE/027_charge27.bin + +echo "== charge28 == +dd if=$BASE/028_charge28.bin of=$DEV bs=4 seek=19993344 count=50579 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19993344 count=50579 +cmp /tmp/chk.bin $BASE/028_charge28.bin + +echo "== charge29 == +dd if=$BASE/029_charge29.bin of=$DEV bs=1 seek=79768576 count=204746 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=79768576 count=204746 +cmp /tmp/chk.bin $BASE/029_charge29.bin + +echo "== charge30 == +dd if=$BASE/030_charge30.bin of=$DEV bs=4 seek=19840896 count=51412 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19840896 count=51412 +cmp /tmp/chk.bin $BASE/030_charge30.bin + +echo "== charge31 == +dd if=$BASE/031_charge31.bin of=$DEV bs=1 seek=79160320 count=203001 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=79160320 count=203001 +cmp /tmp/chk.bin $BASE/031_charge31.bin + +echo "== charge32 == +dd if=$BASE/032_charge32.bin of=$DEV bs=1 seek=78957056 count=203131 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=78957056 count=203131 +cmp /tmp/chk.bin $BASE/032_charge32.bin + +echo "== charge33 == +dd if=$BASE/033_charge33.bin of=$DEV bs=4 seek=19687680 count=51562 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19687680 count=51562 +cmp /tmp/chk.bin $BASE/033_charge33.bin + +echo "== charge34 == +dd if=$BASE/034_charge34.bin of=$DEV bs=4 seek=19636864 count=50739 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19636864 count=50739 +cmp /tmp/chk.bin $BASE/034_charge34.bin + +echo "== charge35 == +dd if=$BASE/035_charge35.bin of=$DEV bs=4 seek=19587328 count=49490 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19587328 count=49490 +cmp /tmp/chk.bin $BASE/035_charge35.bin + +echo "== charge36 == +dd if=$BASE/036_charge36.bin of=$DEV bs=4 seek=19537792 count=49525 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19537792 count=49525 +cmp /tmp/chk.bin $BASE/036_charge36.bin + +echo "== charge37 == +dd if=$BASE/037_charge37.bin of=$DEV bs=1 seek=77954048 count=196965 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=77954048 count=196965 +cmp /tmp/chk.bin $BASE/037_charge37.bin + +echo "== charge38 == +dd if=$BASE/038_charge38.bin of=$DEV bs=4 seek=19439616 count=48761 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19439616 count=48761 +cmp /tmp/chk.bin $BASE/038_charge38.bin + +echo "== charge39 == +dd if=$BASE/039_charge39.bin of=$DEV bs=1 seek=77568000 count=189981 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=77568000 count=189981 +cmp /tmp/chk.bin $BASE/039_charge39.bin + +echo "== charge40 == +dd if=$BASE/040_charge40.bin of=$DEV bs=1 seek=77182976 count=184335 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=77182976 count=184335 +cmp /tmp/chk.bin $BASE/040_charge40.bin + +echo "== charge41 == +dd if=$BASE/041_charge41.bin of=$DEV bs=1 seek=76999680 count=183226 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=76999680 count=183226 +cmp /tmp/chk.bin $BASE/041_charge41.bin + +echo "== charge42 == +dd if=$BASE/042_charge42.bin of=$DEV bs=4 seek=19204736 count=45059 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19204736 count=45059 +cmp /tmp/chk.bin $BASE/042_charge42.bin + +echo "== charge43 == +dd if=$BASE/043_charge43.bin of=$DEV bs=1 seek=76642304 count=176325 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=76642304 count=176325 +cmp /tmp/chk.bin $BASE/043_charge43.bin + +echo "== charge44 == +dd if=$BASE/044_charge44.bin of=$DEV bs=4 seek=19117696 count=42805 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19117696 count=42805 +cmp /tmp/chk.bin $BASE/044_charge44.bin + +echo "== charge45 == +dd if=$BASE/045_charge45.bin of=$DEV bs=1 seek=76307968 count=162630 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=76307968 count=162630 +cmp /tmp/chk.bin $BASE/045_charge45.bin + +echo "== charge46 == +dd if=$BASE/046_charge46.bin of=$DEV bs=4 seek=19041024 count=35958 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=19041024 count=35958 +cmp /tmp/chk.bin $BASE/046_charge46.bin + +echo "== charge47 == +dd if=$BASE/047_charge47.bin of=$DEV bs=1 seek=76019200 count=144563 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=76019200 count=144563 +cmp /tmp/chk.bin $BASE/047_charge47.bin + +echo "== charge48 == +dd if=$BASE/048_charge48.bin of=$DEV bs=1 seek=75868672 count=150019 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=75868672 count=150019 +cmp /tmp/chk.bin $BASE/048_charge48.bin + +echo "== charge49 == +dd if=$BASE/049_charge49.bin of=$DEV bs=1 seek=75696640 count=171665 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=75696640 count=171665 +cmp /tmp/chk.bin $BASE/049_charge49.bin + +echo "== charge50 == +dd if=$BASE/050_charge50.bin of=$DEV bs=4 seek=18829824 count=44284 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=18829824 count=44284 +cmp /tmp/chk.bin $BASE/050_charge50.bin + +echo "== charge51 == +dd if=$BASE/051_charge51.bin of=$DEV bs=1 seek=75135488 count=183445 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=75135488 count=183445 +cmp /tmp/chk.bin $BASE/051_charge51.bin + +echo "== charge52 == +dd if=$BASE/052_charge52.bin of=$DEV bs=4 seek=18737280 count=46476 +dd if=$DEV of=/tmp/chk.bin bs=4 skip=18737280 count=46476 +cmp /tmp/chk.bin $BASE/052_charge52.bin + +echo "== charge53 == +dd if=$BASE/053_charge53.bin of=$DEV bs=1 seek=74759680 count=189246 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=74759680 count=189246 +cmp /tmp/chk.bin $BASE/053_charge53.bin + +echo "== charge54 == +dd if=$BASE/054_charge54.bin of=$DEV bs=1 seek=74567680 count=191681 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=74567680 count=191681 +cmp /tmp/chk.bin $BASE/054_charge54.bin + +echo "== charge55 == +dd if=$BASE/055_charge55.bin of=$DEV bs=1 seek=74374656 count=192782 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=74374656 count=192782 +cmp /tmp/chk.bin $BASE/055_charge55.bin + +echo "== charge56 == +dd if=$BASE/056_charge56.bin of=$DEV bs=1 seek=74179584 count=195037 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=74179584 count=195037 +cmp /tmp/chk.bin $BASE/056_charge56.bin + +echo "== charge57 == +dd if=$BASE/057_charge57.bin of=$DEV bs=1 seek=73982464 count=197081 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73982464 count=197081 +cmp /tmp/chk.bin $BASE/057_charge57.bin + +echo "== charge58 == +dd if=$BASE/058_charge58.bin of=$DEV bs=1 seek=73783808 count=198594 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73783808 count=198594 +cmp /tmp/chk.bin $BASE/058_charge58.bin + +echo "== charge59 == +dd if=$BASE/059_charge59.bin of=$DEV bs=1 seek=73584128 count=199473 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73584128 count=199473 +cmp /tmp/chk.bin $BASE/059_charge59.bin + +echo "== charge60 == +dd if=$BASE/060_charge60.bin of=$DEV bs=1 seek=73184256 count=201487 +dd if=$DEV of=/tmp/chk.bin bs=1 skip=73184256 count=201487 +cmp /tmp/chk.bin $BASE/060_charge60.bin + +rm /tmp/chk.bin +echo "restore done - run reboot to apply" diff --git a/s5e-charge/tools/build_faces.py b/s5e-charge/tools/build_faces.py new file mode 100644 index 0000000..254ee80 --- /dev/null +++ b/s5e-charge/tools/build_faces.py @@ -0,0 +1,105 @@ +"""overlay + 官方模板 -> Compiler.exe -> .face(两份盘共用同一个 watchface id) + +要点(都是踩过的坑): + * preview.png 必须是 326x326,Compiler.exe 会直接拒绝别的尺寸 + * watchfaceId 要带机型家族前缀:S5 家族 = 462150xxx + * 用 Python 的 copytree 拷目录,别用 PowerShell 的 Copy-Item -Recurse(会拍平) + * replace 盘与 restore 盘用**同一个 id**:装一份就顶掉另一份,设备上只占一份空间 +""" +import os, json, shutil, subprocess, sys +from paths import WORK, TEMPLATE + +TPL = TEMPLATE +OVER = os.path.join(WORK, 'overlay') +BUILD = os.path.join(WORK, 'build') + +TARGETS = {'S5Chg': ('462150103', 'S5 41mm 充电动画 -> S5 eSIM 46mm(只写负载)'), + 'S5ChgRestore': ('462150103', 'S5 eSIM 46mm 充电动画还原盘(只写原厂字节)')} + + +def prepare(name): + oid, _ = TARGETS[name] + ov = os.path.join(OVER, name) + dst = os.path.join(BUILD, name) + if not os.path.exists(TPL): + raise SystemExit('找不到官方模板:%s\n请把 FangAiden/LuaDevTemplate 放到 vendor/ 下(见 README)' % TPL) + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree(TPL, dst) + + old = os.path.join(dst, 'watchface', 'fprj', 'LuaDevTemplate.fprj') + if os.path.exists(old): + os.remove(old) + + cfg = json.load(open(os.path.join(ov, 'watchface.config.json'), encoding='utf-8')) + cfg['watchfaceId'] = oid + json.dump(cfg, open(os.path.join(dst, 'watchface.config.json'), 'w', encoding='utf-8'), + indent=4, ensure_ascii=False) + + shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', name + '.fprj'), + os.path.join(dst, 'watchface', 'fprj', name + '.fprj')) + shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'images', 'preview.png'), + os.path.join(dst, 'watchface', 'fprj', 'images', 'preview.png')) + shutil.copyfile(os.path.join(ov, 'watchface', 'fprj', 'app', 'lua', 'main.lua'), + os.path.join(dst, 'watchface', 'fprj', 'app', 'lua', 'main.lua')) + + img = os.path.join(dst, 'watchface', 'fprj', 'app', 'images') + os.makedirs(img, exist_ok=True) + ovimg = os.path.join(ov, 'watchface', 'fprj', 'app', 'images') + for d in sorted(x for x in os.listdir(ovimg) if os.path.isdir(os.path.join(ovimg, x))): + tgt = os.path.join(img, d) + if os.path.exists(tgt): + shutil.rmtree(tgt) + shutil.copytree(os.path.join(ovimg, d), tgt) + print(' %s: app/images/%s/ -> %d files' % (name, d, len(os.listdir(tgt)))) + return dst + + +def normalize_fprj(dst): + """把工作区里的 .fprj 归一成 .fprj,并返回它的路径。 + + 我们拷进去时就是 .fprj,但 vendor 的 build_face.ps1 会先跑 + sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名** S5e Pt.3)找 + .fprj,找不到就把唯一那份改名过去(Title 也被改写成 projectName)。 + 编完这里再改回 .fprj —— 工作区里始终只有一份 .fprj(放两份会被 Compiler.exe + 并成双倍资源,实测资源整体翻倍),文件名也保持项目标识不变。 + """ + name = os.path.basename(dst) + d = os.path.join(dst, 'watchface', 'fprj') + fs = sorted(f for f in os.listdir(d) if f.endswith('.fprj')) + if len(fs) != 1: + raise SystemExit('%s: watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % (name, fs)) + want = os.path.join(d, name + '.fprj') + if fs[0] != name + '.fprj': + os.replace(os.path.join(d, fs[0]), want) + return want + + +def build(dst): + ps = os.path.join(dst, 'scripts', 'build_face.ps1') + # 显式给 -FaceName:vendor 脚本默认用 config 的 projectName 当输出名,而 projectName 现在 + # 是**显示名**(S5e Pt.3),交付/中间文件名必须还是 NAME.face。 + r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps, + '-FaceName', os.path.basename(dst) + '.face'], + capture_output=True, text=True, errors='replace') + normalize_fprj(dst) # vendor 可能把 .fprj 改名成显示名,编完改回 .fprj + for l in [x for x in (r.stdout or '').splitlines() if x.strip()][-6:]: + print(' |', l) + if r.returncode != 0: + print(' !! build failed, rc=', r.returncode) + return r.returncode == 0 + + +if __name__ == '__main__': + only = sys.argv[1] if len(sys.argv) > 1 else None + for name in TARGETS: + if only and name != only: + continue + print('== %s (id=%s) ==' % (name, TARGETS[name][0])) + dst = prepare(name) + build(dst) + face = os.path.join(dst, 'bin', name + '.face') + if os.path.exists(face): + print(' OK %s %d bytes' % (face, os.path.getsize(face))) + else: + print(' missing output') diff --git a/s5e-charge/tools/check_clip.py b/s5e-charge/tools/check_clip.py new file mode 100644 index 0000000..31321a1 --- /dev/null +++ b/s5e-charge/tools/check_clip.py @@ -0,0 +1,74 @@ +"""用真实渲染结果判"会不会被圆屏切掉"(480x480 档)。 + +做法:渲染一张**不套圆形遮罩**的 RGBA,然后量所有非透明像素到圆心的距离。 +这样自动考虑字体实际字宽、折行、圆角、图片缩放 —— 比按元素外框估算准得多。 +replace 盘与 restore 盘的文案都过一遍(PAGES_ALT),只换字不改坐标,所以文案变长 +就必须重新量 —— 这里就是那道闸。 +""" +import os, sys, math +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from PIL import Image, ImageDraw +from render_ui import draw_label, W, H, _rgb +import render_ui as R +from pages import PAGES, PAGES_ALT + +CX = CY = 240.0 +RAD = 240.0 + + +def render_raw(items): + img = Image.new('RGBA', (W, H), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + for it in items: + k = it.get('kind') + if k == 'rect': + box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1] + r = it.get('radius', 0) + if r: + d.rounded_rectangle(box, radius=r, fill=_rgb(it['bg']) + (255,)) + else: + d.rectangle(box, fill=_rgb(it['bg']) + (255,)) + elif k == 'circle': + d.ellipse([it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1], + fill=_rgb(it['bg']) + (255,)) + elif k == 'image': + if os.path.exists(it['src']): + im = Image.open(it['src']).convert('RGBA').resize((it['w'], it['h']), Image.LANCZOS) + img.alpha_composite(im, (it['x'], it['y'])) + elif k == 'label': + draw_label(img, d, it) + return img + + +R.OVERFLOW.clear() +cases = list(PAGES.items()) + [('alt:' + k, v) for k, v in PAGES_ALT.items()] +print('%-16s %8s %11s %s' % ('page', 'max r', 'outside px', 'worst point / note')) +print('-' * 78) +bad = 0 +for name, items in cases: + im = render_raw(items) + px = im.load() + worst, wpt, outside = 0.0, None, 0 + for y in range(H): + for x in range(W): + if px[x, y][3] > 8: + dd = math.hypot(x - CX, y - CY) + if dd > worst: + worst, wpt = dd, (x, y) + if dd > RAD: + outside += 1 + note = '全部在圆内' if worst <= RAD else '会被切 %d px' % outside + print('%-16s %8.1f %11d %s %s%s' % (name, worst, outside, wpt, note, + '' if outside == 0 else ' <-- CLIPPED')) + if outside: + bad += 1 +print('-' * 78) +print('圆心 (%.0f,%.0f) 半径 %.0f 画布 %dx%d 圆屏' % (CX, CY, RAD, W, H)) +if R.OVERFLOW: + print('!! 有标签超出框高(会折行/被裁,先修文案或框):') + for o in R.OVERFLOW: + print(' ', o) + bad += len(R.OVERFLOW) +print('检查了 %d 个页面(含 replace/restore 两套文案):越界页面 %d' % (len(cases), bad)) +print('RESULT: %s' % ('PASS(0 越界、0 折行)' if bad == 0 else 'FAIL')) +sys.exit(1 if bad else 0) diff --git a/s5e-charge/tools/finalize_faces.py b/s5e-charge/tools/finalize_faces.py new file mode 100644 index 0000000..e795035 --- /dev/null +++ b/s5e-charge/tools/finalize_faces.py @@ -0,0 +1,82 @@ +"""产出最终 .face(两份盘各一份,都不带工具链变体)。 + + * Compiler.exe 写 byte5 = 0x00 + 自己的随机 id 到 40..49 + * 官方 set_face_id.ps1 会把 byte5 改成 10 并写 id;固件里 25 个真表盘 byte5 ∈ {0,1} + => 忠实产物 = Compiler.exe 原始输出,只替换 40..49(b5=0 那份) + 体积原因:本盘各 ~12 MB,不再额外复制工具链变体(要的话去掉下面的 SKIP_TOOLCHAIN 即可) +""" +import os, sys, shutil, subprocess, hashlib +from paths import WORK, DIST + +BUILD = os.path.join(WORK, 'build') +TARGETS = {'S5Chg': '462150103', 'S5ChgRestore': '462150103'} +SKIP_TOOLCHAIN = True + + +def compile_raw(name): + w = os.path.join(BUILD, name) + exe = os.path.join(w, 'watchface', 'tools', 'Compiler.exe') + if not os.path.exists(exe): + print(' %s: 跳过(没有构建树,先跑 build_faces.py %s)' % (name, name)) + return None + # vendor 的 build_face.ps1 会先跑 sync_watchface_config.ps1,它按 config 的 projectName + # (现在是**显示名** S5e Pt.3)把 .fprj 改名成 <显示名>.fprj。build_faces.py 编完 + # 会改回来;这里再兜一次,保证只取工作区里唯一那份 .fprj(放两份会让资源翻倍)。 + fdir = os.path.join(w, 'watchface', 'fprj') + fs = sorted(f for f in os.listdir(fdir) if f.endswith('.fprj')) + if len(fs) != 1: + raise SystemExit('%s: watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % (name, fs)) + fprj = os.path.join(fdir, fs[0]) + if fs[0] != name + '.fprj': + os.replace(fprj, os.path.join(fdir, name + '.fprj')) + fprj = os.path.join(fdir, name + '.fprj') + out = os.path.join(WORK, 'raw_' + name) + if os.path.exists(out): + shutil.rmtree(out) + os.makedirs(out) + subprocess.run([exe, '-b', fprj, out, name + '.face', TARGETS[name]], + capture_output=True, text=True, errors='replace') + p = os.path.join(out, name + '.face') + return p if os.path.exists(p) else None + + +def patch_id(src, dst, wid): + d = bytearray(open(src, 'rb').read()) + if d[0:4] != b'\x5a\xa5\x34\x12': + raise SystemExit('bad magic') + if len(wid) > 10: + raise SystemExit('id too long') + d[40:50] = wid.encode() + b'\x00' * (10 - len(wid)) + open(dst, 'wb').write(bytes(d)) + return dst + + +def main(): + os.makedirs(DIST, exist_ok=True) + only = sys.argv[1] if len(sys.argv) > 1 else None + report = [] + for name, wid in TARGETS.items(): + if only and only != name: + continue + raw = compile_raw(name) + if not raw: + continue + faithful = patch_id(raw, os.path.join(DIST, name + '.face'), wid) + if not SKIP_TOOLCHAIN: + tc_src = os.path.join(BUILD, name, 'bin', name + '.face') + tc = os.path.join(DIST, name + '_toolchain.face') + if os.path.exists(tc_src): + shutil.copyfile(tc_src, tc) + for p in (faithful,): + if os.path.exists(p): + d = open(p, 'rb').read() + report.append((os.path.basename(p), len(d), d[4], d[5], d[40:50], + hashlib.sha256(d).hexdigest()[:16])) + print('%-28s%11s b4 b5 id sha256[:16]' % ('file', 'bytes')) + for f, n, b4, b5, sid, h in report: + print('%-28s%11d %-3d %-3d %-18r %s' % (f, n, b4, b5, sid, h)) + print('->', DIST) + + +if __name__ == '__main__': + main() diff --git a/s5e-charge/tools/gen_ui_lua.py b/s5e-charge/tools/gen_ui_lua.py new file mode 100644 index 0000000..6ef4858 --- /dev/null +++ b/s5e-charge/tools/gen_ui_lua.py @@ -0,0 +1,127 @@ +"""规范化构建:表盘源码 + 资源 -> .face(两遍,自动回填 resource.bin 内的偏移) + + main.lua <- **源码**,含 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 占位符 + .work/overlay/ <- 中间产物(回填后的 Lua 落在这里) + .work/build/ <- 编译工作区 + dist/.face <- 交付物 + +两份盘(replace / restore)共用同一套流程,只是负载来源不同: + blob_replace/{payload,fp}.bin -> S5Chg.face + blob_restore/{payload,fp}.bin -> S5ChgRestore.face +rb 表里放的是**另一侧字节前 64 B 的指纹**(只给版本校验用),所以一个 .face 只装一份负载。 +""" +import os, re, sys, json, subprocess +from paths import PROJ, WORK + +ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() +NAME = 'S5Chg' if ROLE == 'replace' else 'S5ChgRestore' +RECDIR = 'payload' if ROLE == 'replace' else 'payload_restore' +TOOLS = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(PROJ, 'main.lua') +OVERLAY_APP = os.path.join(WORK, 'overlay', NAME, 'watchface', 'fprj', 'app') +RESOLVED = os.path.join(OVERLAY_APP, 'lua', 'main.lua') +FACE = os.path.join(WORK, 'build', NAME, 'bin', NAME + '.face') +PY = sys.executable + +UINAMES = ['back', 'ic_help', 'ic_replace', 'ic_about', 'ic_check', 'ic_rollback', + 'ic_qq', 'logo'] + + +def run(script, *args): + r = subprocess.run([PY, os.path.join(TOOLS, script)] + list(args), + capture_output=True, text=True, encoding='utf-8', errors='replace') + if r.returncode != 0: + print(r.stdout[-2000:]); print(r.stderr[-2000:]) + raise SystemExit(f'{script} 执行失败') + return r.stdout + + +def find_blob(face, blob): + hits, start = [], 0 + while True: + i = face.find(blob, start) + if i < 0: + break + hits.append(i) + start = i + 1 + if len(hits) > 4: + break + return hits + + +def main(): + tpl = open(SRC, encoding='utf-8').read() + if '__UIIMG__' not in tpl: + raise SystemExit('main.lua 里没有占位符 —— 它应该是源码模板,不要把回填后的版本存回去') + print('=== %s(role=%s)===' % (NAME, ROLE)) + blobs = json.load(open(os.path.join(PROJ, 'blob_' + ROLE, 'offsets.json'), encoding='utf-8')) + pay_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'), 'rb').read() + fp_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'rb').read() + ml, mlf = blobs['magic_len'], blobs['magic_fp_len'] + + print('第 1 遍:用占位符编一次,量出资源在 resource.bin 里的位置') + run('make_overlays.py', ROLE) + run('build_faces.py', NAME) + face = open(FACE, 'rb').read() + print(' face %d bytes' % len(face)) + + ui_rows, bad = [], 0 + for name in UINAMES: + blob = open(os.path.join(PROJ, 'ui_images', name + '.bin'), 'rb').read() + hits = find_blob(face, blob) + if len(hits) != 1: + print(' !! %s 命中 %d 次(应为 1)' % (name, len(hits))); bad += 1 + if hits: + ui_rows.append((hits[0], len(blob), name)) + if bad: + raise SystemExit('有资源无法唯一定位') + pay_base = face.find(pay_blob[:15]) + fp_base = face.find(fp_blob[:15]) + for tag, blob, base in (('payload', pay_blob, pay_base), ('fp', fp_blob, fp_base)): + if face.count(blob[:15]) != 1: + raise SystemExit('%s 魔数命中 %d 次(应为 1)' % (tag, face.count(blob[:15]))) + if face.find(blob) != base: + raise SystemExit('%s 不是原样连续存放' % tag) + pay = [(pay_base + ml + r['rel'], r['target'], r['length']) for r in blobs['records']] + rb = [(fp_base + mlf + r['fprel'], r['target'], r['fplen']) for r in blobs['records']] + print(' 单条目:payload @0x%x(%d B)/ fp @0x%x(%d B),共 %d 条记录' + % (pay_base, len(pay_blob), fp_base, len(fp_blob), len(pay))) + + ui_txt = '\n'.join(' {%d,%d,"%s"},' % (o, l, n) for o, l, n in ui_rows) + pay_txt = ' pay = {\n' + '\n'.join(' {%d,%d,%d},' % t for t in pay) + '\n },' + rb_txt = ' rb = {\n' + '\n'.join(' {%d,%d,%d},' % t for t in rb) + '\n },' + resolved = (tpl.replace('__UIIMG__', ui_txt).replace('__PAY__', pay_txt) + .replace('__RB__', rb_txt).replace('__ROLE__', ROLE)) + assert '__UIIMG__' not in resolved and '__ROLE__' not in resolved + os.makedirs(os.path.dirname(RESOLVED), exist_ok=True) + open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved) + print('第 2 遍:回填后的 Lua(%d bytes),重新编译' % len(resolved)) + run('build_faces.py', NAME) + print(run('finalize_faces.py', NAME).strip()) + + face2 = open(FACE, 'rb').read() + drift = 0 + for name in UINAMES: + blob = open(os.path.join(PROJ, 'ui_images', name + '.bin'), 'rb').read() + hits = find_blob(face2, blob) + row = [r for r in ui_rows if r[2] == name] + if not row or not hits or hits[0] != row[0][0]: + print(' !! 偏移漂移 %s' % name); drift += 1 + if face2.find(pay_blob) != pay_base or face2.find(fp_blob) != fp_base: + print(' !! 负载偏移漂移'); drift += 1 + files = [(f, open(os.path.join(PROJ, RECDIR, f), 'rb').read()) + for f in sorted(os.listdir(os.path.join(PROJ, RECDIR))) if f.endswith('.bin')] + ok = sum(1 for off, tgt, ln in pay + if any(len(x) == ln and face2[off:off + ln] == x for _, x in files)) + if ok != len(pay): + print(' !! pay 只有 %d/%d 条吻合' % (ok, len(pay))); drift += 1 + okf = sum(1 for (off, tgt, ln), r in zip(rb, blobs['records']) + if face2[off:off + ln] == fp_blob[mlf + r['fprel']:mlf + r['fprel'] + ln]) + if okf != len(rb): + print(' !! fp 只有 %d/%d 条吻合' % (okf, len(rb))); drift += 1 + print('偏移校验:%s' % ('全部吻合,0 漂移' if drift == 0 else '%d 处异常' % drift)) + print('交付物:', os.path.join(PROJ, 'dist', NAME + '.face')) + + +if __name__ == '__main__': + main() diff --git a/s5e-charge/tools/iconpack.py b/s5e-charge/tools/iconpack.py new file mode 100644 index 0000000..68c8672 --- /dev/null +++ b/s5e-charge/tools/iconpack.py @@ -0,0 +1,447 @@ +"""Build / apply a system-app launcher-icon replacement for a Vela `vela_system.bin`. + +Why "same length" is the whole ball game +---------------------------------------- +ROMFS packs superblock -> inode -> name -> file data strictly back to back, and +every `next` / `spec` pointer is an ABSOLUTE offset. If a replacement file +changes length by even one byte, every later offset shifts and the image is +corrupt. So a replacement must encode to EXACTLY the original byte length: + + * source PNG == original dimensions -> encode, lengths match, done. + * source PNG != original dimensions -> two options: + (default) resize to the original dimensions (icon renders identically) + --pad keep the source dimensions, zero-pad the container to the + original length (renders smaller unless the launcher scales) + +Usage: + iconpack.py list + iconpack.py make [--pad] [--patched] + iconpack.py apply +""" +import os, sys, json, struct, hashlib, shutil +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from romfs import Romfs, img_header +from lvgl_bin import rle_compress, pad_rle +from PIL import Image + +CF_ARGB8888, CF_I8, MAGIC = 0x10, 0x0a, 0x19 +FMT = {CF_ARGB8888: 'ARGB8888', CF_I8: 'I8'} +RESAMPLE = Image.LANCZOS + + +# ----------------------------------------------------------------- encoders +def encode_argb8888(img): + rgba = img.convert('RGBA') + w, h = rgba.size + px = rgba.load() + out = bytearray() + for y in range(h): + row = bytearray() + for x in range(w): + r, g, b, a = px[x, y] + row += bytes((b, g, r, a)) + out += row + return bytes(out) + + +def encode_i8(img, alpha_levels=16, sharpen_up=0.0): + """I8 encoder with an ALPHA-AWARE palette. + + The naive version quantised RGB only and then stored one *averaged* alpha + per palette entry. Any colour shared by an opaque body pixel and a + semi-transparent edge pixel collapsed to a mid alpha, and fully transparent + pixels inherited that same entry -- so the transparent margin became faintly + visible (measured: perpetual_calendar haze 0.000 -> 1.000, i.e. a grey veil + around the whole icon). + + Here the palette is built over (quantised RGB, quantised alpha) PAIRS, so a + colour can own several entries at different alphas and alpha 0 stays alpha 0. + """ + rgba = img.convert('RGBA') + w, h = rgba.size + src = rgba.load() + + # average colour of the visible pixels: transparent pixels must not invent + # their own colour clusters + rs = gs = bs = n = 0 + for y in range(h): + for x in range(w): + r, g, b, a = src[x, y] + if a > 0: + rs += r; gs += g; bs += b; n += 1 + avg = (rs // n, gs // n, bs // n) if n else (0, 0, 0) + + step = max(1, 256 // max(1, alpha_levels)) + flat = Image.new('RGB', (w, h)) + fp = flat.load() + qa = [[0] * w for _ in range(h)] + for y in range(h): + for x in range(w): + r, g, b, a = src[x, y] + fp[x, y] = (r, g, b) if a > 0 else avg + q = int(round(a / float(step))) * step + qa[y][x] = 255 if q > 255 else q + + p = flat.quantize(colors=256, method=Image.MEDIANCUT) + pal_rgb = p.getpalette()[:768] + idx = p.tobytes() + + cnt = {} + for y in range(h): + row = y * w + for x in range(w): + k = (idx[row + x], qa[y][x]) + cnt[k] = cnt.get(k, 0) + 1 + keep = sorted(cnt.items(), key=lambda kv: -kv[1])[:256] + + pal = bytearray() + remap = {} + by_rgb = {} + for slot, ((pi, al), _c) in enumerate(keep): + r = pal_rgb[pi * 3] if pi * 3 + 2 < len(pal_rgb) else 0 + g = pal_rgb[pi * 3 + 1] if pi * 3 + 2 < len(pal_rgb) else 0 + b = pal_rgb[pi * 3 + 2] if pi * 3 + 2 < len(pal_rgb) else 0 + pal += bytes((b, g, r, al)) + remap[(pi, al)] = slot + by_rgb.setdefault(pi, []).append((al, slot)) + removed = 256 - len(keep) + if removed > 0: + pal += b'\x00\x00\x00\x00' * removed + for pi in by_rgb: + by_rgb[pi].sort() + + out = bytearray(w * h) + for y in range(h): + row = y * w + for x in range(w): + pi = idx[row + x] + al = qa[y][x] + s = remap.get((pi, al)) + if s is None: + cand = by_rgb.get(pi) + if cand: + s = min(cand, key=lambda t: abs(t[0] - al))[1] + else: + s = 0 + out[row + x] = s + return bytes(pal) + bytes(out) + + +def encode_i8_palette(img, pal_bgra, alpha_tol=12): + """Quantise to an EXISTING 256-entry BGRA palette (as stored in an I8 blob). + + Reusing the source icon's own palette preserves the artwork's gradient shades + and its anti-aliased edge levels. A freshly clustered palette cannot: the + plate gradient eats the 256 entries, the edge collapses to a few alpha steps + and dropped pixels land on wrong entries -- measured on remote_camera as a + jagged plate rim plus stray coloured speckles. + + The search is constrained by alpha so that + * fully transparent source pixels can only pick transparent entries + (no veil, no speckles), and + * fully opaque source pixels can only pick opaque entries + (the body never goes translucent). + """ + rgba = img.convert('RGBA') + w, h = rgba.size + px = rgba.load() + + ent = [(pal_bgra[i * 4 + 2], pal_bgra[i * 4 + 1], + pal_bgra[i * 4], pal_bgra[i * 4 + 3]) for i in range(256)] + clear = [i for i, e in enumerate(ent) if e[3] == 0] + if not clear: # guarantee a transparent entry exists + worst = min(range(256), key=lambda i: ent[i][3]) + ent[worst] = (0, 0, 0, 0) + clear = [worst] + solid = [i for i, e in enumerate(ent) if e[3] >= 248] + by_alpha = {} + for i, e in enumerate(ent): + by_alpha.setdefault(e[3], []).append(i) + + def premul(e): + r, g, b, a = e + return (r * a // 255, g * a // 255, b * a // 255, a) + + P = [premul(e) for e in ent] + cache = {} + out = bytearray(w * h) + for y in range(h): + row = y * w + for x in range(w): + r, g, b, a = px[x, y] + key = (r >> 2, g >> 2, b >> 2, a >> 3) + s = cache.get(key) + if s is None: + if a == 0: + cand = clear + elif a >= 248 and solid: + cand = solid + else: + cand = [] + for da in range(0, alpha_tol + 1): + cand += by_alpha.get(a + da, ()) if a + da <= 255 else () + if da: + cand += by_alpha.get(a - da, ()) if a - da >= 0 else () + if not cand: + cand = range(256) + pr, pg, pb, pa = premul((r, g, b, a)) + best, bd = cand[0], None + for i in cand: + q = P[i] + d = ((pr - q[0]) ** 2 + (pg - q[1]) ** 2 + (pb - q[2]) ** 2 + + 2 * (pa - q[3]) ** 2) + if bd is None or d < bd: + bd, best = d, i + s = best + cache[key] = s + out[row + x] = s + + pal = bytearray() + for (r, g, b, a) in ent: + pal += bytes((b, g, r, a)) + return bytes(pal) + bytes(out) + + +def encode(img, cf, w, h, flags=0, pad_to=None, palette=None): + if cf == CF_ARGB8888: + body = encode_argb8888(img) + elif palette is not None: + body = encode_i8_palette(img, palette) + else: + body = encode_i8(img) + stride = w * (4 if cf == CF_ARGB8888 else 1) + blob = struct.pack(' pad_to: + raise ValueError(f'encoded {len(blob)} > slot {pad_to}') + blob += b'\x00' * (pad_to - len(blob)) + return blob + + +# ----------------------------------------------------------------- helpers +def raw_i8_strided(img, stride, colors=256): + """I8 像素流:1024 B 调色板(BGRA)+ stride*h 个索引(行尾按 header 的 stride 补 0)。 + + RLE 槽位专用:用 FASTOCTREE 自适应减色 + 关闭抖动 —— 颜色越少、相邻像素越容易出现 + 长游程,RLE 流就越短(这是能不能塞进原 csize 的关键)。 + """ + q = img.convert('RGBA').quantize(colors=max(2, min(256, colors)), + method=Image.FASTOCTREE, dither=Image.NONE) + if q.palette.mode == 'RGBA': + pal = list(q.getpalette('RGBA')) + else: + rgb = q.getpalette('RGB') + pal = [v for i in range(256) for v in (rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2], 255)] + pal = (pal + [0] * 1024)[:1024] + pal_b = bytearray() + for i in range(256): # 存储序:B, G, R, A + r, g, b, a = pal[i * 4:i * 4 + 4] + pal_b += bytes((b, g, r, a)) + idx = q.tobytes() + w, h = img.size + rows = bytearray() + for y in range(h): + rows += idx[y * w:(y + 1) * w] + if stride > w: + rows += b'\x00' * (stride - w) + return bytes(pal_b) + bytes(rows) + + +# 从大到小试:优先保真,装不下再减色 +RLE_COLOR_STEPS = (256, 192, 160, 128, 96, 80, 64, 48, 40, 32, 24, 16, 12, 8, 6, 4) + + +def encode_into_slot(img, stock_blob, slot_len=None, colors=None): + """把 img 编成与 stock_blob **逐字节等长** 的 LVGL v9 blob(沿用原 header)。 + + 真实固件里这些 UI 素材有两种槽位形态,都必须支持,否则 ROMFS 后面所有偏移全平移: + * ARGB8888 未压缩(144x144 图标那种):像素流 + 零填充到原长度 + * I8 + RLE 压缩(walkie_talkie 里几乎全是):量化 -> RLE -> 用 ctrl=0 空指令补齐到原 csize + (颜色数从 256 逐级往下试,取第一个能塞进原 csize 的) + 返回 (blob, how);不支持 / 压缩后仍超长时返回 (None, 原因)。 + """ + h = img_header(stock_blob) + if h is None: + return None, '不是 LVGL bin' + w, ht, cf, flags = h['w'], h['h'], h['cf'], h['flags'] + if slot_len is None: + slot_len = len(stock_blob) + out_img = img.convert('RGBA') + if out_img.size != (w, ht): + # 约定:**长宽比不一致的素材直接不替换**(保持原厂图),而不是拉伸或裁切 —— + # 拉伸会把方块变长方形;裁切会切掉按钮两端。宁可保持原厂,也不给设备塞变形图。 + pw, ph = out_img.size + if abs(pw / ph - w / ht) / (w / ht) > 0.005: + return None, ('长宽比不一致(源 %dx%d = %.3f,槽位 %dx%d = %.3f)-> 按约定跳过,保持原厂' + % (pw, ph, pw / ph, w, ht, w / ht)) + out_img = out_img.resize((w, ht), RESAMPLE) + if cf == CF_ARGB8888 and not (flags & 0x08): + blob = stock_blob[:12] + encode_argb8888(out_img) + if len(blob) > slot_len: + return None, 'ARGB 编码 %d > 槽位 %d' % (len(blob), slot_len) + return blob + b'\x00' * (slot_len - len(blob)), 'ARGB(补 %d)' % (slot_len - len(blob)) + if cf == CF_I8 and (flags & 0x08): + method, csize, usize = struct.unpack(' csize %d(已减到 %d 色)' % (len(comp), csize, n_last) + n, raw, comp = best + comp = comp + bytes((1, raw[-1])) # 把流做到正好 usize 字节,和原厂一致 + padded = pad_rle(comp, csize) + if padded is None: + return None, '补齐失败' + blob = stock_blob[:12] + struct.pack(' inode for every launcher icon. + + alarm/launcher.bin -> alarm + sports/training/launcher.bin -> sports_training + phone/contacts_launcher.bin -> phone_contacts + """ + out = {} + for p, i in r.entries(): + if not p.rsplit('/', 1)[-1].endswith('launcher.bin'): + continue + stem = p[:-4] # drop '.bin' + if stem.endswith('_launcher'): + stem = stem[:-len('_launcher')] + elif stem.endswith('/launcher'): + stem = stem[:-len('/launcher')] + out[stem.replace('/', '_')] = i + return out + + +def describe(r, i): + h = img_header(r.read(i)) + if not h: + return '?', '?', '?' + return f'{h["w"]}x{h["h"]}', FMT.get(h['cf'], f'0x{h["cf"]:02x}'), h + + +def cmd_list(image): + r = Romfs(image) + print(f'{os.path.basename(image)} volume={r.volume} romfs_size={r.sb_size}') + for app, i in sorted(targets(r).items()): + dim, fmt, _ = describe(r, i) + print(f' {app:<26} data@0x{i.data_off:08x} len={i.size:<7} {dim} {fmt}') + + +def cmd_make(image, pngdir, outdir, pad=False, write_patched=False): + r = Romfs(image) + tg = targets(r) + os.makedirs(outdir, exist_ok=True) + payload_dir = os.path.join(outdir, 'payload') + os.makedirs(payload_dir, exist_ok=True) + patch = bytearray() + rollback = bytearray() + records, skipped = [], [] + for fn in sorted(os.listdir(pngdir)): + if not fn.lower().endswith('.png'): + continue + app = fn[:-4] + if app not in tg: + skipped.append((app, 'no such app')) + continue + ino = tg[app] + orig = r.read(ino) + _, fmt, h = describe(r, ino) + if h is None: + skipped.append((app, f'not LVGL image (len={ino.size})')) + continue + src = Image.open(os.path.join(pngdir, fn)).convert('RGBA') + src_dim = f'{src.size[0]}x{src.size[1]}' + resized = False + if src.size != (h['w'], h['h']): + if pad: + tw, th = src.size + blob = encode(src, h['cf'], tw, th, h['flags'], pad_to=len(orig)) + else: + src = src.resize((h['w'], h['h']), RESAMPLE) + tw, th = src.size + resized = True + blob = encode(src, h['cf'], tw, th, h['flags']) + else: + tw, th = src.size + blob = encode(src, h['cf'], tw, th, h['flags']) + if len(blob) != len(orig): + skipped.append((app, f'length {len(blob)} != {len(orig)}')) + continue + patch += struct.pack('12}{"bytes":>8} {"fmt":<10}{"stock":>9}{"source":>9} note') + for m in records: + note = 'resized' if m['resized'] else ('padded' if m['padded'] else '') + print(f'{m["app"]:<26}0x{m["offset"]:08x}{m["length"]:>8} {m["format"]:<10}' + f'{m["stock_dim"]:>9}{m["source_dim"]:>9} {note}') + for a, why in skipped: + print(f' SKIP {a}: {why}') + print(f'\n{len(records)} replacement(s)') + print(f' payload -> {payload_dir}') + print(f' patch -> {os.path.join(outdir, "icons.patch")} ({len(patch)} bytes)') + print(f' rollback -> {os.path.join(outdir, "icons.rollback")} ({len(rollback)} bytes)') + print(f' manifest -> {os.path.join(outdir, "manifest.json")}') + if write_patched: + out = os.path.join(outdir, 'vela_app_patched.bin') + cmd_apply(image, os.path.join(outdir, 'icons.patch'), out) + + +def cmd_apply(image, patch, out): + data = bytearray(open(image, 'rb').read()) + blob = open(patch, 'rb').read() + off = n = 0 + while off < len(blob): + addr, ln = struct.unpack(' {out}') + + +if __name__ == '__main__': + a = sys.argv[1:] + if not a: + print(__doc__) + elif a[0] == 'list': + cmd_list(a[1]) + elif a[0] == 'make': + cmd_make(a[1], a[2], a[3], '--pad' in a, '--patched' in a) + elif a[0] == 'apply': + cmd_apply(a[1], a[2], a[3]) + else: + print(__doc__) diff --git a/s5e-charge/tools/lua_balance.py b/s5e-charge/tools/lua_balance.py new file mode 100644 index 0000000..a33ebba --- /dev/null +++ b/s5e-charge/tools/lua_balance.py @@ -0,0 +1,72 @@ +"""Crude but effective Lua block-balance checker: strips comments and strings, +then counts block openers vs `end`.""" +import sys, re + +def strip(src): + out = [] + i, n = 0, len(src) + while i < n: + c = src[i] + # long bracket comment / string --[[ ]] , --[==[ ]==] + m = re.match(r'--\[(=*)\[', src[i:]) + if m: + close = ']' + m.group(1) + ']' + j = src.find(close, i) + i = n if j < 0 else j + len(close) + out.append(' ') + continue + if src.startswith('--', i): + j = src.find('\n', i) + i = n if j < 0 else j + out.append(' ') + continue + m = re.match(r'\[(=*)\[', src[i:]) + if m: + close = ']' + m.group(1) + ']' + j = src.find(close, i) + i = n if j < 0 else j + len(close) + out.append('""') + continue + if c in '"\'': + j = i + 1 + while j < n: + if src[j] == '\\': + j += 2 + continue + if src[j] == c: + break + j += 1 + i = j + 1 + out.append('""') + continue + out.append(c) + i += 1 + return ''.join(out) + + +def check(path): + src = open(path, encoding='utf-8').read() + code = strip(src) + def cnt(k): + return len(re.findall(r'(? n: + raise ValueError('truncated literal run') + out += data[i:i + nb] + i += nb + else: + cnt = ctrl + if i + blk > n: + raise ValueError('truncated repeat unit') + unit = data[i:i + blk] + i += blk + out += unit * cnt + if out_len is not None and len(out) >= out_len: + break + return bytes(out) + + +def rle_compress(raw, max_run=127): + """LVGL RLE 编码器(rle_decompress 的逆向,blk=1)。 + + 贪心:先找同字节游程(>=2 用「重复」指令),否则攒「原样」指令。 + 输出长度总是 <= 原图像素流,因为最坏情况每条 literal 只多 1 个控制字节。 + """ + out = bytearray() + i, n = 0, len(raw) + while i < n: + v = raw[i] + j = i + 1 + while j < n and raw[j] == v and j - i < max_run: + j += 1 + run = j - i + if run >= 2: + out.append(run); out.append(v); i = j + continue + lit = bytearray() + k = i + while k < n and len(lit) < max_run: + if k + 1 < n and raw[k] == raw[k + 1]: + break + lit.append(raw[k]); k += 1 + if not lit: # 理论上到不了这里 + lit.append(v); k = i + 1 + out.append(0x80 | len(lit)); out += lit + i = k + return bytes(out) + + +def pad_rle(comp, want): + """把 RLE 流补到 want 字节:用 ctrl=0(重复 0 次 = 产出 0 像素)的 2 字节空指令。 + + ctrl=0 是合法指令(`len = 0`,两个越界判断都不触发),所以设备端解码器读完 csize + 字节既不会越界、也不会返回 INVALID —— 这是「等长覆盖」能成立的关键。 + 差值为奇数时先把一条 literal 拆成两条(payload 不变、多 1 个控制字节)。 + """ + d = want - len(comp) + if d < 0: + return None + if d % 2: + b = bytearray(comp) + for idx in range(len(b) - 1, -1, -1): + if b[idx] & 0x80 and (b[idx] & 0x7F) >= 2: + cnt = b[idx] & 0x7F + lit = bytes(b[idx + 1: idx + 1 + cnt]) + half = cnt // 2 + b[idx: idx + 1 + cnt] = (bytes([0x80 | half]) + lit[:half] + + bytes([0x80 | (cnt - half)]) + lit[half:]) + break + else: + return None + comp = bytes(b) + d -= 1 + return comp + b'\x00\x00' * (d // 2) + + +def parse_header(blob): + if len(blob) < 12 or blob[0] != MAGIC: + return None + magic, cf, flags, w, h, stride, rsv = struct.unpack(' n)**。S4e 那版是 57 帧按比例重采样(n -> round(n/56*60)), + 这一版两边都是 61 帧,**不要照抄那个比例** —— 逐帧对应才不会出现错帧/跳帧。 + +放大与编码策略(按优先级逐级降级,选第一条塞进槽位的): + 1. 原调色板 + Lanczos:把源帧用 Lanczos 放大到 480x480(RGB),再把每个像素**吸附回源帧 + 自己的调色板**(256 项里找最近的)。颜色只可能来自源帧的调色板 —— 空间上比最近邻放大 + 平滑,颜色上零新增。实测 61/61 帧都塞得进,且体积与最近邻放大几乎一样(0.996x)。 + 2. 原调色板 + Lanczos + 暗区合并:把亮度 < T 的像素全归到一个最暗的索引上换 RLE 长度, + T 从小到大试(只影响接近黑的雾面区,亮环不动)。这是兜底,本项目实际没用上。 + 3. 原调色板 + 最近邻索引放大(保底、无插值)。 + 4. 整帧重新量化(颜色数从 256 逐级往下试)—— 最后兜底。 + +等长要点(这是命根子): + 1) 数据总长必须**恰好**等于原槽位长度(ROMFS 后面所有偏移都是绝对值) + 2) 解码长度必须**恰好**等于 usize(少一字节设备端整帧不画;多一字节越界 -> INVALID) + —— usize_tail 用「重复最后一个字节」补齐,正是因为踩过「RLE 输出比 usize 少 1 个像素」的坑 + 3) pad_rle_safe 只用「产出 0 像素」的合法空指令(00 00 / 80)补齐到 csize +""" +import os, sys, json, struct, shutil, hashlib +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import INPUTS, WORK, DOCS +from romfs import Romfs +import lvgl_bin as L +from PIL import Image +import numpy as np + +STOCK = os.path.join(INPUTS, 'target', 'vela_system.bin') # S5 eSIM 46mm (p62lte) +SOURCE = os.path.join(INPUTS, 'source', 'vela_system.bin') # S5 41mm (q63) +OUTDIR = os.path.join(WORK, 'charge') +PATCHED = os.path.join(OUTDIR, 'vela_system_charge.bin') +DIRP = 'charging/' +W = H = 480 +SAMPLE = None if (len(sys.argv) > 1 and sys.argv[1] == 'all') else [0, 14, 28, 42, 56] +THRS = (4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 96, 128, 192, 255) +FALLBACK_COLORS = (256, 192, 128, 96, 64, 48, 32) +DRAW_DOCS = True + + +def num_of(path): + return int(path.rsplit('charge', 1)[-1].replace('.bin', '')) + + +def collect(rom): + d = {} + for p, i in rom.entries(): + if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin'): + continue + d[num_of(p)] = (p, i, rom.read(i)) + return d + + +def decode_fast(blob): + """(header, palette 256x4 BGRA, index HxW) —— 用库里的 rle_decompress,索引取出用 numpy。""" + h = L.parse_header(blob) + method, csize, usize = struct.unpack(' Lanczos 放大到 (w,h) 的 RGB(质量基准,也用于吸附)。""" + im = Image.fromarray(src_rgb(pal, idx).astype(np.uint8), 'RGB').resize((w, h), Image.LANCZOS) + return np.asarray(im, np.int32) + + +def snap(rgb, pal, chunk=8192): + """每个像素在 256 项调色板里的最近色索引(欧氏距离,分块 brute force)。""" + p = rgb_tab(pal) + flat = rgb.reshape(-1, 3).astype(np.int32) + out = np.empty(len(flat), np.uint8) + for i in range(0, len(flat), chunk): + d = flat[i:i + chunk, None, :] - p[None, :, :] + out[i:i + chunk] = np.argmin((d * d).sum(axis=2), axis=1) + return out.reshape(rgb.shape[:2]) + + +def nn_idx(idx, w, h): + """最近邻索引放大(不做任何插值)。""" + sh, sw = idx.shape + ys = np.clip(np.floor((np.arange(h) + 0.5) * sh / h).astype(np.int64), 0, sh - 1) + xs = np.clip(np.floor((np.arange(w) + 0.5) * sw / w).astype(np.int64), 0, sw - 1) + return idx[ys[:, None], xs[None, :]] + + +def dark_index(pal, idx, thr): + """暗区里选一个代表索引:取其中颜色最暗的那个。""" + vals = np.unique(idx[np.asarray(rgb_tab(pal)[idx].max(axis=2) < thr)]) + if len(vals) == 0: + return None + return int(vals[rgb_tab(pal)[vals].max(axis=1).argmin()]) + + +def pal4(q): + pal = list(q.getpalette() or []) + [0] * (768 - len(q.getpalette() or [])) + out = bytearray() + for k in range(256): + out += bytes((pal[k * 3 + 2], pal[k * 3 + 1], pal[k * 3], 255)) + return bytes(out) + + +def usize_tail(comp, raw_len, usize): + """把「解码出来的裸字节数」补到恰好 usize。 + + 踩过的坑:RLE 输出比 usize **少一个像素**时设备端整帧不画。这里的补法是 + 「再重复最后一个字节 N 次」(ctrl=N, unit=last)—— 视觉上只是把最后一个像素的 + 游程延长,不会像补 0 那样在末尾画一条黑边;也不会多解(正好补齐 usize)。 + """ + short = usize - raw_len + if short <= 0: + return comp + unit = comp[-1] if comp else 0 + ex = bytearray() + while short > 0: + n = min(short, 127) + ex += bytes((n, unit)) + short -= n + return comp + bytes(ex) + + +def encode_frame(pal, idx, w, h, csize, usize): + """返回 (comp, idx_out, how)。comp 已补到「恰好解码出 usize 字节」。""" + ref = lanczos_ref(pal, idx, w, h) + # --- 1) 原调色板 + Lanczos 吸附(本项目 61/61 都走这条) + cand_idx = snap(ref, pal) + for thr in (0,) + THRS: + if thr <= 0: + i2 = cand_idx + else: + m = ref.max(axis=2) < thr + if not m.any(): + i2 = cand_idx + else: + di = dark_index(pal, cand_idx, thr) + i2 = cand_idx if di is None else np.where(m, di, cand_idx).astype(np.uint8) + c = L.rle_compress(pal.tobytes() + i2.tobytes()) + c = usize_tail(c, 1024 + w * h, usize) + if len(c) <= csize: + how = '原调色板+Lanczos' if thr <= 0 else '原调色板+Lanczos+暗区<%d' % thr + return c, i2, how + # --- 2) 最近邻索引放大(无插值) + i2 = nn_idx(idx, w, h) + c = usize_tail(L.rle_compress(pal.tobytes() + i2.tobytes()), 1024 + w * h, usize) + if len(c) <= csize: + return c, i2, '原调色板+最近邻放大' + # --- 3) 整帧重新量化,颜色数逐级往下 + for ncol in FALLBACK_COLORS: + q = Image.fromarray(ref.astype(np.uint8), 'RGB').quantize( + colors=ncol, method=Image.MEDIANCUT, dither=Image.NONE) + i2 = np.asarray(q, np.uint8) + c = usize_tail(L.rle_compress(pal4(q) + i2.tobytes()), 1024 + w * h, usize) + if len(c) <= csize: + return c, i2, '重新量化 %d 色' % ncol + raise AssertionError('帧塞不进槽位(连 %d 色都不行)' % FALLBACK_COLORS[-1]) + + +def main(): + os.makedirs(OUTDIR, exist_ok=True) + stock, src = Romfs(STOCK), Romfs(SOURCE) + slots, srcs = collect(stock), collect(src) + tl, sl = sorted(slots), sorted(srcs) + print('目标 S5 eSIM(p62lte) %d 帧(%d..%d,槽位合计 %.2f MB)' % ( + len(tl), tl[0], tl[-1], sum(len(slots[n][2]) for n in tl) / 1048576)) + print('源 S5 41mm(q63) %d 帧(%d..%d,%.2f MB)' % ( + len(sl), sl[0], sl[-1], sum(len(srcs[n][2]) for n in sl) / 1048576)) + assert tl == sl, '两边帧号不一致,不能做 1:1 覆盖' + todo = tl if SAMPLE is None else [n for n in SAMPLE if n in slots] + print('映射:n -> n(1:1) 本次要改的帧:%s' % todo) + print('输出分辨率:%dx%d(源 464x464 -> Lanczos 放大后吸附回源调色板)\n' % (W, H)) + + records, decode_ck, used, samples = [], 0, {}, [] + for n in todo: + path, ino, blob = slots[n] + h = L.parse_header(blob) + w, ht, stride = h['w'], h['h'], max(h['stride'], h['w']) + method, csize, usize = struct.unpack('/payload[_restore]/ NNN_.bin + payload.lua + /blob_/payload.bin 单条目:魔数 + 顺序拼接 + /blob_/fp.bin 指纹单条目 + /flash.sh | restore.sh 线性 nsh 脚本(replace 盘还有 restore.sh 的对应物) + /payload.json 机器可读清单 +""" +import os, sys, json, hashlib +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import PROJ, INPUTS, WORK + +ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() +assert ROLE in ('replace', 'restore'), ROLE +STOCK = os.path.join(INPUTS, 'target', 'vela_system.bin') +PATCHED = os.path.join(WORK, 'charge', 'vela_system_charge.bin') +OUT = PROJ + +recs_in = json.load(open(os.path.join(WORK, 'charge', 'manifest.json'), encoding='utf-8')) +stock = open(STOCK, 'rb').read() +patched = open(PATCHED, 'rb').read() +assert len(stock) == len(patched) + +SRC = patched if ROLE == 'replace' else stock # 要写进设备的字节 +OTHER = stock if ROLE == 'replace' else patched # 另一侧的字节(做指纹) +FPDIR = os.path.join(OUT, 'payload' if ROLE == 'replace' else 'payload_restore') +BLOBDIR = os.path.join(OUT, 'blob_' + ROLE) +FP_LEN = 64 + +# 不再往偏移 0 写那 32 字节的「canary」:写 superblock 头没有意义, +# 而且它极可能就是真机上「总是失败 1」的那一条(偏移 0 的写/读回最容易出岔子)。 +assert stock[:8] == b'-rom1fs-' +records = [] +for r in recs_in['records']: + off, ln = r['offset'], r['length'] + records.append(dict(name=r['name'], offset=off, length=ln, data=SRC[off:off + ln], + changed=(stock[off:off + ln] != patched[off:off + ln]) if ROLE == 'replace' else True, + fp=OTHER[off:off + FP_LEN])) +emitted = [r for r in records if r['changed']] +dropped = [r for r in records if not r['changed']] + + +def write_pack(d): + os.makedirs(d, exist_ok=True) + for f in os.listdir(d): + os.remove(os.path.join(d, f)) + rows = [] + for i, r in enumerate(emitted): + fn = '%03d_%s.bin' % (i, r['name']) + open(os.path.join(d, fn), 'wb').write(r['data']) + assert r['offset'] % 4 == 0, r['name'] + rows.append(dict(name=r['name'], file=fn, offset=r['offset'], length=r['length'])) + with open(os.path.join(d, 'payload.lua'), 'w', encoding='utf-8', newline='\n') as f: + f.write('-- generated by tools/make_dd_payload.py (%s) -- do not hand-edit\n' % ROLE) + f.write('-- dd: -if / -of /dev/system bs=4 seek=offset/4 count=length/4\n') + f.write('return {\n') + for r in rows: + f.write(' { name = "%s", file = "%s", offset = %d, length = %d },\n' + % (r['name'], r['file'], r['offset'], r['length'])) + f.write('}\n') + return rows + + +lua_rows = write_pack(FPDIR) + +MAGIC_PAY = b'\x00S5C-PAYv1\x00\xa5\x5a\xc3\x3c' # 15 B,必须全局唯一 +MAGIC_FP = b'\x00S5C-FPv1\x00\x5a\xa5\x3c\xc3' +os.makedirs(BLOBDIR, exist_ok=True) +for f in os.listdir(BLOBDIR): + os.remove(os.path.join(BLOBDIR, f)) +pay_blob = MAGIC_PAY + b''.join(r['data'] for r in emitted) +fp_blob = MAGIC_FP + b''.join(r['fp'] for r in emitted) +open(os.path.join(BLOBDIR, 'payload.bin'), 'wb').write(pay_blob) +open(os.path.join(BLOBDIR, 'fp.bin'), 'wb').write(fp_blob) +rel, off, fpoff = [], 0, 0 +for r in emitted: + rel.append(dict(name=r['name'], rel=off, fprel=fpoff, target=r['offset'], + length=r['length'], fplen=len(r['fp']))) + off += r['length'] + fpoff += len(r['fp']) +assert fpoff == len(fp_blob) - len(MAGIC_FP), (fpoff, len(fp_blob)) +json.dump(dict(role=ROLE, magic_len=len(MAGIC_PAY), magic_fp_len=len(MAGIC_FP), fp_len=FP_LEN, + records=rel, pay_bytes=len(pay_blob), fp_bytes=len(fp_blob)), + open(os.path.join(BLOBDIR, 'offsets.json'), 'w', encoding='utf-8'), indent=1) +print('blob_%s/ 单条目: payload.bin %d B + fp.bin %d B(%d 条记录)' + % (ROLE, len(pay_blob), len(fp_blob), len(rel))) + + +def script(fname, rows, header, base, verify, tail): + with open(os.path.join(OUT, fname), 'w', encoding='utf-8', newline='\n') as f: + f.write('#!/bin/sh\n') + for line in header: + f.write('# ' + line + '\n') + f.write('\nDEV=/dev/system\nBASE=' + base + '\n\n') + f.write('echo "target check"\nls -l $DEV\n\n') + for r in rows: + b = 4 if (r['offset'] % 4 == 0 and r['length'] % 4 == 0) else 1 + f.write('echo "== ' + r['name'] + ' ==\n') + f.write('dd if=$BASE/%s of=$DEV bs=%d seek=%d count=%d\n' + % (r['file'], b, r['offset'] // b, r['length'] // b)) + if verify: + f.write('dd if=$DEV of=/tmp/chk.bin bs=%d skip=%d count=%d\n' + % (b, r['offset'] // b, r['length'] // b)) + f.write('cmp /tmp/chk.bin $BASE/%s\n\n' % r['file']) + f.write(tail + '\n') + + +if ROLE == 'replace': + script('flash.sh', lua_rows, + ['S5 41mm 充电动画 -> S5 eSIM 46mm(只写负载)', + '把 flash.sh 与 payload/ 放到同一个目录,例如 /data/s5panim/', + '然后: sh /data/s5panim/flash.sh 最后: reboot', + '还原用 restore.sh 或安装还原盘 S5ChgRestore.face'], + '/data/s5panim/payload', True, + 'rm /tmp/chk.bin\necho "all done - run reboot to apply"') +else: + script('restore.sh', lua_rows, + ['把 S5 eSIM 46mm 的充电动画写回原厂(还原盘)'], + '/data/s5panim/payload_restore', True, + 'rm /tmp/chk.bin\necho "restore done - run reboot to apply"') + +json.dump(dict(device='Xiaomi Watch S5 eSIM 46mm (p62lte)', target='/dev/system', + role=ROLE, kind='charging-anim', + dd='dd if=/ of=/dev/system bs=4 seek= count=', + block_size=4, records=lua_rows, + dropped_identical=[r['name'] for r in dropped]), + open(os.path.join(OUT, 'payload.json'), 'w', encoding='utf-8'), indent=1, ensure_ascii=False) +total = sum(r['length'] for r in lua_rows) +print('role=%s: emitted %d records, %.2f MB; dropped %d' % (ROLE, len(lua_rows), total / 1048576, len(dropped))) diff --git a/s5e-charge/tools/make_overlays.py b/s5e-charge/tools/make_overlays.py new file mode 100644 index 0000000..96afe38 --- /dev/null +++ b/s5e-charge/tools/make_overlays.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""为官方 LuaDevTemplate 生成可编译的 overlay。两份盘:S5Chg(替换)/ S5ChgRestore(还原)。 + +要点(都是踩过的坑): + * Compiler.exe 的设备表里没有 480 档 -> 用 DeviceType=462(S5 家族,圆表)+ 480x480 的 + Screen;preview.png 必须是 326x326,别的尺寸直接拒绝 + * 预览图按 tools/pages.py 的首页规格真渲染(真 MiSans 字体)再套圆形 alpha 缩到 326 + * 两份盘共用同一个 watchface id 462150103:装一份就顶掉另一份 +""" +import os, sys, json, shutil +from PIL import Image, ImageDraw +from paths import PROJ, WORK +import render_ui as R +from pages import PAGES + +ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() +NAME = 'S5Chg' if ROLE == 'replace' else 'S5ChgRestore' # 路径/标识(不要改) +OUT = os.path.join(WORK, 'overlay') +ASSETS = os.path.join(PROJ, 'assets') + +DEVICE_TYPE = "462" # S5 家族(Compiler.exe 只认 362 / 462 这两个圆表档) +ENTRY_WIDGET = "app_lua%2Fmain.lua" +PREVIEW_SIZE = 326 # Compiler.exe 对圆表设备强制 326x326 +SCREEN_SIZE = 480 +WF_ID = "462150103" # 两份盘共用同一个 id:装一份就顶掉另一份 +TITLE = 'S5e Pt.3' # 手表上显示的表盘名(两份盘同一个名字)= fprj 的 + + +def make_preview(path): + """480 首页 -> 圆形 alpha -> 326x326(Compiler.exe 只收这个尺寸)。""" + R.W = R.H = SCREEN_SIZE + img = Image.new('RGBA', (SCREEN_SIZE, SCREEN_SIZE), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + for it in PAGES['home']: + k = it.get('kind') + if k == 'rect': + box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1] + r = it.get('radius', 0) + (d.rounded_rectangle(box, radius=r, fill=R._rgb(it['bg']) + (255,)) if r + else d.rectangle(box, fill=R._rgb(it['bg']) + (255,))) + elif k == 'circle': + d.ellipse([it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1], + fill=R._rgb(it['bg']) + (255,)) + elif k == 'image': + if os.path.exists(it['src']): + im = Image.open(it['src']).convert('RGBA').resize((it['w'], it['h']), Image.LANCZOS) + img.alpha_composite(im, (it['x'], it['y'])) + elif k == 'label': + R.draw_label(img, d, it) + base = Image.new('RGBA', (SCREEN_SIZE, SCREEN_SIZE), (0, 0, 0, 255)) + base.alpha_composite(img) + mask = Image.new('L', (SCREEN_SIZE, SCREEN_SIZE), 0) + ImageDraw.Draw(mask).ellipse([0, 0, SCREEN_SIZE - 1, SCREEN_SIZE - 1], fill=255) + base.putalpha(mask) + base.resize((PREVIEW_SIZE, PREVIEW_SIZE), Image.LANCZOS).save(path) + + +def write_fprj(path, title): + xml = ('\r\n' + f'\r\n' + f' \r\n' + f' \r\n' + ' \r\n\r\n') + open(path, 'w', encoding='utf-16', newline='').write(xml) + + +def build(name, role): + base = os.path.join(OUT, name) + if os.path.exists(base): + shutil.rmtree(base) + fprj = os.path.join(base, 'watchface', 'fprj') + os.makedirs(os.path.join(fprj, 'images'), exist_ok=True) + os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True) + + cfg = {"projectName": TITLE, "watchfaceId": WF_ID, "power_consumption": "3", + "resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE", + "input": "watchface/fprj/images/preview.png", "name": "preview"}} + json.dump(cfg, open(os.path.join(base, 'watchface.config.json'), 'w', encoding='utf-8'), + indent=4, ensure_ascii=False) + write_fprj(os.path.join(fprj, f'{name}.fprj'), TITLE) + make_preview(os.path.join(fprj, 'images', 'preview.png')) + shutil.copyfile(os.path.join(PROJ, 'main.lua'), + os.path.join(fprj, 'app', 'lua', 'main.lua')) + # 只放「合并后的单条目」(blob_/) —— .face 里每个文件 = 一个资源条目 + blob = os.path.join(PROJ, 'blob_' + role) + if not os.path.isdir(blob): + raise SystemExit('缺少 blob_%s/ —— 先跑 tools/make_dd_payload.py %s' % (role, role)) + for src, rel in ((blob, 'pay'), (os.path.join(PROJ, 'ui_images'), 'ui')): + dst = os.path.join(fprj, 'app', 'images', rel) + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree(src, dst) + return base + + +if __name__ == '__main__': + build(NAME, ROLE) + print('overlay written to', os.path.join(OUT, NAME)) diff --git a/s5e-charge/tools/make_ui_images.py b/s5e-charge/tools/make_ui_images.py new file mode 100644 index 0000000..5531e41 --- /dev/null +++ b/s5e-charge/tools/make_ui_images.py @@ -0,0 +1,58 @@ +"""把 assets/ 里的 UI 素材转成 LVGL v9 二进制图片(.bin),供表盘运行时加载。 + +尺寸取自 tools/pages.py 的 480 档规格:列表图标 31x31、返回箭头 18x29、关于页 logo 80x80。 +格式(已在真机验证): + header 12B: magic=0x19 | cf | flags | w | h | stride | reserved + cf=0x10 ARGB8888 -> 数据为 BGRA,长度 = w*h*4 +编码器用 iconpack.encode(与图标包同一套,字节级验证过)。 +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import iconpack as ip +from PIL import Image, ImageDraw + +from paths import PROJ, DOCS +ASSETS = os.path.join(PROJ, 'assets') +OUT = os.path.join(PROJ, 'ui_images') + +SPEC = [ + ('back', 'backarrow.png', 18, 29), + ('ic_help', 'MdiLightbulbOutline.png', 31, 31), + ('ic_replace', 'MdiBandage.png', 31, 31), + ('ic_about', 'MdiInformationOutline.png', 31, 31), + ('ic_check', 'MdiClipboardTextSearchOutline.png', 31, 31), + ('ic_rollback', 'MdiReplyAllOutline.png', 31, 31), + ('ic_qq', 'qq.png', 31, 31), +] + +os.makedirs(OUT, exist_ok=True) +for name, png, w, h in SPEC: + p = os.path.join(ASSETS, png) + if not os.path.exists(p): + print(f' 缺少 {png}'); continue + img = Image.open(p).convert('RGBA') + if img.size != (w, h): + img = img.resize((w, h), Image.LANCZOS) + blob = ip.encode(img, ip.CF_ARGB8888, w, h, 0) + dst = os.path.join(OUT, name + '.bin') + open(dst, 'wb').write(blob) + print(f' {name:<12} <- {png:<34} {w}x{h} {len(blob)} bytes') + +# 关于页的 logo:封面图缩到 80x80,并加圆形遮罩 +# (封面是圆形图案 + 黑色方角,不裁成圆的话会在墨绿卡片上露出黑方块) +logo_src = os.path.join(ASSETS, 'cover.png') +if os.path.exists(logo_src): + L = 80 + img = Image.open(logo_src).convert('RGBA').resize((L, L), Image.LANCZOS) + mask = Image.new('L', (L, L), 0) + ImageDraw.Draw(mask).ellipse([0, 0, L - 1, L - 1], fill=255) + img.putalpha(mask) + blob = ip.encode(img, ip.CF_ARGB8888, L, L, 0) + open(os.path.join(OUT, 'logo.bin'), 'wb').write(blob) + # 顺便存一份 PNG 给本机预览用(注意别放进打包目录,会一起被打进去) + prev = os.path.join(DOCS, 'logo_preview_480.png') + os.makedirs(os.path.dirname(prev), exist_ok=True) + img.save(prev) + print(f' {"logo":<12} <- cover.png(圆形裁切) {L}x{L} {len(blob)} bytes') + +print('written to', OUT) diff --git a/s5e-charge/tools/pages.py b/s5e-charge/tools/pages.py new file mode 100644 index 0000000..1fa9e15 --- /dev/null +++ b/s5e-charge/tools/pages.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +"""Xiaomi Watch S5 eSIM 46mm(480x480)页面规格 + 本机渲染器。 + +设计语言与 466x466 的 S4e 版一致,整体按 480/466 放大并重新居中;坐标就是 +main.lua 里实际用的坐标(tools/render_ui.py 严格按本文件渲染,tools/check_clip.py +拿**真实渲染像素**判圆屏是否切边),所以预览图是诚实的。 + +CSS -> 实际能力 的映射(真机验证过): + left/top/width/height -> x / y / w / h(480 画布绝对坐标) + background -> bg_color + border-radius -> radius + color (alpha 1) -> text_color '#rrggbb' + color (alpha 0.6) -> 白 60% 叠在纯黑底上 = #999999 + font-weight 600 -> MiSans-Semibold ; 500 -> MiSans-Medium + 多色文本 -> **不能**用 LVGL recolor(会被原样显示),拆成多个标签 + 手工算 x +""" +import os + +from paths import PROJ, DOCS +ROOT = PROJ +ASSETS = os.path.join(ROOT, 'assets') +W = H = 480 +BG = (0, 0, 0) # 页面底色纯黑 +GRAY = '999999' # rgba(255,255,255,0.6) 叠黑 +WHITE = 'ffffff' +PLATE = '002927' # rgba(0,41,39,1) +CIRCLE = '4f4f4f' # rgba(79,79,79,1) +OK = '43cf7c' # rgba(67,207,124,1) +ERR = 'd43030' # rgba(212,48,48,1) +S600, S500 = 'semibold', 'medium' + +# 列表项几何(480 档;= 466 档 × 480/466 后取整) +ITEM_X, ITEM_W, ITEM_H, ITEM_R = 50, 379, 91, 35 +ICONC_DX, ICONC_DY, ICONC_SZ = 21, 20, 52 +ICON_DX, ICON_DY, ICON_SZ = 31, 30, 31 +TITLE_DX, TITLE_DY, TITLE_W, TITLE_H = 82, 26, 214, 40 +# 说明文字的公共框(比 466 档宽一点:中文 25px 一行放得下,且右边界仍在圆内) +DESC_X, DESC_W = 60, 372 +ICON_PNG = {'ic_help': 'MdiLightbulbOutline.png', 'ic_replace': 'MdiBandage.png', + 'ic_about': 'MdiInformationOutline.png', 'ic_check': 'MdiClipboardTextSearchOutline.png', + 'ic_rollback': 'MdiReplyAllOutline.png', 'ic_qq': 'qq.png'} + + +def item(y, iconkey, title): + """一个列表项:底板(整块可点) + 圆形遮罩 + 图标 + 标题。""" + return [ + dict(kind='rect', x=ITEM_X, y=y, w=ITEM_W, h=ITEM_H, bg=PLATE, radius=ITEM_R), + dict(kind='circle', x=ITEM_X + ICONC_DX, y=y + ICONC_DY, w=ICONC_SZ, h=ICONC_SZ, bg=CIRCLE), + dict(kind='image', x=ITEM_X + ICON_DX, y=y + ICON_DY, w=ICON_SZ, h=ICON_SZ, + src=os.path.join(ASSETS, ICON_PNG[iconkey])), + # 标题行高 40(29px 字体),框高 40 -> 垂直居中 y = 26 + (40-40)/2 = 26 + dict(kind='label', x=ITEM_X + TITLE_DX, y=y + TITLE_DY, w=TITLE_W, h=TITLE_H, + text=title, size=29, weight=S500, color=WHITE, align='left', vtop=True), + ] + + +def clock(t='10:24'): + return dict(kind='label', x=201, y=4, w=78, h=46, text=t, size=29, + weight=S600, color=GRAY, align='left', vtop=True) + + +def title(text, x, w): + return dict(kind='label', x=x, y=36, w=w, h=46, text=text, size=31, + weight=S500, color=WHITE, align='left', vtop=True) + + +def back_arrow(x=167): + return dict(kind='image', x=x, y=44, w=18, h=29, src=os.path.join(ASSETS, 'backarrow.png')) + + +def big_button(y, text): + """大按钮:258x82 圆角 41 —— 圆角把四角收进圆内(本机像素判据实测 0 越界)。""" + return [ + dict(kind='rect', x=111, y=y, w=258, h=82, bg=PLATE, radius=41), + dict(kind='label', x=182, y=y + 22, w=126, h=40, text=text, size=29, + weight=S500, color=WHITE, align='left', vtop=True), + ] + + +# ---------------------------------------------------------------- 1. 首页 +# 标题按真字体宽度居中:'S5e Pt.3' 31px 量得 126 -> x = (480-126)/2 = 177 +PAGE_HOME = [clock(), title('S5e Pt.3', 177, 128), + *item(88, 'ic_help', '快速帮助'), + *item(186, 'ic_replace', '开始替换'), + *item(285, 'ic_about', '关于')] + +# ---------------------------------------------------------------- 2. 快速帮助 +_HELP_HEAD = [clock(), back_arrow(167), title('快速帮助', 188, 124)] +# 说明文字固定 2 行(显式换行),框高 76 = 2 × 行高 35 + 余量 +HELP_DESC_DEF = dict(kind='label', x=DESC_X, y=182, w=DESC_W, h=76, + text='检查 /dev/system 分区与\n61 帧充电动画的完整性。', size=25, + weight=S500, color=GRAY, align='left', vtop=True) +HELP_DESC_OK = dict(kind='label', x=DESC_X, y=182, w=DESC_W, h=76, + text='预检查通过 | 61 条 | 11.27MB\n您可以返回主页开始修改。', + size=25, weight=S500, color=OK, align='left', vtop=True) +HELP_DESC_ERR = dict(kind='label', x=DESC_X, y=182, w=DESC_W, h=76, + text='预检查失败:设备节点不可读\n或分区头不是 ROMFS。', + size=25, weight=S500, color=ERR, align='left', vtop=True) +HELP_DESC_TIP = dict(kind='label', x=DESC_X, y=182, w=DESC_W, h=76, + text='本盘只负责替换,请装\nS5e Pt.3 · 还原。', + size=25, weight=S500, color=GRAY, align='left', vtop=True) +HELP_D1 = dict(kind='label', x=DESC_X, y=355, w=DESC_W, h=36, + text='还原请装 S5e Pt.3 · 还原。', size=25, weight=S500, color=GRAY, + align='left', vtop=True) +HELP_DESC_TIP_RB = dict(kind='label', x=DESC_X, y=182, w=DESC_W, h=76, + text='本盘负责还原,换回动画\n请装 S5e Pt.3。', + size=25, weight=S500, color=GRAY, align='left', vtop=True) +HELP_D1_RB = dict(kind='label', x=DESC_X, y=355, w=DESC_W, h=36, + text='换回动画请装 S5e Pt.3。', size=25, weight=S500, color=GRAY, + align='left', vtop=True) + + +def _help_page(desc, d1=None): + return (_HELP_HEAD + item(88, 'ic_check', '环境预检查') + [desc] + + item(264, 'ic_rollback', '回滚修改') + [d1 or HELP_D1]) + + +PAGE_HELP = _help_page(HELP_DESC_DEF) +PAGE_HELP_OK = _help_page(HELP_DESC_OK) +PAGE_HELP_ERR = _help_page(HELP_DESC_ERR) +PAGE_HELP_TIP = _help_page(HELP_DESC_TIP) + +# ---------------------------------------------------------------- 3. 开始替换(警告确认页) +# 每个片段都用真 MiSans 25px 量过宽度,框宽 ≥ 文本宽 -> 保证**不折行**(折行会让 +# 手工算 x 的多色片段错位),并且整框都在圆内(check_clip 用渲染像素实测)。 +WARN_LABELS = [ + dict(kind='label', x=52, y=93, w=340, h=36, + text='替换充电动画之前,请先确认', size=25, weight=S500, color=GRAY, + align='left', vtop=True), + dict(kind='label', x=52, y=126, w=380, h=36, + text='自己的系统版本号为 3.112.035!', size=25, weight=S500, color=GRAY, + align='left', vtop=True), + dict(kind='label', x=52, y=208, w=280, h=36, text='请确认自己的手表型号为', + size=25, weight=S500, color=GRAY, align='left', vtop=True), + dict(kind='label', x=52, y=241, w=405, h=36, text='Xiaomi Watch S5 eSIM 46mm !', + size=25, weight=S500, color=WHITE, align='left', vtop=True), + dict(kind='label', x=52, y=331, w=330, h=36, text='搞机有风险,变砖后果自负。', + size=25, weight=S500, color=GRAY, align='left', vtop=True), +] +PAGE_CONFIRM = ([clock(), back_arrow(167), title('开始替换', 188, 124)] + + WARN_LABELS + big_button(376, '确认替换')) + +# ---------------------------------------------------------------- 4. 替换中 +LOG_MID = ('> 开始替换\n' + '> 读取 resource.bin\n' + '> 写入 40/61 失败 0\n' + '> 替换完毕,请重启手表。\n' + '> 若发现问题,请回滚修改。') +LOG_END = ('> 开始替换\n' + '> 读取 resource.bin\n' + '> 写入 61/61 失败 0\n' + '> 共 61 条,失败 0 条\n' + '> 版本不符跳过 0 条') +PAGE_RUNNING = [clock(), title('正在替换', 178, 124), + dict(kind='label', x=67, y=93, w=348, h=277, text=LOG_MID, size=25, + weight=S500, color=WHITE, align='left', vtop=True), + *big_button(376, '重启手表')] +PAGE_RUNNING_END = [clock(), title('正在替换', 178, 124), + dict(kind='label', x=67, y=93, w=348, h=277, text=LOG_END, size=25, + weight=S500, color=WHITE, align='left', vtop=True), + *big_button(376, '重启手表')] + +# ---------------------------------------------------------------- 5. 关于 +# 卡片 373 宽:'S5e Pt.3' 29px 量得 119 -> x = 54 + (373-119)/2 = 181(整) +# '1.0.0' 21px 量得 49 -> x = 54 + (373-49)/2 = 216 +PAGE_ABOUT = [ + clock(), back_arrow(198), title('关于', 220, 62), + dict(kind='rect', x=54, y=81, w=373, h=210, bg=PLATE, radius=31), + dict(kind='image', x=200, y=111, w=80, h=80, src=os.path.join(DOCS, 'logo_preview_480.png')), + dict(kind='label', x=181, y=198, w=121, h=40, text='S5e Pt.3', + size=29, weight=S500, color=WHITE, align='left', vtop=True), + dict(kind='label', x=216, y=234, w=49, h=28, text='1.0.0', + size=21, weight=S500, color=GRAY, align='left', vtop=True), + *item(300, 'ic_qq', '732681995'), +] + +PAGES = {'home': PAGE_HOME, 'help': PAGE_HELP, 'help_ok': PAGE_HELP_OK, 'help_err': PAGE_HELP_ERR, + 'help_tip': PAGE_HELP_TIP, 'confirm': PAGE_CONFIRM, 'running': PAGE_RUNNING, + 'running_end': PAGE_RUNNING_END, 'about': PAGE_ABOUT} + + +# ---------------------------------------------------------------- 还原盘(ROLE=restore)的文案 +# 同一套坐标,只换文字。文字比替换盘长的(首页标题 / 关于卡片大标题:加了 " · 还原") +# 必须把 x / w 一起重新按真字体宽度居中 —— check_clip.py 会把 PAGES_ALT 一起渲染并测量。 +def _swap(items, old, new, x=None, w=None): + out = [] + for it in items: + if it.get('kind') == 'label' and it['text'] == old: + it = dict(it, text=new) + if x is not None: + it['x'] = x + if w is not None: + it['w'] = w + out.append(it) + return out + + +PAGES_ALT = { + 'home': _swap(PAGE_HOME, 'S5e Pt.3', 'S5e Pt.3 · 还原', x=127, w=228), + 'home_item2': _swap(PAGE_HOME, '开始替换', '开始还原'), + 'help_d1': _help_page(HELP_DESC_DEF, HELP_D1_RB), + 'help_tip': _help_page(HELP_DESC_TIP_RB, HELP_D1_RB), + 'confirm': _swap(PAGE_CONFIRM, '开始替换', '开始还原'), + 'confirm_btn': _swap(PAGE_CONFIRM, '确认替换', '确认还原'), + 'running': _swap(PAGE_RUNNING, '正在替换', '正在回滚'), + 'about': _swap(PAGE_ABOUT, 'S5e Pt.3', 'S5e Pt.3 · 还原', x=134, w=214), +} diff --git a/s5e-charge/tools/paths.py b/s5e-charge/tools/paths.py new file mode 100644 index 0000000..a2da22a --- /dev/null +++ b/s5e-charge/tools/paths.py @@ -0,0 +1,35 @@ +"""项目里所有脚本的路径入口 —— 换机器 / 换目录只看这一个文件。 + +默认布局(都相对项目根): + inputs/target/ 目标固件的分区镜像(要自己放,见 README) + inputs/source/ 图标来源的分区镜像(要自己放) + inputs/fonts/ 可选:MiSans-Medium.ttf / MiSans-Semibold.ttf(渲染预览用) + vendor/ 第三方 LuaDevTemplate-main(里面有 Compiler.exe,要自己放) + .work/ 构建工作区(产物,可删) + dist/ 交付的 .face + docs/ 说明与预览图 + +环境变量可覆盖:ICON_EDIT_INPUTS / ICON_EDIT_VENDOR / ICON_EDIT_WORK / + ICON_EDIT_DIST / ICON_EDIT_DOCS / ICON_EDIT_FONTS +""" +import os + +PROJ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _p(env, default): + return os.environ.get(env) or os.path.join(PROJ, default) + + +INPUTS = _p('ICON_EDIT_INPUTS', 'inputs') +VENDOR = _p('ICON_EDIT_VENDOR', 'vendor') +WORK = _p('ICON_EDIT_WORK', '.work') +DIST = _p('ICON_EDIT_DIST', 'dist') +DOCS = _p('ICON_EDIT_DOCS', 'docs') +FONTS = os.environ.get('ICON_EDIT_FONTS') or os.path.join(INPUTS, 'fonts') +TEMPLATE = os.path.join(VENDOR, 'LuaDevTemplate-main') +INPUT_TARGET = os.path.join(INPUTS, 'target') +INPUT_SOURCE = os.path.join(INPUTS, 'source') + +for _d in (WORK, DIST, DOCS, INPUT_TARGET, INPUT_SOURCE): + os.makedirs(_d, exist_ok=True) diff --git a/s5e-charge/tools/render_ui.py b/s5e-charge/tools/render_ui.py new file mode 100644 index 0000000..4923707 --- /dev/null +++ b/s5e-charge/tools/render_ui.py @@ -0,0 +1,202 @@ +"""按页面规格渲染 480x480 预览图(真 MiSans 字体 + 真素材 + 圆形遮罩)。 + +支持 kind: rect / circle / image / label +label 支持 recolor 标记 "#RRGGBB 文字#"(LVGL 的写法),以及 align/vcenter/vtop/lh +""" +import os, re, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from PIL import Image, ImageDraw, ImageFont + +from paths import PROJ, INPUTS, FONTS, WORK, DOCS +ROOT = PROJ +FONT_FILES = {'semibold': 'MiSans-Semibold.ttf', 'medium': 'MiSans-Medium.ttf', + 'regular': 'MiSans-Regular.ttf'} +W = H = 480 +_fc = {} + + +def _font_from_firmware(name): + """从 inputs/target/vela_font.bin 里现取 MiSans(固件里就有这几个 ttf)。""" + p = os.path.join(INPUTS, 'target', 'vela_font.bin') + if not os.path.exists(p): + return None + try: + from romfs import Romfs + r = Romfs(p) + except Exception: + return None + for path, ino in r.entries(): + if path == name: + d = os.path.join(WORK, 'fonts') + os.makedirs(d, exist_ok=True) + out = os.path.join(d, name) + open(out, 'wb').write(r.read(ino)) + return out + return None + + +def font(weight, size): + k = (weight, size) + if k in _fc: + return _fc[k] + name = FONT_FILES.get(weight, FONT_FILES['medium']) + p = os.path.join(FONTS, name) + if not os.path.exists(p): + p = _font_from_firmware(name) or p + if not os.path.exists(p): + for alt in (r'C:\Windows\Fonts\msyh.ttc', r'C:\Windows\Fonts\msyhbd.ttc', + r'C:\Windows\Fonts\arial.ttf'): + if os.path.exists(alt): + p = alt + print(' [warn] %s 缺失,预览改用 %s(只影响预览缩略图)' % (name, os.path.basename(alt))) + break + _fc[k] = ImageFont.truetype(p, size) if os.path.exists(p) else ImageFont.load_default() + return _fc[k] + + +def _rgb(c): + return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4)) + + +def split_recolor(text, base): + """LVGL 写法: '灰#ffffff 白#灰' + - # + 6 位十六进制 -> 切到该颜色(该字符不显示) + - 再次遇到单独的 # -> 结束着色,回到基础色(该字符不显示) + """ + runs, buf, cur, incol = [], '', base, False + i = 0 + while i < len(text): + if text[i] == '#': + if re.match(r'[0-9a-fA-F]{6}', text[i + 1:i + 7]): + runs.append((cur, buf)); buf = '' + cur = text[i + 1:i + 7].lower(); incol = True + i += 7 + continue + if incol: + runs.append((cur, buf)); buf = '' + cur = base; incol = False + i += 1 + continue + buf += text[i]; i += 1 + runs.append((cur, buf)) + return runs + + +def wrap_runs(d, runs, f, maxw): + """按像素宽度折行,返回 [[(color, text), ...], ...](尽量保持原换行)""" + lines = [[]] + for color, chunk in runs: + for k, seg in enumerate(chunk.split('\n')): + if k: + lines.append([]) + if not seg: + continue + cur = '' + for ch in seg: + if d.textlength(cur + ch, font=f) > maxw and cur: + lines[-1].append((color, cur)) + lines.append([]) + cur = ch + else: + cur += ch + if cur: + lines[-1].append((color, cur)) + return lines + + +OVERFLOW = [] # (标签文本前 12 字, css_lh, 行数, 块高, 框高) + + +def draw_label(img, d, it): + f = font(it.get('weight', 'medium'), it['size']) + runs = split_recolor(it['text'], it.get('color', 'ffffff')) \ + if it.get('recolor') else [(it.get('color', 'ffffff'), it['text'])] + asc, desc = f.getmetrics() + natural = asc + desc + lh = max(it.get('lh', 0), natural) # LVGL 用字体自身行高,取较大者预测 + lines = wrap_runs(d, runs, f, it['w']) + total = lh * len(lines) + if total > it['h'] + 0.5: + OVERFLOW.append((it['text'][:14].replace('\n', '/'), + round(it.get('lh', 0), 2), len(lines), + round(total, 1), it['h'], round(natural, 2))) + if it.get('vcenter'): + y = it['y'] + (it['h'] - total) / 2 + elif it.get('vtop'): + y = it['y'] + else: + y = it['y'] + (it['h'] - total) / 2 + for ln in lines: + wsum = sum(d.textlength(t, font=f) for _, t in ln) + if it.get('align') == 'center': + x = it['x'] + (it['w'] - wsum) / 2 + elif it.get('align') == 'right': + x = it['x'] + it['w'] - wsum + else: + x = it['x'] + for color, t in ln: + d.text((x, y), t, font=f, fill=_rgb(color) + (255,)) + x += d.textlength(t, font=f) + y += lh + + +def render(items, out, mask=True, ring=False): + img = Image.new('RGBA', (W, H), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + for it in items: + k = it.get('kind') + if k == 'rect': + box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1] + r = it.get('radius', 0) + (d.rounded_rectangle if r else d.rectangle)( + box, radius=r, fill=_rgb(it['bg']) + (255,)) if r else d.rectangle( + box, fill=_rgb(it['bg']) + (255,)) + elif k == 'circle': + box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1] + d.ellipse(box, fill=_rgb(it['bg']) + (255,)) + elif k == 'image': + p = it['src'] + if os.path.exists(p): + im = Image.open(p).convert('RGBA').resize((it['w'], it['h']), Image.LANCZOS) + img.alpha_composite(im, (it['x'], it['y'])) + elif k == 'label': + draw_label(img, d, it) + if mask: + m = Image.new('L', (W, H), 0) + ImageDraw.Draw(m).ellipse([0, 0, W - 1, H - 1], fill=255) + img.putalpha(m) + base = Image.new('RGB', (W, H), (0, 0, 0)) + base.paste(img, (0, 0), img) + if ring: + ImageDraw.Draw(base).ellipse([0, 0, W - 1, H - 1], outline=(80, 80, 90)) + base.save(out) + return out + + +if __name__ == '__main__': + from pages import PAGES, PAGES_ALT + os.makedirs(DOCS, exist_ok=True) + outs = [] + for name, items in list(PAGES.items()) + [('alt_' + k, v) for k, v in PAGES_ALT.items()]: + p = os.path.join(DOCS, 'ui_%s.png' % name) + render(items, p) + outs.append((name, p)) + print('wrote', p) + if OVERFLOW: + print('!! 标签超出框高(会被裁/折行,需要调框或调文案):') + for o in OVERFLOW: + print(' ', o) + else: + print('no label overflow(所有标签都在框内,不多行、不裁切)') + cols = 4 + rows = (len(outs) + cols - 1) // cols + sheet = Image.new('RGB', (cols * (W + 12) + 12, rows * (H + 26) + 12), (28, 28, 32)) + d = ImageDraw.Draw(sheet) + for i, (name, p) in enumerate(outs): + x = 12 + (i % cols) * (W + 12) + y = 12 + (i // cols) * (H + 26) + sheet.paste(Image.open(p), (x, y + 18)) + d.text((x + 4, y + 2), name, fill=(200, 200, 200)) + sp = os.path.join(DOCS, 'ui_all.png') + sheet.save(sp) + print('wrote', sp, sheet.size) diff --git a/s5e-charge/tools/romfs.py b/s5e-charge/tools/romfs.py new file mode 100644 index 0000000..753dda2 --- /dev/null +++ b/s5e-charge/tools/romfs.py @@ -0,0 +1,112 @@ +"""Minimal reader for Xiaomi Vela ROMFS partition images (Linux 'rom1fs' variant). + +Layout (big-endian): + super : magic "-rom1fs-", u32 size, u32 checksum, name (NUL-term, padded to 16) + inode : u32 next (low 4 bits = ROMFH_ type flags), u32 spec, u32 size, u32 checksum, + name (NUL-term, padded to 16), then file data immediately after. + type : 0=hardlink (spec = target inode offset), 1=dir (spec = offset of first child), + 2=regular file, 8=exec bit OR-ed in. + For a directory, its children are the chain starting at `spec`, linked by + `next & ~0xF`, and they stay inside the directory's own block. +""" +import struct, os + +ROUND16 = lambda n: (n + 15) & ~15 + +class Inode: + __slots__ = ('off', 'next', 'spec', 'size', 'cksum', 'name', 'namelen', 'data_off', 'kind') + + @property + def type_flags(self): + return self.next & 0xF + + @property + def type(self): + return self.next & 0x7 + + @property + def exec_bit(self): + return bool(self.next & 0x8) + + @property + def next_off(self): + return self.next & ~0xF + + KIND = {0: 'HRD', 1: 'DIR', 2: 'REG', 3: 'SYM', 4: 'BLK', 5: 'CHR', 6: 'SCK', 7: 'FIF'} + + def __repr__(self): + return (f'') + + +class Romfs: + def __init__(self, path): + self.path = path + self.data = open(path, 'rb').read() + d = self.data + if d[0:8] != b'-rom1fs-': + raise ValueError('not a romfs image') + self.sb_size, self.sb_cksum = struct.unpack('>II', d[8:16]) + end = d.index(b'\x00', 16) + self.volume = d[16:end].decode('latin1') + self.root_off = 16 + ROUND16(end - 16 + 1) + + def inode(self, off): + i = Inode() + i.off = off + i.next, i.spec, i.size, i.cksum = struct.unpack('>IIII', self.data[off:off+16]) + p = off + 16 + end = self.data.find(b'\x00', p, p + 256) + if end < 0: + raise ValueError(f'bad name at 0x{off:x}') + i.name = self.data[p:end].decode('latin1') + i.namelen = end - p + i.data_off = off + 16 + ROUND16(i.namelen + 1) + return i + + def walk(self, first, limit, prefix=''): + """Yield (path, Inode) for every entry reachable from `first` below `limit`. + + `limit` bounds the sibling chain. The LAST entry of a chain has next==0, + but its own children still live after it: they inherit the parent's limit + instead of being bounded by that zero. + """ + off = first + seen = set() + while off and (limit == 0 or off < limit) and off not in seen: + seen.add(off) + i = self.inode(off) + path = prefix + i.name + yield path, i + if i.type == 1 and i.spec: # directory -> first child at spec + yield from self.walk(i.spec, i.next_off or limit, path + '/') + off = i.next_off + + def entries(self): + """All entries of the image, as (path, Inode).""" + return self.walk(self.root_off + 0x20, len(self.data)) + + def read(self, ino): + return self.data[ino.data_off:ino.data_off + ino.size] + + +def img_header(blob): + if len(blob) < 12 or blob[0] != 0x19: + return None + magic, cf, flags, w, h, stride, res = struct.unpack(' 2 and sys.argv[2] not in path: + continue + kind = ('DIR ' if i.type == 1 else 'LNK ' if i.type == 0 else 'FILE') + print(f'{kind} {i.size:>9} {path}') + print(f'-- total entries: {n}') diff --git a/s5e-charge/tools/selfcheck.ps1 b/s5e-charge/tools/selfcheck.ps1 new file mode 100644 index 0000000..7b6a62f --- /dev/null +++ b/s5e-charge/tools/selfcheck.ps1 @@ -0,0 +1,47 @@ +# S5eChg rebuild + self-check pipeline. Key output is appended to docs/selfcheck_run.txt +# Usage: & ".\\.work\\selfcheck.ps1" (run from the project root) +# NOTE: keep this file pure ASCII -- the Chinese text comes from the python tools. +$ErrorActionPreference = 'Continue' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$env:PYTHONIOENCODING = 'utf-8' +$proj = Split-Path -Parent $PSScriptRoot +Set-Location $proj +$py = 'C:\Users\hi\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\python\python.exe' +$log = 'docs\selfcheck_run.txt' +$bar = '=' * 78 + +$stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' +"S5eChg rebuild + self-check run: $stamp" | Out-File -Encoding utf8 $log +"Python : $py" | Out-File -Append -Encoding utf8 $log +"Project: $proj" | Out-File -Append -Encoding utf8 $log + +function Step([string[]]$argv) { + $line = '>>> python ' + ($argv -join ' ') + $bar | Out-File -Append -Encoding utf8 $log + $line | Out-File -Append -Encoding utf8 $log + $bar | Out-File -Append -Encoding utf8 $log + Write-Host '' + Write-Host $line + & $py @argv 2>&1 | Tee-Object -FilePath $log -Append + $rc = $LASTEXITCODE + "EXIT=$rc" | Out-File -Append -Encoding utf8 $log + Write-Host "EXIT=$rc" + if ($rc -ne 0) { + Write-Host '!! step failed, aborting' -ForegroundColor Red + exit $rc + } +} + +Step @('tools\make_charge_payload.py', 'all') +Step @('tools\make_dd_payload.py', 'replace') +Step @('tools\gen_ui_lua.py', 'replace') +Step @('tools\verify_anim.py', 'replace') +Step @('tools\make_dd_payload.py', 'restore') +Step @('tools\gen_ui_lua.py', 'restore') +Step @('tools\verify_anim.py', 'restore') +Step @('tools\verify_all.py') + +'' | Out-File -Append -Encoding utf8 $log +'ALL STEPS OK' | Out-File -Append -Encoding utf8 $log +Write-Host '' +Write-Host "ALL STEPS OK -- log: $log" diff --git a/s5e-charge/tools/verify_all.py b/s5e-charge/tools/verify_all.py new file mode 100644 index 0000000..2e23a5b --- /dev/null +++ b/s5e-charge/tools/verify_all.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +"""独立总校验:只看**交付物 + 固件镜像**,不看任何中间产物(不读 .work/)。 + + python tools/verify_all.py # 生成 docs/selfcheck_report.txt + 预览图 + +逐项判据(每项都是一条可证伪的断言): + A 交付物形态:.face 魔数、watchface id = 462150103(两份共用)、b5=0、体积上限 + B Lua 表:从 .face 里的 Lua 反解 pay / rb 表(各 61 条) + C 等长:每条 pay 的长度 == 原厂槽位长度,且目标偏移就是 charging/chargeN.bin 的 + 数据偏移(61 条恰好一一覆盖,无重复无遗漏) + D 可解码:每条 pay 是 480x480 I8+RLE,且 RLE 流**恰好**解出 usize = 1024+480*480 + E 帧内容:把交付物里的每一帧解出来,与"用 q63 源帧独立做的 Lanczos 放大"比对, + 平均差 < 1.0/255(吸附误差量级),并报最像的源帧号(证明 n 对 n 没错帧) + F 版本指纹:replace 盘的 rb == **原厂**前 64 B(对固件镜像独立比对) + restore 盘的 pay == **原厂**整条字节(逐字节) + restore 盘的 rb == replace 盘 pay 的前 64 B(两份交付物互证) + G 整镜像:用 replace 盘的负载重建改后镜像 -> inode 表 / superblock / 长度与原厂 + 逐位一致,且**除 61 个槽位外没有任何字节变化** + H 预览:把交付物解出来的 61 帧拼成图(docs/decode_preview.png)便于肉眼复核 +""" +import os, re, sys, hashlib, struct +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import PROJ, INPUTS, DOCS +from romfs import Romfs +import lvgl_bin as L +from make_charge_payload import src_indexed +from PIL import Image, ImageDraw +import numpy as np + +W = H = 480 +WF_ID = '462150103' +DIRP = 'charging/' +STOCK = os.path.join(INPUTS, 'target', 'vela_system.bin') +SOURCE = os.path.join(INPUTS, 'source', 'vela_system.bin') +FACES = {'replace': os.path.join(PROJ, 'dist', 'S5Chg.face'), + 'restore': os.path.join(PROJ, 'dist', 'S5ChgRestore.face')} +REPORT = [] +ok = fail = 0 + + +def log(s=''): + print(s) + REPORT.append(s) + + +def ck(cond, msg, detail=''): + global ok, fail + if cond: + ok += 1 + else: + fail += 1 + log(' [%s] %s%s' % ('OK ' if cond else 'FAIL', msg, (' ' + detail) if detail else '')) + return cond + + +def num_of(p): + return int(p.rsplit('charge', 1)[-1].replace('.bin', '')) + + +def slots_of(path): + rom = Romfs(path) + d = {} + for p, i in rom.entries(): + if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin'): + continue + d[i.data_off] = (num_of(p), i.size) + return rom, d + + +def decode(blob): + h = L.parse_header(blob) + m, csize, usize = struct.unpack(' ' + os.path.join(DOCS, 'selfcheck_report.txt')) +sys.exit(1 if fail else 0) diff --git a/s5e-charge/tools/verify_anim.py b/s5e-charge/tools/verify_anim.py new file mode 100644 index 0000000..748de1f --- /dev/null +++ b/s5e-charge/tools/verify_anim.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""独立校验交付的 .face(只看交付物 + 固件镜像,不看中间产物)。 + + role: replace -> dist/S5Chg.face pay 应等于**改后镜像**的字节,fp 应等于**原厂**前 64 B + restore -> dist/S5ChgRestore.face pay 应等于**原厂**字节, fp 应等于**改后镜像**前 64 B + ① 从 .face 里抠出 Lua 表(pay / rb),按表里的偏移取字节 + ② 每条 pay 长度必须 == 原厂槽位长度(等长覆盖的底线),且能按 LVGL RLE 解出 480x480 + ③ rb 每条必须是 64 B 指纹,且等于另一侧字节的前 64 B(版本校验数据正确) + ④ 目标镜像的 inode 表 / superblock / 分区长度与原厂逐位一致 +""" +import os, re, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import PROJ, INPUTS, WORK +from romfs import Romfs +import lvgl_bin as L + +ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() +NAME = 'S5Chg' if ROLE == 'replace' else 'S5ChgRestore' +FACE = os.path.join(PROJ, 'dist', NAME + '.face') +STOCK = os.path.join(INPUTS, 'target', 'vela_system.bin') +PATCHED = os.path.join(WORK, 'charge', 'vela_system_charge.bin') +DIRP = 'charging/' +FP_LEN = 64 +ok = fail = 0 + + +def ck(cond, msg): + global ok, fail + if cond: + ok += 1 + else: + fail += 1 + print(' !! ' + msg) + + +face = open(FACE, 'rb').read() +src = face.decode('latin1') +m = re.search(r'pay = \{(.*?)\n \},', src, re.S) +m2 = re.search(r'rb = \{(.*?)\n \},', src, re.S) +ck(m is not None and m2 is not None, '在 .face 里找不到 pay / rb 表') +pay = [tuple(int(x) for x in r) for r in re.findall(r'\{(\d+),(\d+),(\d+)\}', m.group(1))] +rb = [tuple(int(x) for x in r) for r in re.findall(r'\{(\d+),(\d+),(\d+)\}', m2.group(1))] +print('%s:pay %d 条,rb %d 条' % (NAME, len(pay), len(rb))) + +stock_b = open(STOCK, 'rb').read() +patch_b = open(PATCHED, 'rb').read() +c = re.search(r"local ROLE = '(\w+)'", src) +ck(c is not None and c.group(1) == ROLE, 'Lua 里的 ROLE 不是 %s' % ROLE) +EXPECT_PAY = patch_b if ROLE == 'replace' else stock_b +EXPECT_FP = stock_b if ROLE == 'replace' else patch_b +stock = Romfs(STOCK) +slots = {} +for p, i in stock.entries(): + if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0: + continue + slots[i.data_off] = (p, i.size) + +canary = [r for r in pay if r[1] == 0 and r[2] == 32] +ck(len(canary) <= 1, 'canary 记录不止一条') +if canary: + ck(face[canary[0][0]:canary[0][0] + 32] == stock_b[:32], 'canary 不对') +data = [r for r in pay if not (r[1] == 0 and r[2] == 32)] +bad_len = bad_dec = bad_bytes = 0 +for o, t, ln in data: + blob = face[o:o + ln] + if t not in slots or slots[t][1] != ln: + bad_len += 1; continue + if EXPECT_PAY[t:t + ln] != blob: + bad_bytes += 1 + h = L.parse_header(blob) + if h is None or not (h['flags'] & L.FLAG_COMPRESSED) or h['w'] != 480 or h['h'] != 480: + bad_dec += 1; continue + # ★ 设备端解码器要求流解出**正好 usize 字节**(1024 + w*h + 1),少一字节就整帧不画 + import struct as _s + _m, _cs, _us = _s.unpack(' x = (480-121)/2 = 179.5 -> 179 + pageTitle(p, 'S5e Pt.1', 179, 123) listItem(p, 88, 'ic_help', '快速帮助', function() show(pages.help) end) listItem(p, 186, 'ic_replace', '开始替换', function() pendingVariant = 'replace' @@ -602,7 +603,8 @@ local function buildAbout() if IMG.logo then pcall(function() p:Image { src = IMG.logo, x = 200, y = 111 } end) end - lab(p, { x = 170, y = 198, w = 141, h = 40, text = 'OS4 Icons', + -- 'S5e Pt.1' 29px 量得 114 -> 居中 x = 54 + (373-114)/2 = 183.5 -> 183 + lab(p, { x = 183, y = 198, w = 116, h = 40, text = 'S5e Pt.1', text_color = C_WHITE, font_size = 29, text_font = F_ITEM }) lab(p, { x = 216, y = 234, w = 49, h = 28, text = '1.0.0', text_color = C_GRAY, font_size = 21, text_font = F_VER }) diff --git a/s5e-icons/tools/s5e_build.py b/s5e-icons/tools/s5e_build.py index c0a37e5..8642761 100644 --- a/s5e-icons/tools/s5e_build.py +++ b/s5e-icons/tools/s5e_build.py @@ -21,7 +21,8 @@ TPL = TEMPLATE OVER = os.path.join(WORK, 'overlay', 'S5eIcons') BUILD = os.path.join(WORK, 'build', 'S5eIcons') OUTFACE = DIST -NAME = 'S5eIcons' +NAME = 'S5eIcons' # 路径/标识(文件名、目录名、dist 名都用它,**不要改**) +DISPLAY_NAME = 'S5e Pt.1' # 手表上显示的表盘名 = fprj 的 = config 的 projectName WF_ID = '462150101' # 462 = S5 家族(固件自带 AOD 表盘是 462150001..8) DEVICE_TYPE = '462' SCREEN = 480 @@ -75,7 +76,7 @@ def prepare(lua_text): if os.path.exists(old): os.remove(old) - cfg = {"projectName": NAME, "watchfaceId": WF_ID, "power_consumption": "3", + cfg = {"projectName": DISPLAY_NAME, "watchfaceId": WF_ID, "power_consumption": "3", "resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE", "input": "watchface/fprj/images/preview.png", "name": "preview"}} json.dump(cfg, open(os.path.join(BUILD, 'watchface.config.json'), 'w', encoding='utf-8'), @@ -86,11 +87,11 @@ def prepare(lua_text): ' \r\n' ' \r\n' - ' \r\n\r\n') % (DEVICE_TYPE, NAME, SCREEN, SCREEN) - open(os.path.join(BUILD, 'watchface', 'fprj', NAME + '.fprj'), 'w', + ' \r\n\r\n') % (DEVICE_TYPE, DISPLAY_NAME, SCREEN, SCREEN) + fprjdir = os.path.join(BUILD, 'watchface', 'fprj') + open(os.path.join(fprjdir, NAME + '.fprj'), 'w', encoding='utf-16', newline='').write(fprj) - fprjdir = os.path.join(BUILD, 'watchface', 'fprj') make_preview(os.path.join(fprjdir, 'images', 'preview.png')) open(os.path.join(fprjdir, 'app', 'lua', 'main.lua'), 'w', encoding='utf-8', newline='\n').write(lua_text) @@ -110,8 +111,12 @@ def prepare(lua_text): def run_build(script=True): ps = os.path.join(BUILD, 'scripts', 'build_face.ps1') - r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps], + # 显式给 -FaceName:vendor 脚本默认拿 config 的 projectName 当输出名,而 projectName + # 现在是**显示名**(S5e Pt.1),交付文件名必须还是 NAME.face。 + r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps, + '-FaceName', NAME + '.face'], capture_output=True, text=True, errors='replace') + workspace_fprj() # vendor 可能把 .fprj 改名成显示名,编完改回 NAME.fprj tail = [l for l in ((r.stdout or '') + (r.stderr or '')).splitlines() if l.strip()][-6:] for l in tail: log(' | ' + l) @@ -121,9 +126,28 @@ def run_build(script=True): return open(face, 'rb').read() +def workspace_fprj(): + """把工作区里的 .fprj 归一成 NAME.fprj,并返回它的路径。 + + 我们写下去时就是 NAME.fprj,但 vendor 的 build_face.ps1 会先跑 + sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名**)找 + .fprj,找不到就把唯一那份改名过去(Title 也被改写成 projectName)。 + 编完这里再改回 NAME.fprj —— 这样工作区里始终只有一份 .fprj(放两份会被 + Compiler.exe 并成双倍资源,实测资源整体翻倍),文件名也保持项目标识不变。 + """ + d = os.path.join(BUILD, 'watchface', 'fprj') + fs = sorted(f for f in os.listdir(d) if f.endswith('.fprj')) + if len(fs) != 1: + raise SystemExit('watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % fs) + want = os.path.join(d, NAME + '.fprj') + if fs[0] != NAME + '.fprj': + os.replace(os.path.join(d, fs[0]), want) + return want + + def compile_raw(): exe = os.path.join(BUILD, 'watchface', 'tools', 'Compiler.exe') - fprj = os.path.join(BUILD, 'watchface', 'fprj', NAME + '.fprj') + fprj = workspace_fprj() out = os.path.join(BUILD, 'raw') if os.path.exists(out): shutil.rmtree(out) diff --git a/s5e-icons/tools/s5e_pages.py b/s5e-icons/tools/s5e_pages.py index 79a4bb7..56a1b7c 100644 --- a/s5e-icons/tools/s5e_pages.py +++ b/s5e-icons/tools/s5e_pages.py @@ -63,7 +63,7 @@ def big_button(y, text): ] -PAGE_HOME = [clock(), title('OS4 Icons', 164, 152), +PAGE_HOME = [clock(), title('S5e Pt.1', 179, 123), *item(88, 'ic_help', '快速帮助'), *item(186, 'ic_replace', '开始替换'), *item(285, 'ic_about', '关于')] @@ -122,7 +122,7 @@ PAGE_ABOUT = [ clock(), back_arrow(198), title('关于', 220, 62), dict(kind='rect', x=54, y=81, w=373, h=210, bg=PLATE, radius=31), dict(kind='image', x=200, y=111, w=80, h=80, src=os.path.join(DOCS, 'logo_preview_480.png')), - dict(kind='label', x=170, y=198, w=141, h=40, text='OS4 Icons', + dict(kind='label', x=183, y=198, w=116, h=40, text='S5e Pt.1', size=29, weight=S500, color=WHITE, align='left', vtop=True), dict(kind='label', x=216, y=234, w=49, h=28, text='1.0.0', size=21, weight=S500, color=GRAY, align='left', vtop=True), diff --git a/s5e-icons/ui_images/logo.bin b/s5e-icons/ui_images/logo.bin index cca18bb..6935df5 100644 Binary files a/s5e-icons/ui_images/logo.bin and b/s5e-icons/ui_images/logo.bin differ diff --git a/s5e-pressure-anim/.gitignore b/s5e-pressure-anim/.gitignore new file mode 100644 index 0000000..13a0346 --- /dev/null +++ b/s5e-pressure-anim/.gitignore @@ -0,0 +1,10 @@ +# 大文件 / 中间产物:不进仓库 +.work/ +inputs/ +vendor/ +__pycache__/ +*.pyc +payload/ +payload_restore/ +blob_replace/ +blob_restore/ diff --git a/s5e-pressure-anim/README.md b/s5e-pressure-anim/README.md new file mode 100644 index 0000000..b93d11d --- /dev/null +++ b/s5e-pressure-anim/README.md @@ -0,0 +1,319 @@ +# S5ePAnim —— 把 S5 41mm(q63) 的压力检测动画搬到 S5 eSIM 46mm + +把 **Xiaomi Watch S5 41mm(q63, v4.101.020)** 压力检测的 **100 帧 480×480** 动画, +逐帧**等长覆盖**写进 **Xiaomi Watch S5 eSIM 46mm(p62lte, v3.112.035)** `/dev/health` 里 +`pressure/measure/Measuring1..119.bin` 的 **119 个槽位**(**464×464** I8 + LVGL RLE)。 + +> 关系:`s4e-pressure-anim` 是「S5 41mm → S4 eSIM(466×466)」的母项目(只读参考)。 +> 本项目沿用它的全部结构(两遍构建回填偏移、指纹表拆盘、逐字节等长覆盖、独立校验), +> 只把目标换成 S5 eSIM、界面重做成 480×480、写入节点换成 `/dev/health`。 + +## 交付物 + +| 文件 | 字节数 | id / b5 | sha256 | +|---|---|---|---| +| `dist/S5ePAnim.face` | **16,450,844** | `462150102` / b5=0 | `d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c` | +| `dist/S5ePAnimRestore.face` | **16,450,844** | `462150102` / b5=0 | `13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69` | + +两份盘**共用同一个表盘 id**(`462150102`):装其中一份就会顶掉另一份,设备上始终只占一份空间 +(合在一张盘要 ~31 MB,超体积 —— 这是当初拆盘的原因)。 + +* `S5ePAnim.face`:`pay` = 替换后的 119 条(q63 的帧),`rb` = **原厂**前 64 B 指纹(只给版本校验用) +* `S5ePAnimRestore.face`:`pay` = **原厂** 119 条(逐字节),`rb` = 替换后前 64 B 指纹 + +两张盘除了负载/指纹与 Lua 里的两张表以外没有任何差别(头 50 B、8 张界面素材、资源基址逐字节相同)。 + +装法与普通 Lua 表盘相同(把生成的 `resource.bin` 推到表盘目录,例如 +`/data/app/watchface/market/462150102/`)。装好后在表盘上操作: + +``` +首页 →「快速帮助」→「环境预检查」 只读,一个字节都不写 +首页 →「开始替换」→ 确认页 →「确认替换」 写入 119 条 → 「重启手表」 +还原:装 dist/S5ePAnimRestore.face(同 id 顶掉替换盘)→「开始还原」→ 重启 +``` + +## 源 / 目标 + +| | 分区 | 槽位 | 尺寸 / 格式 | 单条大小 / 合计 | +|---|---|---|---|---| +| **目标** S5 eSIM 46mm(p62lte v3.112.035) | `inputs/target/vela_health.bin`(`/dev/health`,卷名 `health`) | `pressure/measure/Measuring1..119.bin` | 464×464 I8 **RLE**,`usize` **216,321 = 1024 + 464×464 + 1** | 4,485 ~ 196,538 B,共 **15.49 MB** | +| **源** S5 41mm(q63 v4.101.020) | `inputs/source/vela_app.bin` | `pressure/measure/Measuring1..100.bin` | 480×480 I8 **RLE**,`usize` 231,424 = 1024 + 480×480(**没有**尾巴字节) | ~182 KB / 帧 | + +* 目标镜像 sha256 `489752c8a3fb24fff3e167610964795f0faaf830f142223b63be6ac2f57e67aa` + (206,924,800 B,sb_size 206,924,736,superblock cksum `b804503a`)—— **偏移锁死这一版固件**。 +* 改后镜像 `.work/anim/vela_health_anim.bin` sha256 `b11b72fcb38ad75a643d93d7db4dd366477ac33bfed93ef91096e04a73d67479`。 + +**映射**:第 k 个槽位(k=1..119) ← q63 第 `round((k-1)/(119-1)*(100-1))+1` 帧(1..100)。 +119 与 100 不是整数倍关系,所以有 19 帧被连续两个槽位复用(`119↔100` 比例重采样,**不是** 1:1), +帧号顺序与速度与原动画一致。 + +### 目标固件的事实(从固件包里查出来的) + +| 项 | 结论 | 证据 | +|---|---|---| +| 写入分区 | `vela_health.bin`,**卷名 `health`**,设备节点 `/dev/health` | ROMFS superblock 卷名 = `health`;206,924,800 B | +| 压力动画槽位 | `pressure/measure/Measuring1.bin … Measuring119.bin`,**119 帧** | 逐条枚举 ROMFS;编号连续 1..119 | +| 槽位格式 | 464×464,`cf=0x0a`(I8),`flags=0x08`(RLE),`method=1`,`stride=464` | 每条 12 B 图像头 + 12 B 压缩头;119 条的 `usize` 全是 216,321 | +| 槽位长度 | 4,485 ~ 196,538 B(差 40 倍),合计 15.49 MB | 逐条统计;`槽位长度 = 24 B 头 + csize` | +| 源素材 | q63 的 `vela_app.bin` 里 `pressure/measure/Measuring1..100.bin` | q63 的 `vela_health.bin` 里**没有** `pressure/measure/` | +| 源格式 | 480×480 I8 + RLE,`usize` 231,424,约 182 KB/帧 | 底噪是接近黑的 `#1a1a1a` 抖动 | +| 表盘宿主 | Lua 5.4,480×480 圆屏;`Compiler.exe` 对 DeviceType 462 强制 326×326 预览 | 同 `s5e-icons` 的实测结论 | + +## 怎么做到「等长覆盖」 + +ROMFS 里每个文件的数据长度是硬编码的(后面所有文件的偏移都是绝对值),所以槽位必须**逐字节等长**替换: +`槽位长度 = 24 B 头(12 B 图像头 + 12 B 压缩头)+ csize 字节 RLE 流`。而两边尺寸不同、还都是 RLE, +压缩后长度各不相同。流水线(`tools/s5e_anim.py`): + +1. **暗部归零**(能不能塞进去的前提):q63 的"黑"其实是一条接近黑的抖动底噪,熵极高; + 阈值(亮度 < 40,部分帧 < 56)以下的像素一律钉成纯黑之后,背景塌成整行单游程, + RLE 体积掉到约 1/4。球体最暗的实体像素远高于阈值,所以只吃底噪、不动画面。 +2. **缩放 + 量化**:480×480 → 464×464,在一条 34 档的阶梯上挑一档 + (256 色@1.00 → … → 32 色@0.70 → … → 2 色@0.05)。 +3. **平滑**:以各帧"还能保住 ≥32 色"的最大缩放为上限,从峰值向两侧取**运行最小值**, + 得到单峰、单调的曲线,避免相邻帧忽大忽小(S4e 真机反馈过"忽大忽小")。 +4. **编码**:LVGL RLE(`tools/lvgl_bin.py: rle_compress`)。 +5. **补齐**:`pad_rle_safe` 用两类**产出 0 像素**的合法指令把流补到 csize + (`00 00` = 重复 0 次;`80` = 原样 0 个,用来修奇偶)。 +6. **usize 尾巴**:解码长度必须**恰好**等于 header 里的 `usize`。 + 目标槽位的 `usize = 216,321 = 1024 + 464×464 + **1**` —— 像素区之后还有 1 个字节; + 我们编码时也补出这 1 个字节(`usize_tail`),否则设备端 RLE 解码器认为流不完整, + **整帧直接不画**(S4e 真机上就是这个现象:黑屏、只偶尔闪一下原版帧)。 + `s5e_anim.py` 里对这条有显式断言(源世代是 0 字节尾巴、目标世代是 1 字节,两者不同)。 + +### 编码期的三条就地自证(每条负载都跑) + +1. `len(decode(stream)) == usize`(少一字节设备端就不画) +2. 解出来的像素 == 量化结果(逐点) +3. 量化结果的**形状**(非黑像素数 + bbox)与源图一致 + +> 第 3 条是被真 bug 逼出来的:早期版本量化时按 472 宽取像素、却读成 464 宽,整幅图被剪成 +> **斜条纹** —— RLE 流完全正确、解出来也和"量化结果"逐点一致,只有比形状才抓得住。 + +**替换质量**:**119/119 全部替换**,`kept_stock = []`(没有一帧保留原厂)。 +颜色分布:256 色 91 帧、192 色 1、128 色 2、96 色 1、64 色 7、48 色 3、32 色 6、 +24 色 1、16 色 2、12 色 1、4 色 4。 + +* 开头 8 帧(`Measuring1..8`,4~24 色、缩放 0.16~0.65)是**设计如此**:动画从一个小点长起来, + 平滑曲线本身的目标档就在低端,不是被迫降级。 +* **14 帧**(`Measuring106..119`)是**被迫退档**:平滑目标档(256 色@1.00)塞不进那 14 个偏小的槽位 + (槽位 19,025~77,823 B),退到 192/128/64/48/32 色 + 0.70~0.90 缩放;逐帧的 `(ci, target_ci, how)` + 都在 `.work/anim/manifest.json` 的 `records` / `degraded` 字段里。 +* 预算利用率约 **49%**(编码 7.88 MB / 槽位 16.24 MB):源帧本身是抖动过的 256 色图, + 去掉底噪后在 464×464 下的熵上限就是约 80 KB,而尾部槽位有 150~196 KB。 + 想把预算填满只能把源图的**抖动颗粒**一起搬过去(背景会泛灰,也不是有效细节), + 权衡后选择保持画面干净 —— 这是**有意的取舍**,不是做不到。 + +## 真机怎么做 + +1. 装 `dist/S5ePAnim.face` +2. 表盘「快速帮助 → 环境预检查」(**只读**):确认 `/dev/health` 可读、卷名是 `health`、 + 119 条负载不越界(用 superblock 的**大端** size 判),并对首/中/尾三条做抽样版本校验 + (`原厂✓` / `已替换✓`) +3. 首页「开始替换」→ 确认页 → **确认替换** → 等 `成功 119 失败 0`(15.49 MB) +4. 「重启手表」→ 打开压力 App 开始检测,看整段动画 +5. 还原:装 `dist/S5ePAnimRestore.face` →「开始还原」→ 重启 + +「正在替换」页每 10 条打一行 `> 写入 n/119 成功 x 失败 y`,结束打 `> 共 119 条,成功 x 失败 y`。 +**判断成功的唯一标准是 `失败 0`**;若出现 `> 版本不符跳过 n 条`,说明设备上的 `/dev/health` +不是算偏移时用的那一版固件 —— **不要重启**,装对应版本的盘或先还原。 + +写入前每条都做版本校验:读设备上该偏移的前 64 B,必须等于「本盘要写的字节」或「另一侧的指纹」, +**两边都不是就判版本不符,并跳过 `/dev/health` 剩余记录**(绝不把字节写进别人的文件里)。 +写入后用**内容比对**判成败(`os.execute` 的返回值在这台机器上不可靠),失败自动重试 3 次, +仍不过就退化成「整条一次 dd」。 + +### 为什么写入要分段(S4e 真机的教训) + +119 个槽位**全部 512 对齐**,但 **97/119 条的长度不是 4 的倍数**(RLE 流长度随机)。 +若按长度取块大小就只能退化成 `bs=1` —— 一条 19.6 万字节的记录要 ~40 万次单字节读写, +真机上慢到偶发失败。本项目按「`bs=512` 大块 + `bs=4` 次块 + 最后 1~3 字节 `bs=1`」切成 ≤3 段, +每条最多 3 个 dd 调用。 + +## 界面(480×480) + +设计稿只有一处:`tools/s5e_pages.py`(`main.lua` 用的就是同一套坐标), +用真 MiSans 渲染到 `docs/ui_*.png`,并用**真实渲染像素**判圆屏切边(`tools/s5e_check_clip.py`)。 + +圆屏的硬约束:**在高度 y 处,屏幕的弦半宽 = √(240² − (y−240)²)**,越靠下越窄。所以: + +* 列表底板 `radius=40` 不是审美:y=88 那行弦半宽只有 185.8,圆角不够时四个角会越过圆边 +* 型号那一行拆成两行(`Xiaomi Watch S5` / `eSIM 46mm(p62lte)`):整串 25px 真 MiSans 量得 + 367px,而 y=243 处弦半宽只有 ~190px,放一行必被圆边切掉 +* 说明文字每行都控制在 348px 以内(25px 下约 13 个中文字),否则折行后第 2 行会被圆边切 +* 多色文本**不能**用 LVGL recolor(会被原样显示成 `#ffffff…`),一律拆成多个标签 + 手工算 x + +实测:7 个页面越界像素 **0**(最远点 238.4 / 半径 240),标签溢框 0。 + +## 重建 + +```powershell +$env:PYTHONIOENCODING="utf-8" # Windows 上必须,否则中文输出会炸 +# 需要 Python 3 + Pillow (+ numpy) +python tools\s5e_anim.py all # 100 帧 -> 119 个等长槽位 + 改后整分区镜像(几分钟,几百 MB) +python tools\s5e_lua_static.py # main.lua 静态体检(块配平 / 先用后声明 / 模板约定) + +python tools\s5e_payload.py replace # 负载 + 指纹 -> payload/ + blob_replace/ +python tools\s5e_overlay.py replace # 480x480 overlay(首页真渲染 -> 326x326 缩略图) +python tools\s5e_build.py replace # 两遍构建 -> dist\S5ePAnim.face(0 漂移校验) +python tools\s5e_verify.py replace # 逐盘校验(38 项) + +python tools\s5e_payload.py restore +python tools\s5e_overlay.py restore +python tools\s5e_build.py restore # -> dist\S5ePAnimRestore.face +python tools\s5e_verify.py restore + +python tools\s5e_verify_indep.py # 独立总校验(70 项,不读构建中间产物) +``` + +界面相关(改了 `tools/s5e_pages.py` / `main.lua` 之后): + +```powershell +python tools\s5e_ui_images.py # assets/ -> ui_images/*.bin(8 张 LVGL v9 素材) +python tools\s5e_render_ui.py # 480x480 预览 -> docs\ui_*.png + docs\ui_all.png(会报溢框) +python tools\s5e_check_clip.py # 真实渲染像素判圆屏切边(0 越界才算过) +``` + +### 需要自己准备的输入(放进 `inputs/`,不进仓库) + +| 路径 | 是什么 | 大小 | +|---|---|---| +| `inputs/target/vela_health.bin` | 目标固件(p62lte)health 分区 | 206,924,800 | +| `inputs/target/vela_app.bin` | 目标固件 app 分区(可选) | 103,043,072 | +| `inputs/target/vela_font.bin` | 目标固件 font 分区(可选:渲染预览时从里面取 MiSans) | 43,379,712 | +| `inputs/source/vela_app.bin` | 素材来源(S5 41mm q63)app 分区 | 206,000,128 | +| `inputs/fonts/MiSans-*.ttf` | 可选;不提供就自动从 `inputs/target/vela_font.bin` 里取 | — | +| `vendor/LuaDevTemplate-main/` | 官方模板(里面有 `Compiler.exe`) | — | + +固件分区镜像就是官方 OTA 包(ZIP)里的 `vela_*.bin` 条目,解压出来即可。 +**不要设 `ICON_EDIT_VENDOR`** —— 每个项目自带 `vendor/LuaDevTemplate-main/`。 + +## 自证输出(全部真实执行过,原始输出在 `docs/selfcheck_run.txt` / `docs/selfcheck_report.txt`) + +**① 119 条负载长度逐条 == 原槽位长度** + +``` +OK 每条负载长度 == 原槽位长度(119 条) <- s5e_verify.py(两盘各一次) +OK 第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符) <- s5e_verify_indep.py:名字也对得上 +OK 119 个目标偏移全部 512 对齐 + (97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1) +``` + +**② 每条负载解出来都是 464×464,且 RLE 恰好 `usize`** + +``` +OK 每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464 +OK 每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符) +OK 每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符) +``` + +**③ 改后镜像的 inode 表 / superblock 逐位不变、槽位外 0 字节变化** + +``` +OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变 +OK superblock 与卷名不变(size 206924736 / cksum b804503a / 'health') +OK 分区长度不变(206924800 B) +OK 除 119 个槽位外,其余 5275 个文件的字节**逐字节**不变(0 处不符) +OK 变化区间 0x2c18618..0x3b9d384 完全落在槽位区间 0x2c18600..0x3b9d385 内 +OK 119 个槽位全部被改写(未变的:无) +``` + +**④ `.face` 里 Lua 表反解出的偏移与镜像一致(0 漂移)+ 两张盘互证** + +``` +OK 两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续) +OK Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移 <- s5e_verify.py +偏移校验:全部吻合,0 漂移 <- s5e_build.py(两遍构建) +OK replace 的负载前 64 B == restore 的指纹;restore 的负载前 64 B == replace 的指纹 +OK 119/119 条负载在两张盘里都不一样 +``` + +**总账** + +``` +s5e_lua_static.py : 全部通过(块配平 202/202;无先用后声明;4 个占位符各 1 次) +s5e_verify.py replace : 38 项通过,0 项失败 +s5e_verify.py restore : 38 项通过,0 项失败 +s5e_verify_indep.py : 70 项通过,0 项失败 +s5e_check_clip.py : 7 个页面,越界 0(最远 238.4 / 半径 240) +``` + +## 构建期的两个坑(都在 `tools/s5e_build.py` 里修掉了) + +**① 交付物的 b5 曾经是 10(必须和固件自带表盘一样是 0)** +两遍构建走的是 vendor 的 `scripts/build_face.ps1`,它编完之后会调 +`scripts/internal/set_face_id.ps1`,里面有一句 `$bytes[5] = [byte]$faceIdSize`(= 10)—— +把 id 字符串长度写进了头里第 5 字节。现在最终产物取自**裸 `Compiler.exe -b`** 的输出 +(b5 由编译器给出,实测 0),只覆盖 40..49 = id;并断言「裸产物与 toolchain 产物只差 +b5 + id 这 8 个字节」,否则拒绝交付。 + +**② 第二遍曾经编的还是占位符版 Lua** +`main.lua` 是带 `__UIIMG__ / __PAY__ / __RB__ / __ROLE__` 的模板:第一遍编模板、量出资源偏移; +第二遍必须把**回填后的 Lua 写回编译工作区**再编。之前只把回填结果写到了 +`.work/overlay/...`(给人看的副本),编译工作区里还是模板 → 交付物里的 Lua 带着占位符, +表盘在设备上会直接"负载表为空"。现在第二遍前会 `write_lua(resolved)`。 + +## 已知边界 / 没做的部分 + +1. **真机未验证 S5 eSIM 的 `/dev/health` 可写**:同源平台的 S4 eSIM 上 dd 读写流程实测通过, + S5 eSIM 上只能先靠只读的「环境预检查」确认可读 + 偏移对版。整条 119 条的写入路径在本机 + **无法**端到端验证(没有设备),本机只验证了字节、长度、偏移、头、镜像完整性。 +2. **偏移锁固件版本**:换固件必须重跑 `s5e_anim.py all` → 4 步流水线。用错版本时表盘会判 + 「版本不符」并跳过 `/dev/health` **剩余**记录(`job.skipRest`),不会把字节写进别人的文件里。 +3. **开头 8 帧与结尾 14 帧的画质**:开头 8 帧只有 4~24 色(它们是"一个小点",槽位预算 + 4.5~17.5 KB);结尾 14 帧被预算逼到 32~192 色 + 0.70~0.90 缩放。这是等长覆盖的硬约束 + (464×464 的 I8 图光全黑背景就要 ~3.7 KB),不是编码 bug;要更大就得上「非等长写」, + 那会动 inode 表和后面所有文件的偏移(本项目刻意不做)。 +4. **`Compiler.exe` 的设备表里没有 480 档**:圆表且预览 326×326 的只有 362 / 462, + 所以用 `DeviceType=462` + 326×326 预览图。预览只是表盘列表里的缩略图, + 实际画面尺寸由 `main.lua` 与 `.fprj` 的 480 决定。 +5. **负载文件必须原样连续放在 resource.bin 里**,偏移才等于 `文件基址 + 魔数长度 + 相对偏移`; + `s5e_build.py` 会逐条用生成时的 sha256 校验,并做两遍 0 漂移检查。魔数长度是 **15 B** + (`S5P-PAYv1`),曾经写成 16 导致全部偏移差 1。 +6. **分区键名 ≠ 节点路径**:Lua 表里存 `'health'`,运行期由 `DEVS['health']` 取到 `/dev/health`。 + 把 `/dev/health` 直接存进表里,拼出来就是 `if=nil`。`s5e_verify.py` / `s5e_lua_static.py` + 都专门检查这一条。 +7. **没动的**:inode 表、目录项、superblock、任何非 `pressure/measure/Measuring*.bin` 的文件; + 仓库里其它项目(`s4e-*`、`s5e-icons`、`s5e-charge`)与 git(未 commit、未改 `.gitignore`)。 + +## 第三方与风险 + +* 本仓库不含固件、OTA 包、字体;`.face` 由第三方 `Compiler.exe` 编译 +* 往分区节点写数据是危险操作:写错偏移可能损坏资源分区。表盘自带版本校验与还原盘, + 但请自行确认设备所有权并承担后果 +* 官方 OTA 升级会整分区覆盖,动画会回到原厂(等于自动还原) + +## 目录 + +``` +main.lua 表盘源码(含 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 占位符,构建时回填) +tools/ 全部脚本(见下表) +ui_images/ 界面素材(LVGL v9 .bin,8 张,480 档尺寸) +assets/ 设计素材 PNG(Mdi 图标、返回箭头、QQ、封面) +dist/ 交付的 .face +docs/ 界面预览 + selfcheck_run.txt / selfcheck_report.txt +inputs/ 固件分区镜像 + 字体(不进仓库,见上) +vendor/ LuaDevTemplate-main(含 Compiler.exe,不进仓库) +.work/ 构建工作区(anim/ 改后镜像、overlay/、build/;可删) +payload/ payload_restore/ 逐条目负载 + payload.lua(人肉 dd 用) +blob_replace/ blob_restore/ 魔数 + 顺序拼接的 payload.bin / fp.bin / offsets.json +``` + +| 脚本 | 作用 | +|---|---| +| `tools/paths.py` | 所有路径的唯一入口(本项目事实:id / 480 / 464 / 119 都在这) | +| `tools/romfs.py` | ROMFS 分区解析(只读) | +| `tools/lvgl_bin.py` | LVGL v9 RLE 编解码 + `pad_rle_safe` | +| `tools/iconpack.py` | ARGB8888 / I8 图片编码(界面素材用) | +| `tools/lua_balance.py` | Lua 块配平(被 `s5e_lua_static.py` 调用) | +| `tools/s5e_anim.py` | **核心**:100 帧 → 119 个等长槽位 + 改后整分区镜像 + `manifest.json`(含三条就地自证) | +| `tools/s5e_payload.py` | 按角色切负载 + 指纹表 + `blob_*/`(含逐条 sha256) | +| `tools/s5e_overlay.py` | 生成可编译的 overlay(config / fprj / 预览图 / 资源) | +| `tools/s5e_build.py` | **两遍构建 + 裸编忠实产物(b5=0)+ 0 漂移校验** → `dist/*.face` | +| `tools/s5e_verify.py` | 逐盘校验(38 项):头 / Lua / 偏移 / 逐条负载 / 指纹 / 镜像 / UI / 体积 | +| `tools/s5e_verify_indep.py` | 独立总校验(70 项):不读构建中间产物的那一路 + 两张盘互证 | +| `tools/s5e_lua_static.py` | `main.lua` 静态体检(含 S4e 踩过的「先调用后声明」坑) | +| `tools/s5e_pages.py` | 480×480 页面规格(唯一的设计稿来源) | +| `tools/s5e_render_ui.py` | 用真 MiSans 渲染 7 页预览(会报标签溢框) | +| `tools/s5e_check_clip.py` | 用真实渲染像素判圆屏是否切边 | +| `tools/s5e_ui_images.py` | 设计素材 PNG → `ui_images/*.bin` + 圆形 logo | diff --git a/s5e-pressure-anim/assets/MdiBandage.png b/s5e-pressure-anim/assets/MdiBandage.png new file mode 100644 index 0000000..26abc5b Binary files /dev/null and b/s5e-pressure-anim/assets/MdiBandage.png differ diff --git a/s5e-pressure-anim/assets/MdiClipboardTextSearchOutline.png b/s5e-pressure-anim/assets/MdiClipboardTextSearchOutline.png new file mode 100644 index 0000000..85ea2da Binary files /dev/null and b/s5e-pressure-anim/assets/MdiClipboardTextSearchOutline.png differ diff --git a/s5e-pressure-anim/assets/MdiInformationOutline.png b/s5e-pressure-anim/assets/MdiInformationOutline.png new file mode 100644 index 0000000..3af2a29 Binary files /dev/null and b/s5e-pressure-anim/assets/MdiInformationOutline.png differ diff --git a/s5e-pressure-anim/assets/MdiLightbulbOutline.png b/s5e-pressure-anim/assets/MdiLightbulbOutline.png new file mode 100644 index 0000000..7d7819c Binary files /dev/null and b/s5e-pressure-anim/assets/MdiLightbulbOutline.png differ diff --git a/s5e-pressure-anim/assets/MdiReplyAllOutline.png b/s5e-pressure-anim/assets/MdiReplyAllOutline.png new file mode 100644 index 0000000..f419de0 Binary files /dev/null and b/s5e-pressure-anim/assets/MdiReplyAllOutline.png differ diff --git a/s5e-pressure-anim/assets/backarrow.png b/s5e-pressure-anim/assets/backarrow.png new file mode 100644 index 0000000..9e55859 Binary files /dev/null and b/s5e-pressure-anim/assets/backarrow.png differ diff --git a/s5e-pressure-anim/assets/cover.png b/s5e-pressure-anim/assets/cover.png new file mode 100644 index 0000000..d3a9a70 Binary files /dev/null and b/s5e-pressure-anim/assets/cover.png differ diff --git a/s5e-pressure-anim/assets/qq.png b/s5e-pressure-anim/assets/qq.png new file mode 100644 index 0000000..3982762 Binary files /dev/null and b/s5e-pressure-anim/assets/qq.png differ diff --git a/s5e-pressure-anim/dist/S5e-Pt2.face b/s5e-pressure-anim/dist/S5e-Pt2.face new file mode 100644 index 0000000..d07eff8 Binary files /dev/null and b/s5e-pressure-anim/dist/S5e-Pt2.face differ diff --git a/s5e-pressure-anim/dist/S5ePAnimRestore.face b/s5e-pressure-anim/dist/S5ePAnimRestore.face new file mode 100644 index 0000000..d8fb22d Binary files /dev/null and b/s5e-pressure-anim/dist/S5ePAnimRestore.face differ diff --git a/s5e-pressure-anim/docs/logo_preview_480.png b/s5e-pressure-anim/docs/logo_preview_480.png new file mode 100644 index 0000000..2143fc9 Binary files /dev/null and b/s5e-pressure-anim/docs/logo_preview_480.png differ diff --git a/s5e-pressure-anim/docs/selfcheck_report.txt b/s5e-pressure-anim/docs/selfcheck_report.txt new file mode 100644 index 0000000..1def5c8 --- /dev/null +++ b/s5e-pressure-anim/docs/selfcheck_report.txt @@ -0,0 +1,298 @@ +# S5ePAnim 交付前自证(全部只读;UTF-8) +# 生成时间 2026-10-02 22:34:54 +# python C:\Users\hi\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\python\python.exe + +============================================================================== +$ python tools/s5e_lua_static.py +============================================================================== + +ALL OK +=== tools/s5e_lua_static.py D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\main.lua (31141 B) === + +[1] 块配平 + OK openers=202 end=202(function=90 if=99 for=11 while=1 do=1)repeat=0 until=0 + +[2] 先用后声明(真机上会变成 "call a nil value") + OK 没有"先调用后 local": + OK 没有未声明的被调用名: + +[3] 模板约定(s5e_verify.py 靠这些从 .face 里反解 Lua) + OK 占位符 __UIIMG__ 出现 1 次(要 1) + OK 占位符 __PAY__ 出现 1 次(要 1) + OK 占位符 __RB__ 出现 1 次(要 1) + OK 占位符 __ROLE__ 出现 1 次(要 1) + OK 文件以 --[[-- 开头 + OK --[[ 只出现 1 次(要 1,否则 verify 会从中间截 Lua) + OK 以 return ui\n 结尾 + OK return ui\n 只出现 1 次(要 1) + OK 有 local UIIMG = { + +[4] 与 s5e_verify.py 对齐的字面量 + OK local ROLE = '__ROLE__' + OK local W, H = 480, 480 + OK DEVS 里有 health -> /dev/health:health = "/dev/health" + OK DEV 来自 DEVS.health(表里的键名真的被用于取节点,不是摆设) + OK PAYTBL 里只有 __PAY__ / __RB__ 两处占位符(没有硬编码节点名) + OK 没有残留的 466(旧 S4e 尺寸) + +=== 全部通过 === +EXIT=0 + +============================================================================== +$ python tools/s5e_verify.py replace +============================================================================== +============================================================================== +S5ePAnim(role=replace) D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnim.face +============================================================================== + +[1] .face 头 + OK magic 5a a5 34 12 + OK b4 = 0 + OK b5 = 0(固件自带表盘 b5 也是 0) + OK id = b'462150102\x00'(期望 '462150102') + OK preview 326x326(Compiler.exe 对 462 设备强制 326x326) + OK preview 数据 77538 B @0xf9d638(文件尾 0xfb051c) + 文件 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c + +[2] 从 .face 里反解加载的 Lua + OK 找到 Lua 结尾 "return ui\n" + OK Lua 提取成功(37900 B) + OK Lua 原样出现 1 次(偏移 0xf94220) + OK Lua 里的 ROLE == 'replace' + OK 占位符已全部回填 + OK 界面尺寸 W,H = 480,480(480x480 圆屏) + OK DEVS 里有 health -> /dev/health:health = "/dev/health" + +[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表) + OK payload 魔数(15 B)在 .face 里唯一 + OK fp 魔数(14 B)在 .face 里唯一 + OK payload.bin 原样连续存放在 0xb16a + OK fp.bin 原样连续存放在 0x205 + OK 负载记录 119 条(期望 119) + OK payload.bin 16238747 B 在 .face 里逐字节连续 + OK fp.bin 7630 B 在 .face 里逐字节连续 + OK Lua 表 pay 119 条 / rb 119 条 + OK Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移 + OK 原厂 pressure/measure/ 下 119 个槽位 + OK 原厂 usize 唯一且 == 1024 + 464*464 + 1 = 216321 + +[4] 逐条负载 + OK 每条负载长度 == 原槽位长度(119 条) + OK 每条负载字节 == 改后镜像对应位置 + OK 每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464 + +[5] 版本校验指纹 + OK 119 条指纹都是 64 B 且 == 原厂镜像前 64 B + OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度) + +[6] 改后镜像的完整性 + OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变 + OK superblock 一致(size 206924736 / cksum b804503a) + OK 卷名 health 不变 + OK 分区长度不变(206924800 B) + OK 改动只落在负载槽位区间内(首差 0x2c18618,末差 0x3b9d384,槽位区间 0x2c18600..0x3b9d385) + OK 119 个槽位全部都真的写入了新字节(未变的槽位:无) + +[7] UI 图片 + OK UIIMG 条目 8 条(期望 8) + OK 8 张界面素材都与 ui_images/*.bin 逐字节一致 + +[8] 体积 + OK 交付物 15.69 MB < 20 MB + 两张盘同 id:装一份就顶掉另一份,设备上只占一份空间 + +============================================================================== +S5ePAnim:38 项通过,0 项失败 +全部通过 —— S5ePAnim.face(16450844 B,sha256 d0bb012a364fd46e) +EXIT=0 + +============================================================================== +$ python tools/s5e_verify.py restore +============================================================================== +============================================================================== +S5ePAnimRestore(role=restore) D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnimRestore.face +============================================================================== + +[1] .face 头 + OK magic 5a a5 34 12 + OK b4 = 0 + OK b5 = 0(固件自带表盘 b5 也是 0) + OK id = b'462150102\x00'(期望 '462150102') + OK preview 326x326(Compiler.exe 对 462 设备强制 326x326) + OK preview 数据 77538 B @0xf9d638(文件尾 0xfb051c) + 文件 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69 + +[2] 从 .face 里反解加载的 Lua + OK 找到 Lua 结尾 "return ui\n" + OK Lua 提取成功(37900 B) + OK Lua 原样出现 1 次(偏移 0xf94220) + OK Lua 里的 ROLE == 'restore' + OK 占位符已全部回填 + OK 界面尺寸 W,H = 480,480(480x480 圆屏) + OK DEVS 里有 health -> /dev/health:health = "/dev/health" + +[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表) + OK payload 魔数(15 B)在 .face 里唯一 + OK fp 魔数(14 B)在 .face 里唯一 + OK payload.bin 原样连续存放在 0xb16a + OK fp.bin 原样连续存放在 0x205 + OK 负载记录 119 条(期望 119) + OK payload.bin 16238747 B 在 .face 里逐字节连续 + OK fp.bin 7630 B 在 .face 里逐字节连续 + OK Lua 表 pay 119 条 / rb 119 条 + OK Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移 + OK 原厂 pressure/measure/ 下 119 个槽位 + OK 原厂 usize 唯一且 == 1024 + 464*464 + 1 = 216321 + +[4] 逐条负载 + OK 每条负载长度 == 原槽位长度(119 条) + OK 每条负载字节 == 原厂镜像对应位置 + OK 每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464 + +[5] 版本校验指纹 + OK 119 条指纹都是 64 B 且 == 改后镜像前 64 B + OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度) + +[6] 改后镜像的完整性 + OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变 + OK superblock 一致(size 206924736 / cksum b804503a) + OK 卷名 health 不变 + OK 分区长度不变(206924800 B) + OK 改动只落在负载槽位区间内(首差 0x2c18618,末差 0x3b9d384,槽位区间 0x2c18600..0x3b9d385) + OK 119 个槽位全部都真的写入了新字节(未变的槽位:无) + +[7] UI 图片 + OK UIIMG 条目 8 条(期望 8) + OK 8 张界面素材都与 ui_images/*.bin 逐字节一致 + +[8] 体积 + OK 交付物 15.69 MB < 20 MB + 两张盘同 id:装一份就顶掉另一份,设备上只占一份空间 + +============================================================================== +S5ePAnimRestore:38 项通过,0 项失败 +全部通过 —— S5ePAnimRestore.face(16450844 B,sha256 13cafae4bf2b03db) +EXIT=0 + +============================================================================== +$ python tools/s5e_verify_indep.py +============================================================================== +============================================================================== +独立总校验(不读 blob_*/offsets.json、不读 .work/overlay、不读 .work/build) + 交付物 : dist/S5ePAnim.face + dist/S5ePAnimRestore.face + 固件 : inputs/target/vela_health.bin 206924800 B(卷名 health) + 改后 : .work/anim/vela_health_anim.bin 206924800 B +============================================================================== + OK 固件里 pressure/measure/ 下 119 个槽位(期望 119) + OK 槽位名字就是 Measuring1..119.bin + +------------------------------------------------------------------------------ +[replace] S5ePAnim.face 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c +------------------------------------------------------------------------------ + OK magic + OK b5 = 0(固件自带表盘是 0) + OK b4 = 0 + OK id = b'462150102\x00' + OK preview 326x326 + OK Lua 在文件里只出现 1 次(偏移 0xf94220) + OK Lua 里的 ROLE == 'replace' + OK 占位符 __UIIMG__ 已回填 + OK 占位符 __PAY__ 已回填 + OK 占位符 __RB__ 已回填 + OK 占位符 __ROLE__ 已回填 + OK 界面尺寸 W,H = 480,480 + OK DEVS 有 health -> /dev/health + OK UIIMG 8 条(期望 8) + OK pay 119 条 / rb 119 条(期望 119/119) + OK 两个魔数各命中 1 次(payload 0xb16a / fp 0x205) + OK 两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续) + OK rb 每条都是 64 B + OK 第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符) + OK 每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(0 处不符) + OK 119 个目标偏移全部 512 对齐 + (97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1) + OK 每条负载字节 == 改后镜像对应位置(0 处不符) + OK 每条负载都真的与"另一侧"不同(0 条与另一侧相同 —— 应当是 0) + OK 每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符) + OK 每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符) + OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同 + OK rb 的 target 也指向对应槽位 + OK 8 张界面素材与 ui_images/*.bin 逐字节一致(0 处不符) + +------------------------------------------------------------------------------ +[restore] S5ePAnimRestore.face 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69 +------------------------------------------------------------------------------ + OK magic + OK b5 = 0(固件自带表盘是 0) + OK b4 = 0 + OK id = b'462150102\x00' + OK preview 326x326 + OK Lua 在文件里只出现 1 次(偏移 0xf94220) + OK Lua 里的 ROLE == 'restore' + OK 占位符 __UIIMG__ 已回填 + OK 占位符 __PAY__ 已回填 + OK 占位符 __RB__ 已回填 + OK 占位符 __ROLE__ 已回填 + OK 界面尺寸 W,H = 480,480 + OK DEVS 有 health -> /dev/health + OK UIIMG 8 条(期望 8) + OK pay 119 条 / rb 119 条(期望 119/119) + OK 两个魔数各命中 1 次(payload 0xb16a / fp 0x205) + OK 两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续) + OK rb 每条都是 64 B + OK 第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符) + OK 每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(0 处不符) + OK 119 个目标偏移全部 512 对齐 + (97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1) + OK 每条负载字节 == 原厂镜像对应位置(0 处不符) + OK 每条负载都真的与"另一侧"不同(0 条与另一侧相同 —— 应当是 0) + OK 每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符) + OK 每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符) + OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同 + OK rb 的 target 也指向对应槽位 + OK 8 张界面素材与 ui_images/*.bin 逐字节一致(0 处不符) + +============================================================================== +[互证] 两张交付物互相印证(完全不碰 .work / 固件镜像) +============================================================================== + OK 两张盘字节数相同(16450844) + OK 头 50 B(magic / b4 / b5 / id)逐字节相同 + OK replace 的负载前 64 B == restore 的指纹;restore 的负载前 64 B == replace 的指纹(0 处不符) + OK 两张盘的 8 张界面素材逐字节相同(同一套界面) + OK 两张盘的资源基址相同(0xb16a / 0x205) + OK 119/119 条负载在两张盘里都不一样(119) + +============================================================================== +[镜像] 改后镜像 vs 原厂镜像 +============================================================================== + OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变 + OK superblock 与卷名不变(size 206924736 / cksum b804503a / 'health') + OK 分区长度不变(206924800 B) + OK 除 119 个槽位外,其余 5275 个文件的字节**逐字节**不变(0 处不符) + OK 变化区间 0x2c18618..0x3b9d384 完全落在槽位区间 0x2c18600..0x3b9d385 内 + OK 119 个槽位全部被改写(未变的:无) + +============================================================================== +独立总校验:70 项通过,0 项失败 +============================================================================== +EXIT=0 + +============================================================================== +$ python tools/s5e_check_clip.py +============================================================================== +page max r outside px worst point +-------------------------------------------------------------- +home 230.7 0 (275, 12) +help 230.7 0 (275, 12) +help_ok 230.7 0 (275, 12) +help_err 230.7 0 (275, 12) +confirm 238.4 0 (133, 453) +running 238.4 0 (133, 453) +about 232.3 0 (61, 92) +-------------------------------------------------------------- +圆心 (240,240) 半径 240 画布 480x480 越界页数:0 +EXIT=0 + +============================================================================== +ALL_STEPS_OK=True(5 步,0 步失败) +============================================================================== diff --git a/s5e-pressure-anim/docs/selfcheck_run.txt b/s5e-pressure-anim/docs/selfcheck_run.txt new file mode 100644 index 0000000..9910a48 --- /dev/null +++ b/s5e-pressure-anim/docs/selfcheck_run.txt @@ -0,0 +1,347 @@ +਍# S5ePAnim 重建 + 自证 原始输出(2026-10-02 22:24:49) +# python: C:\Users\hi\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\python\python.exe + +=== python tools\s5e_lua_static.py === + +ALL OK +=== tools/s5e_lua_static.py D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\main.lua (31141 B) === + +[1] 块配平 + OK openers=202 end=202(function=90 if=99 for=11 while=1 do=1)repeat=0 until=0 + +[2] 先用后声明(真机上会变成 "call a nil value") + OK 没有"先调用后 local": + OK 没有未声明的被调用名: + +[3] 模板约定(s5e_verify.py 靠这些从 .face 里反解 Lua) + OK 占位符 __UIIMG__ 出现 1 次(要 1) + OK 占位符 __PAY__ 出现 1 次(要 1) + OK 占位符 __RB__ 出现 1 次(要 1) + OK 占位符 __ROLE__ 出现 1 次(要 1) + OK 文件以 --[[-- 开头 + OK --[[ 只出现 1 次(要 1,否则 verify 会从中间截 Lua) + OK 以 return ui\n 结尾 + OK return ui\n 只出现 1 次(要 1) + OK 有 local UIIMG = { + +[4] 与 s5e_verify.py 对齐的字面量 + OK local ROLE = '__ROLE__' + OK local W, H = 480, 480 + OK DEVS 里有 health -> /dev/health:health = "/dev/health" + OK DEV 来自 DEVS.health(表里的键名真的被用于取节点,不是摆设) + OK PAYTBL 里只有 __PAY__ / __RB__ 两处占位符(没有硬编码节点名) + OK 没有残留的 466(旧 S4e 尺寸) + +=== 全部通过 === +EXIT=0 + +=== python tools\s5e_payload.py replace === +blob_replace/ 单条目: payload.bin 16238747 B + fp.bin 7630 B(119 条记录,跳过 0 条未变) +role=replace: emitted 119 records, 15.49 MB; dropped 0 +EXIT=0 + +=== python tools\s5e_overlay.py replace === +overlay written to D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\overlay\S5ePAnim +EXIT=0 + +=== python tools\s5e_build.py replace === +=== S5ePAnim(role=replace,id=462150102)=== +第 1 遍:用占位符编一次,量出资源在 .face 里的位置 + | ------------------------------------------------------------------------------- + | Patched face ID: 462150102 + | Done: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnim\bin\S5ePAnim.face + | Generated: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnim\watchface\data\resource.bin + face 16444088 bytes + back off=0xf87a2e len=2100 + ic_help off=0xf8a105 len=3856 + ic_replace off=0xf8bf7c len=3856 + ic_about off=0xf8828e len=3856 + ic_check off=0xf891ca len=3856 + ic_rollback off=0xf8ceb9 len=3856 + ic_qq off=0xf8b03f len=3856 + logo off=0xf8ddf2 len=25612 + payload base=0x00b16a(16238747 B) / fp base=0x000205(7630 B),共 119 条记录 +第 2 遍:回填后的 Lua(32814 bytes)重新编译 + | ------------------------------------------------------------------------------- + | Patched face ID: 462150102 + | Done: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnim\bin\S5ePAnim.face + | Generated: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnim\watchface\data\resource.bin +偏移校验:全部吻合,0 漂移 +忠实产物 vs toolchain:8 字节差异(只允许 b5 + id 这 8 个位置:[5, 40, 42, 43, 44, 46, 47, 48]) +b4=0 b5=0 id=b'462150102\x00' +preview @0xf9d62c: 326x326 palette=1024 data=77538 +交付物:D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnim.face 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c +EXIT=0 + +=== python tools\s5e_verify.py replace === +============================================================================== +S5ePAnim(role=replace) D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnim.face +============================================================================== + +[1] .face 头 + OK magic 5a a5 34 12 + OK b4 = 0 + OK b5 = 0(固件自带表盘 b5 也是 0) + OK id = b'462150102\x00'(期望 '462150102') + OK preview 326x326(Compiler.exe 对 462 设备强制 326x326) + OK preview 数据 77538 B @0xf9d638(文件尾 0xfb051c) + 文件 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c + +[2] 从 .face 里反解加载的 Lua + OK 找到 Lua 结尾 "return ui\n" + OK Lua 提取成功(37900 B) + OK Lua 原样出现 1 次(偏移 0xf94220) + OK Lua 里的 ROLE == 'replace' + OK 占位符已全部回填 + OK 界面尺寸 W,H = 480,480(480x480 圆屏) + OK DEVS 里有 health -> /dev/health:health = "/dev/health" + +[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表) + OK payload 魔数(15 B)在 .face 里唯一 + OK fp 魔数(14 B)在 .face 里唯一 + OK payload.bin 原样连续存放在 0xb16a + OK fp.bin 原样连续存放在 0x205 + OK 负载记录 119 条(期望 119) + OK payload.bin 16238747 B 在 .face 里逐字节连续 + OK fp.bin 7630 B 在 .face 里逐字节连续 + OK Lua 表 pay 119 条 / rb 119 条 + OK Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移 + OK 原厂 pressure/measure/ 下 119 个槽位 + OK 原厂 usize 唯一且 == 1024 + 464*464 + 1 = 216321 + +[4] 逐条负载 + OK 每条负载长度 == 原槽位长度(119 条) + OK 每条负载字节 == 改后镜像对应位置 + OK 每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464 + +[5] 版本校验指纹 + OK 119 条指纹都是 64 B 且 == 原厂镜像前 64 B + OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度) + +[6] 改后镜像的完整性 + OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变 + OK superblock 一致(size 206924736 / cksum b804503a) + OK 卷名 health 不变 + OK 分区长度不变(206924800 B) + OK 改动只落在负载槽位区间内(首差 0x2c18618,末差 0x3b9d384,槽位区间 0x2c18600..0x3b9d385) + OK 119 个槽位全部都真的写入了新字节(未变的槽位:无) + +[7] UI 图片 + OK UIIMG 条目 8 条(期望 8) + OK 8 张界面素材都与 ui_images/*.bin 逐字节一致 + +[8] 体积 + OK 交付物 15.69 MB < 20 MB + 两张盘同 id:装一份就顶掉另一份,设备上只占一份空间 + +============================================================================== +S5ePAnim:38 项通过,0 项失败 +全部通过 —— S5ePAnim.face(16450844 B,sha256 d0bb012a364fd46e) +EXIT=0 + +=== python tools\s5e_payload.py restore === +blob_restore/ 单条目: payload.bin 16238747 B + fp.bin 7630 B(119 条记录,跳过 0 条未变) +role=restore: emitted 119 records, 15.49 MB; dropped 0 +EXIT=0 + +=== python tools\s5e_overlay.py restore === +overlay written to D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\overlay\S5ePAnimRestore +EXIT=0 + +=== python tools\s5e_build.py restore === +=== S5ePAnimRestore(role=restore,id=462150102)=== +第 1 遍:用占位符编一次,量出资源在 .face 里的位置 + | ------------------------------------------------------------------------------- + | Patched face ID: 462150102 + | Done: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnimRestore\bin\S5ePAnimRestore.face + | Generated: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnimRestore\watchface\data\resource.bin + face 16444088 bytes + back off=0xf87a2e len=2100 + ic_help off=0xf8a105 len=3856 + ic_replace off=0xf8bf7c len=3856 + ic_about off=0xf8828e len=3856 + ic_check off=0xf891ca len=3856 + ic_rollback off=0xf8ceb9 len=3856 + ic_qq off=0xf8b03f len=3856 + logo off=0xf8ddf2 len=25612 + payload base=0x00b16a(16238747 B) / fp base=0x000205(7630 B),共 119 条记录 +第 2 遍:回填后的 Lua(32814 bytes)重新编译 + | ------------------------------------------------------------------------------- + | Patched face ID: 462150102 + | Done: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnimRestore\bin\S5ePAnimRestore.face + | Generated: D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\.work\build\S5ePAnimRestore\watchface\data\resource.bin +偏移校验:全部吻合,0 漂移 +忠实产物 vs toolchain:8 字节差异(只允许 b5 + id 这 8 个位置:[5, 40, 42, 43, 44, 46, 47, 48]) +b4=0 b5=0 id=b'462150102\x00' +preview @0xf9d62c: 326x326 palette=1024 data=77538 +交付物:D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnimRestore.face 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69 +EXIT=0 + +=== python tools\s5e_verify.py restore === +============================================================================== +S5ePAnimRestore(role=restore) D:\UserData\Documents\GitHub\o62lte-customising-plan\s5e-pressure-anim\dist\S5ePAnimRestore.face +============================================================================== + +[1] .face 头 + OK magic 5a a5 34 12 + OK b4 = 0 + OK b5 = 0(固件自带表盘 b5 也是 0) + OK id = b'462150102\x00'(期望 '462150102') + OK preview 326x326(Compiler.exe 对 462 设备强制 326x326) + OK preview 数据 77538 B @0xf9d638(文件尾 0xfb051c) + 文件 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69 + +[2] 从 .face 里反解加载的 Lua + OK 找到 Lua 结尾 "return ui\n" + OK Lua 提取成功(37900 B) + OK Lua 原样出现 1 次(偏移 0xf94220) + OK Lua 里的 ROLE == 'restore' + OK 占位符已全部回填 + OK 界面尺寸 W,H = 480,480(480x480 圆屏) + OK DEVS 里有 health -> /dev/health:health = "/dev/health" + +[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表) + OK payload 魔数(15 B)在 .face 里唯一 + OK fp 魔数(14 B)在 .face 里唯一 + OK payload.bin 原样连续存放在 0xb16a + OK fp.bin 原样连续存放在 0x205 + OK 负载记录 119 条(期望 119) + OK payload.bin 16238747 B 在 .face 里逐字节连续 + OK fp.bin 7630 B 在 .face 里逐字节连续 + OK Lua 表 pay 119 条 / rb 119 条 + OK Lua 表里的 119 条偏移 == 文件基址 + 魔数长度 + 相对偏移 + OK 原厂 pressure/measure/ 下 119 个槽位 + OK 原厂 usize 唯一且 == 1024 + 464*464 + 1 = 216321 + +[4] 逐条负载 + OK 每条负载长度 == 原槽位长度(119 条) + OK 每条负载字节 == 原厂镜像对应位置 + OK 每条负载都能按 LVGL RLE 解出正好 usize 字节、解成 464x464 + +[5] 版本校验指纹 + OK 119 条指纹都是 64 B 且 == 改后镜像前 64 B + OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度) + +[6] 改后镜像的完整性 + OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变 + OK superblock 一致(size 206924736 / cksum b804503a) + OK 卷名 health 不变 + OK 分区长度不变(206924800 B) + OK 改动只落在负载槽位区间内(首差 0x2c18618,末差 0x3b9d384,槽位区间 0x2c18600..0x3b9d385) + OK 119 个槽位全部都真的写入了新字节(未变的槽位:无) + +[7] UI 图片 + OK UIIMG 条目 8 条(期望 8) + OK 8 张界面素材都与 ui_images/*.bin 逐字节一致 + +[8] 体积 + OK 交付物 15.69 MB < 20 MB + 两张盘同 id:装一份就顶掉另一份,设备上只占一份空间 + +============================================================================== +S5ePAnimRestore:38 项通过,0 项失败 +全部通过 —— S5ePAnimRestore.face(16450844 B,sha256 13cafae4bf2b03db) +EXIT=0 + +=== python tools\s5e_verify_indep.py === +============================================================================== +独立总校验(不读 blob_*/offsets.json、不读 .work/overlay、不读 .work/build) + 交付物 : dist/S5ePAnim.face + dist/S5ePAnimRestore.face + 固件 : inputs/target/vela_health.bin 206924800 B(卷名 health) + 改后 : .work/anim/vela_health_anim.bin 206924800 B +============================================================================== + OK 固件里 pressure/measure/ 下 119 个槽位(期望 119) + OK 槽位名字就是 Measuring1..119.bin + +------------------------------------------------------------------------------ +[replace] S5ePAnim.face 16450844 B sha256 d0bb012a364fd46eb4bb27db26ead72ae3166060c72c08ec5a76670dc06a3b1c +------------------------------------------------------------------------------ + OK magic + OK b5 = 0(固件自带表盘是 0) + OK b4 = 0 + OK id = b'462150102\x00' + OK preview 326x326 + OK Lua 在文件里只出现 1 次(偏移 0xf94220) + OK Lua 里的 ROLE == 'replace' + OK 占位符 __UIIMG__ 已回填 + OK 占位符 __PAY__ 已回填 + OK 占位符 __RB__ 已回填 + OK 占位符 __ROLE__ 已回填 + OK 界面尺寸 W,H = 480,480 + OK DEVS 有 health -> /dev/health + OK UIIMG 8 条(期望 8) + OK pay 119 条 / rb 119 条(期望 119/119) + OK 两个魔数各命中 1 次(payload 0xb16a / fp 0x205) + OK 两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续) + OK rb 每条都是 64 B + OK 第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符) + OK 每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(0 处不符) + OK 119 个目标偏移全部 512 对齐 + (97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1) + OK 每条负载字节 == 改后镜像对应位置(0 处不符) + OK 每条负载都真的与"另一侧"不同(0 条与另一侧相同 —— 应当是 0) + OK 每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符) + OK 每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符) + OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同 + OK rb 的 target 也指向对应槽位 + OK 8 张界面素材与 ui_images/*.bin 逐字节一致(0 处不符) + +------------------------------------------------------------------------------ +[restore] S5ePAnimRestore.face 16450844 B sha256 13cafae4bf2b03db82f2c17f9d3af7c7e8c01d0c26275feda301753a76ebda69 +------------------------------------------------------------------------------ + OK magic + OK b5 = 0(固件自带表盘是 0) + OK b4 = 0 + OK id = b'462150102\x00' + OK preview 326x326 + OK Lua 在文件里只出现 1 次(偏移 0xf94220) + OK Lua 里的 ROLE == 'restore' + OK 占位符 __UIIMG__ 已回填 + OK 占位符 __PAY__ 已回填 + OK 占位符 __RB__ 已回填 + OK 占位符 __ROLE__ 已回填 + OK 界面尺寸 W,H = 480,480 + OK DEVS 有 health -> /dev/health + OK UIIMG 8 条(期望 8) + OK pay 119 条 / rb 119 条(期望 119/119) + OK 两个魔数各命中 1 次(payload 0xb16a / fp 0x205) + OK 两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(0 处不连续) + OK rb 每条都是 64 B + OK 第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(0 处不符) + OK 每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(0 处不符) + OK 119 个目标偏移全部 512 对齐 + (97/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,否则退化成 bs=1) + OK 每条负载字节 == 原厂镜像对应位置(0 处不符) + OK 每条负载都真的与"另一侧"不同(0 条与另一侧相同 —— 应当是 0) + OK 每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(0 条不符) + OK 每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴(0 条不符) + OK 119/119 条的"原厂前 64 B"与"改后前 64 B"确实不同 + OK rb 的 target 也指向对应槽位 + OK 8 张界面素材与 ui_images/*.bin 逐字节一致(0 处不符) + +============================================================================== +[互证] 两张交付物互相印证(完全不碰 .work / 固件镜像) +============================================================================== + OK 两张盘字节数相同(16450844) + OK 头 50 B(magic / b4 / b5 / id)逐字节相同 + OK replace 的负载前 64 B == restore 的指纹;restore 的负载前 64 B == replace 的指纹(0 处不符) + OK 两张盘的 8 张界面素材逐字节相同(同一套界面) + OK 两张盘的资源基址相同(0xb16a / 0x205) + OK 119/119 条负载在两张盘里都不一样(119) + +============================================================================== +[镜像] 改后镜像 vs 原厂镜像 +============================================================================== + OK inode 表(路径/偏移/next/spec/size/cksum)5555 条逐位不变 + OK superblock 与卷名不变(size 206924736 / cksum b804503a / 'health') + OK 分区长度不变(206924800 B) + OK 除 119 个槽位外,其余 5275 个文件的字节**逐字节**不变(0 处不符) + OK 变化区间 0x2c18618..0x3b9d384 完全落在槽位区间 0x2c18600..0x3b9d385 内 + OK 119 个槽位全部被改写(未变的:无) + +============================================================================== +独立总校验:70 项通过,0 项失败 +============================================================================== +EXIT=0 + +ALL_STEPS_OK=True diff --git a/s5e-pressure-anim/docs/ui_about.png b/s5e-pressure-anim/docs/ui_about.png new file mode 100644 index 0000000..702c5ae Binary files /dev/null and b/s5e-pressure-anim/docs/ui_about.png differ diff --git a/s5e-pressure-anim/docs/ui_all.png b/s5e-pressure-anim/docs/ui_all.png new file mode 100644 index 0000000..e3990bd Binary files /dev/null and b/s5e-pressure-anim/docs/ui_all.png differ diff --git a/s5e-pressure-anim/docs/ui_confirm.png b/s5e-pressure-anim/docs/ui_confirm.png new file mode 100644 index 0000000..a06b1c9 Binary files /dev/null and b/s5e-pressure-anim/docs/ui_confirm.png differ diff --git a/s5e-pressure-anim/docs/ui_help.png b/s5e-pressure-anim/docs/ui_help.png new file mode 100644 index 0000000..f79741f Binary files /dev/null and b/s5e-pressure-anim/docs/ui_help.png differ diff --git a/s5e-pressure-anim/docs/ui_help_err.png b/s5e-pressure-anim/docs/ui_help_err.png new file mode 100644 index 0000000..3639816 Binary files /dev/null and b/s5e-pressure-anim/docs/ui_help_err.png differ diff --git a/s5e-pressure-anim/docs/ui_help_ok.png b/s5e-pressure-anim/docs/ui_help_ok.png new file mode 100644 index 0000000..22fe935 Binary files /dev/null and b/s5e-pressure-anim/docs/ui_help_ok.png differ diff --git a/s5e-pressure-anim/docs/ui_home.png b/s5e-pressure-anim/docs/ui_home.png new file mode 100644 index 0000000..2a1e92e Binary files /dev/null and b/s5e-pressure-anim/docs/ui_home.png differ diff --git a/s5e-pressure-anim/docs/ui_running.png b/s5e-pressure-anim/docs/ui_running.png new file mode 100644 index 0000000..5a694e6 Binary files /dev/null and b/s5e-pressure-anim/docs/ui_running.png differ diff --git a/s5e-pressure-anim/main.lua b/s5e-pressure-anim/main.lua new file mode 100644 index 0000000..a018288 --- /dev/null +++ b/s5e-pressure-anim/main.lua @@ -0,0 +1,817 @@ +--[[---------------------------------------------------------------------------- + S5ePAnim —— S5 41mm(q63) 压力检测动画 -> Xiaomi Watch S5 eSIM 46mm(p62lte) + + 目标固件:miwear.watch.p62lte_v3.112.035_full(480x480,Lua 5.4 宿主) + 写入目标:ROMFS 分区 /dev/health(卷名 health,sb_size 206,924,736 B) + pressure/measure/Measuring1..119.bin + 119 个槽位全是 464x464 I8 + RLE(usize = 1024 + 464*464 + 1 = 216,321)。 + 负载**逐条等长**:每条写进去的字节数恰好等于原槽位长度(4485 ~ 196538 B), + 所以 ROMFS 的 inode 表 / 目录项 / 后面所有文件的绝对偏移一个字节都不动。 + + 源:Xiaomi Watch S5 41mm(q63, v4.101.020)pressure/measure 的 100 帧 480x480。 + 映射:第 k 个槽位(k=1..119) <- q63 第 round((k-1)/(119-1)*(100-1))+1 帧。 + + 界面按 480x480 设计(tools/s5e_pages.py,本机真 MiSans 渲染 + 真实像素判圆屏切边: + 0 越界、0 折行)。坐标与设计稿一一对应,预览图不是另画一套。 + + 两份盘共用同一个 watchface id 462150102(462 = S5 家族): + S5ePAnim.face 只装"替换后"的 119 条 + 原厂前 64 B 指纹 + S5ePAnimRestore.face 只装"原厂"的 119 条 + 替换后前 64 B 指纹 + 装其中一份就顶掉另一份,设备上始终只占一份空间。 + + ★ 分区键名 vs 设备节点(S4e 那版踩过的坑,别改回去) + DEVS 的**键**是 'health',值是设备节点 '/dev/health';运行期才取绝对路径。 + 负载表里存的是**分区内偏移**(整数),没有节点名字段。 + + 真机已验证的底层能力(沿用 S5->S4 那个表盘): + * 宿主不调用 ui.init -> 加载期建界面 + * SCRIPT_PATH 为空 -> 用 debug.getinfo 反推目录 + * .face 不解包,资源仍在 resource.bin 里 -> 按偏移读出来用 + * os.execute / dd 可用;os.execute 的返回值不可靠 -> 一律用内容比对判成败 + 真机**未**验证的:S5 eSIM(p62lte) 的 /dev/health 可写(同源平台 S4 eSIM 上的 dd 流程 + 实测通过)。所以写入前一律先只读预检查、再逐条版本校验,版本对不上就跳过分区剩余记录。 +------------------------------------------------------------------------------]] + +local lvgl = require("lvgl") + +local W, H = 480, 480 +local BLK = 4 + +--------------------------------------------------------------------- 设备与临时文件 +local DEVS = { health = "/dev/health" } -- 键名 -> 设备节点(Lua 表里只存键名) +local DEV = DEVS.health +local VOLUME = "health" -- ROMFS superblock 里的卷名,预检查要逐字节对上 +local TMPR = "/tmp/s5p_back.bin" -- 回读出来的那一份 +local TMPB = "/tmp/s5p_blob.bin" -- 待写入的负载(resource.bin 里抠出来的那一份) + +-- 等长覆盖只保证「偏移」512 对齐;RLE 素材的长度常常不是 4 的倍数(如 4485 / 196538), +-- 那种记录必须退化成 bs=1(偏移本身对齐,所以 bs=1 也精确),否则会多写相邻文件的字节。 +local function ddBlk(off, len) + if off % BLK == 0 and len % BLK == 0 then return BLK end + return 1 +end + +--------------------------------------------------------------------- 自身目录 +local SRC = "" +if debug and debug.getinfo then + local info = debug.getinfo(1, "S") + SRC = (info and info.source) or "" +end +if type(SRC) ~= "string" then SRC = "" end +if SRC:sub(1, 1) == "@" then SRC = SRC:sub(2) end +SRC = SRC:gsub("\\", "/") +local THIS_DIR = SRC:match("^(.*)/[^/]+$") or "" +local SP = THIS_DIR ~= "" and (THIS_DIR .. "/") or "" +local PARENT = SP:gsub("[^/]+/$", "") + +local RES = nil +do + local c = { PARENT .. "resource.bin", SP .. "../resource.bin", SP .. "resource.bin" } + for i = 1, #c do + local f = io.open(c[i], "rb") + if f then f:close(); RES = c[i]; break end + end +end + +--------------------------------------------------------------------- 资源偏移 +-- UI 图片 { resource.bin 内偏移, 长度, 名字 } —— 由 tools/s5e_build.py 回填 +local UIIMG = { +__UIIMG__ +} + +-- 角色:'replace' = 只负责替换(写 q63 的帧);'restore' = 只负责还原(写回原厂字节) +-- 两份盘共用同一个 watchface id,所以设备上只会占一份空间(见 README) +local ROLE = '__ROLE__' + +-- 负载表 { resource.bin 内偏移, /dev/health 内偏移, 长度 } —— 由脚本回填 +-- pay = 本盘要写进去的字节 +-- rb = **另一侧字节前 64 B 的指纹**(只给写入前的版本校验用) +-- replace 盘的 rb 是原厂前 64 B;restore 盘的 rb 是替换后前 64 B +local PAYTBL = { +__PAY__ +__RB__ +} + +local function prepImages() + local paths = {} + if not RES then return paths end + local f = io.open(RES, "rb") + if not f then return paths end + for i = 1, #UIIMG do + local e = UIIMG[i] + f:seek("set", e[1]) + local d = f:read(e[2]) + if d and #d == e[2] then + -- 直接写到 /tmp 根下:不能写子目录(目录不存在,且这台机器的 Lua 没有 mkdir) + local p = "/tmp/s5p_ui_" .. e[3] .. ".bin" + local o = io.open(p, "wb") + if o then o:write(d); o:close(); paths[e[3]] = p end + end + end + f:close() + return paths +end + +local IMG = prepImages() + +--------------------------------------------------------------------- 颜色与字体 +local C_GRAY, C_WHITE = '#999999', '#ffffff' +local C_OK, C_ERR = '#43cf7c', '#d43030' +local C_PLATE, C_CIRC = 0x002927, 0x4f4f4f + +local function getFont(name, size) + local f = nil + pcall(function() f = lvgl.Font(name, size) end) + return f +end + +local F_CLOCK = getFont('MiSans-Semibold', 29) +local F_TITLE = getFont('MiSans-Medium', 31) +local F_ITEM = getFont('MiSans-Medium', 29) +local F_BODY = getFont('MiSans-Medium', 25) +local F_SMALL = getFont('MiSans-Medium', 24) -- 说明文字 / 日志(一行 348px 以内) +local F_VER = getFont('MiSans-Medium', 21) + +--------------------------------------------------------------------- 按下高亮 +-- onPressed / onClicked 是真实的 LVGL 对象方法(就在 onevent 旁边,属性表里可查) +local HL = 0x005149 -- 按下高亮色(比底板 0x002927 明显亮) +local pressed = nil -- { obj = , t = } +local tickCount = 0 + +local function setBg(obj, c) + pcall(function() obj:set { bg_color = c } end) +end + +local function pressRelease() + if pressed then + setBg(pressed.obj, C_PLATE) + pressed = nil + end +end + +--------------------------------------------------------------------- 基础 +local ui = {} +local built = false +local pages = {} +local clocks = {} +local job = { run = false, list = nil, i = 0, ok = 0, errs = 0, vers = 0, + skipRest = false } +-- 从哪个入口进确认页:首页 -> 'replace'(本盘的 pay 表就是该角色的字节); +-- 帮助页「回滚修改」只提示装另一份盘(本盘不含另一侧的完整负载) +local pendingVariant = 'replace' +-- 确认页是从哪进来的,返回就回哪:首页 -> 'home';帮助页 -> 'help' +local confirmFrom = 'home' + +local function newPage() + local p = nil + pcall(function() + p = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000, + bg_opa = 255, border_width = 0, pad_all = 0 }) + p:clear_flag(lvgl.FLAG.SCROLLABLE) + p:add_flag(lvgl.FLAG.EVENT_BUBBLE) + end) + return p +end + +local function show(p) + if not p then return end + pressRelease() -- 切页时清掉可能残留的高亮 + for k, v in pairs(pages) do + pcall(function() v:add_flag(lvgl.FLAG.HIDDEN) end) + end + pcall(function() p:clear_flag(lvgl.FLAG.HIDDEN) end) +end + +local function bind(obj, fn) + if not obj then return end + -- 成品样本用的是 onevent(lvgl.EVENT.SHORT_CLICKED, fn)(CLICKED 这个常量 + -- 在这台机器上不存在),所以按 SHORT_CLICKED -> onClicked -> CLICKED 依次试 + local ev = lvgl.EVENT + if ev and ev.SHORT_CLICKED then + if pcall(function() obj:onevent(ev.SHORT_CLICKED, function() fn() end) end) then return end + end + if pcall(function() obj:onClicked(function() fn() end) end) then return end + pcall(function() obj:onevent(ev and ev.CLICKED, fn) end) +end + +--------------------------------------------------------------------- 点按 +-- 点按 = 按下高亮 + 抬起恢复 + 执行动作;带超时兜底(手指滑出时 click 不触发) +local function bindTap(obj, onTap) + if not obj then return end + local ok = pcall(function() + obj:onPressed(function() + setBg(obj, HL) + pressed = { obj = obj, t = tickCount } + end) + obj:onClicked(function() + pressRelease() + if onTap then onTap() end + end) + end) + if not ok then bind(obj, onTap) end +end + +local function setClickable(o) + if not o then return end + pcall(function() o:add_flag(lvgl.FLAG.CLICKABLE) end) +end + +local function lab(parent, o) + o.text_font = o.text_font or F_BODY + local l = nil + pcall(function() l = parent:Label(o) end) + return l +end + +-- 时钟:居中不用 text_align(它要的是 lv_text_align_t,传 ALIGN.CENTER 越界会被忽略 +-- -> 实测变成左对齐)。改为按真字体量出的宽度手工算 x:'10:24' 29px 量得 78 +local function clockLabel(parent) + local l = lab(parent, { x = 201, y = 4, w = 78, h = 46, text = '--:--', + text_color = C_GRAY, font_size = 29, text_font = F_CLOCK }) + clocks[#clocks + 1] = l + return l +end + +-- 标题:框 y=36 h=46,字号 31 行高 42 -> 从框顶画即可(LVGL 标签从框顶起排) +local function pageTitle(parent, text, x, w) + return lab(parent, { x = x, y = 36, w = w, h = 46, text = text, + text_color = C_WHITE, font_size = 31, text_font = F_TITLE }) +end + +local function backArrow(parent, x, onTap) + local im = nil + if IMG.back then + pcall(function() im = parent:Image { src = IMG.back, x = x or 167, y = 44 } end) + setClickable(im) + bind(im, onTap) + end + return im +end + +-- 列表项:底板(整块可点) + 圆形遮罩 + 图标 + 标题 +-- pad_all=0 是关键:LVGL 主题会给 Object 加内边距,不清零子元素会整体右下偏移。 +-- radius=40 也不是审美选择:y=88 那行离圆心 152px,圆屏在那里的弦半宽只有 185.8, +-- 圆角不够大时四个角会越过圆边(tools/s5e_check_clip.py 会量出来)。 +local function listItem(parent, y, iconkey, title, onTap) + local plate = nil + pcall(function() + plate = parent:Object { x = 50, y = y, w = 379, h = 91, pad_all = 0, + bg_color = C_PLATE, border_width = 0, radius = 40 } + end) + if plate then + if onTap then + setClickable(plate) + bindTap(plate, onTap) + end + pcall(function() + plate:Object { x = 21, y = 20, w = 52, h = 52, + bg_color = C_CIRC, border_width = 0, radius = 26 } + end) + if IMG[iconkey] then + pcall(function() plate:Image { src = IMG[iconkey], x = 31, y = 30 } end) + end + -- 标题 29px 行高 40,框高 42 -> 从框顶画就是垂直居中 + lab(plate, { x = 82, y = 26, w = 214, h = 42, text = title, + text_color = C_WHITE, font_size = 29, text_font = F_ITEM }) + end + return plate +end + +-- 大按钮 258x82 圆角 41(y=376):圆角把四角收进圆内(本机像素判据实测 0 越界) +local function bigButton(parent, text) + local b = nil + pcall(function() + b = parent:Object { x = 111, y = 376, w = 258, h = 82, pad_all = 0, + bg_color = C_PLATE, border_width = 0, radius = 41 } + end) + if b then setClickable(b) end + -- 按钮文字:框 (71,22,126,40) 相对底板;'确认替换'/'确认还原'/'重启手表' 29px 量得 116 + -- -> 居中 x = (258-116)/2 = 71 + lab(b, { x = 71, y = 22, w = 126, h = 40, text = text, + text_color = C_WHITE, font_size = 29, text_font = F_ITEM }) + return b +end + +local function clockTick() + local t = '--:--' + if os and type(os.date) == "function" then + local ok, s = pcall(os.date, "%H:%M") + if ok and type(s) == "string" then t = s end + end + for i = 1, #clocks do + pcall(function() clocks[i]:set { text = t } end) + end +end + +--------------------------------------------------------------------- 外壳 +local function slurp(p, n) + local f = io.open(p, "rb") + if not f then return nil end + local d = f:read(n or "*a") + f:close() + return d +end + +local function sh(cmd) + if type(os.execute) ~= "function" then return -1 end + local a, b, c = os.execute(cmd) + if type(a) == "number" then return a end + if a == true then return 0 end + if type(c) == "number" then return c end + if type(b) == "number" then return b end + return -1 +end + +-- 从 resource.bin 里取一段(负载 / 另一侧指纹) +local function slice(off, len) + if not RES then return nil end + local f = io.open(RES, "rb") + if not f then return nil end + f:seek("set", off) + local d = f:read(len) + f:close() + return d +end + +-- 偏移都是 512 对齐,RLE 素材的长度却常常不是 4/512 的倍数(4485 / 196538 …)。 +-- 老写法对这种长度只能 bs=1 -> 一条 19 万字节要 38 万次单字节读写,慢到偶发失败 +-- (S4e 真机上就是这么翻车的)。这里把它切成 ≤3 段:bs=512 的大块 + bs=4 的次块 + +-- 最后 1~3 字节 bs=1,syscall 直接降两个数量级。 +local BIG = 512 + +local function segs(off, len) + local out = {} + if off % BIG == 0 then + local head = len - (len % BIG) + if head > 0 then out[#out + 1] = { BIG, 0, head } end + local rest = len - head + local t4 = rest - (rest % 4) + if t4 > 0 then out[#out + 1] = { 4, head, t4 } end + if rest - t4 > 0 then out[#out + 1] = { 1, head + t4, rest - t4 } end + else + out[1] = { 1, 0, len } + end + return out +end + +-- 读设备上某偏移处的 len 字节(只读) +local function ddRead(off, len) + local parts = {} + for _, s in ipairs(segs(off, len)) do + local b, rel, n = s[1], s[2], s[3] + if os.remove then pcall(os.remove, TMPR) end + sh(string.format("dd if=%s of=%s bs=%d skip=%d count=%d", + DEV, TMPR, b, (off + rel) // b, n // b)) + local d = slurp(TMPR, n) + if not d or #d ~= n then return nil end + parts[#parts + 1] = d + end + return table.concat(parts) +end + +-- 每段单独一个临时文件:这样 dd 只需要 seek(输出偏移),不依赖输入侧的 skip +local function ddWrite(off, len, data) + for _, s in ipairs(segs(off, len)) do + local b, rel, n = s[1], s[2], s[3] + local o = io.open(TMPB, "wb") + if not o then return false end + o:write(data:sub(rel + 1, rel + n)) + o:close() + sh(string.format("dd if=%s of=%s bs=%d seek=%d count=%d", + TMPB, DEV, b, (off + rel) // b, n // b)) + end + return true +end + +--------------------------------------------------------------------- 预检查 +local desc1 = nil + +local function setDesc1(txt, color) + if not desc1 then return end + pcall(function() desc1:set { text = txt, text_color = color } end) +end + +-- 同一条记录,两边各自叫什么:replace 盘的 pay 是"改后"、rb 是"原厂" +local function sideName(which) + if which == 'mine' then + return (ROLE == 'restore') and '原厂' or '已替换' + end + return (ROLE == 'restore') and '已替换' or '原厂' +end + +local function doCheck() + if not RES then + setDesc1('找不到 resource.bin,无法操作。', C_ERR) + return false + end + local list = PAYTBL.pay or {} + if #list == 0 then + setDesc1('预检查失败:负载表为空。', C_ERR) + return false + end + local h = ddRead(0, 48) + if not h or #h < 48 then + setDesc1('预检查失败:设备节点读不到。', C_ERR) + return false + end + if h:sub(1, 8) ~= "-rom1fs-" then + setDesc1('预检查失败:设备节点读不到,\n或分区头不是 ROMFS。', C_ERR) + return false + end + local z = h:find("\0", 17, true) + local vol = h:sub(17, (z or 33) - 1) + if vol ~= VOLUME then + setDesc1('预检查失败:卷名是 ' .. vol .. '。', C_ERR) + return false + end + -- superblock 的 size 是**大端**(romfs 规范:__be32),所以这里必须用 ">I4"。 + -- (S4e 那版写成 " 越界判断形同虚设;这里修正。) + local size = string.unpack(">I4", h, 9) + local bad, total = 0, 0 + for i = 1, #list do + local r = list[i] + local b = ddBlk(r[2], r[3]) + if (r[2] % b) ~= 0 or (r[3] % b) ~= 0 then bad = bad + 1 end + if (r[2] + r[3]) > size then bad = bad + 1 end + total = total + r[3] + end + if bad > 0 then + setDesc1(string.format('预检查失败:%d 项越界或未对齐。', bad), C_ERR) + return false + end + -- 顺带确认 resource.bin 第一条能读出来 + local probe = slice(list[1][1], list[1][3]) + if not probe or #probe ~= list[1][3] then + setDesc1('预检查失败:resource.bin 读取异常。', C_ERR) + return false + end + -- 抽样版本校验(只读):首 / 中 / 尾三条,比对设备上真实的 64 字节与本盘的 + -- 「要写进去的字节」或「另一侧指纹」—— 能确认负载偏移就是给这一版固件算的。 + local marks = { 1, math.max(1, #list // 2), #list } + local samp, okAll = {}, true + for i = 1, #marks do + local k = marks[i] + local rec, oth = list[k], (PAYTBL.rb or {})[k] + local got = ddRead(rec[2], 64) + local mine = slice(rec[1], 64) + local other = oth and slice(oth[1], 64) or nil + if not got or #got < 64 then + samp[#samp + 1] = '×' + okAll = false + elseif mine and got == mine then + samp[#samp + 1] = sideName('mine') .. '✓' + elseif other and got == other then + samp[#samp + 1] = sideName('other') .. '✓' + else + samp[#samp + 1] = '抽样×' + okAll = false + end + end + local head = string.format('预检查通过 | %d 条 | %.2fMB', #list, total / 1048576) + setDesc1(head .. '\n' .. VOLUME .. ' 分区可读。' .. table.concat(samp, ' '), + okAll and C_OK or C_ERR) + return okAll +end + +--------------------------------------------------------------------- 替换 / 还原 +local logLabel = nil +local rebootBtn = nil +local runningTitle = nil +local logLines = {} + +local function logOut(s) + logLines[#logLines + 1] = s + -- 日志框 h=280、24px 行高 34 -> 最多 8 行;留 8 行保证最后一行完整可见 + while #logLines > 8 do table.remove(logLines, 1) end + if logLabel then + pcall(function() logLabel:set { text = table.concat(logLines, "\n") } end) + end +end + +local function showReboot() + if rebootBtn then pcall(function() rebootBtn:clear_flag(lvgl.FLAG.HIDDEN) end) end +end + +-- 一条记录三步:① 版本校验(设备上那段必须等于"本盘要写的"或"另一侧"的前 64 B) +-- ② 写入 ③ 整段回读比对 +-- 版本两边都不是 -> 'ver',并且**跳过 /dev/health 剩余记录**(换固件后最安全的行为: +-- 绝不把字节写进别人的文件里)。os.execute 的返回值在这台机器上不可靠,所以判据 +-- 一律是内容比对。 +local function writeOne(rec, want) + local data = slice(rec[1], rec[3]) + if not data or #data ~= rec[3] then return 'err' end + local n = 64 + if want then + if want[3] < n then n = want[3] end + local other = slice(want[1], n) + local cur = ddRead(rec[2], n) + if not cur or #cur ~= n or not other or #other ~= n then return 'err' end + if cur ~= data:sub(1, n) and cur ~= other then return 'ver' end + end + local ok, lastBack = false, nil + for attempt = 1, 3 do + if not ddWrite(rec[2], rec[3], data) then return 'err' end + sh("sync") + lastBack = ddRead(rec[2], rec[3]) + if lastBack and #lastBack == rec[3] and lastBack == data then ok = true break end + logOut(string.format('> 第 %d 条校验不过,重试 %d/3', job.i, attempt)) + end + if not ok then + -- 计划 B:常规「分段写」3 次不过时,改成整条一次 dd(bs=4 或 bs=1)。 + -- 有些驱动对「同一区域被拆成多次小写」比「一次大写」更敏感,换个写法常常就过了。 + local b = (rec[3] % 4 == 0) and 4 or 1 + local o = io.open(TMPB, "wb") + if o then + o:write(data) + o:close() + sh(string.format("dd if=%s of=%s bs=%d seek=%d count=%d", + TMPB, DEV, b, rec[2] // b, rec[3] // b)) + sh("sync") + local back2 = ddRead(rec[2], rec[3]) + if back2 and #back2 == rec[3] and back2 == data then + ok = true + logOut(string.format('> 第 %d 条改用整条 bs=%d 写入后校验通过', job.i, b)) + end + end + end + if not ok then + -- 诊断:把「哪一条 / 目标偏移 / 长度 / 读回多少字节 / 第一个不同的字节」打出来 + local why = '读回 nil' + if lastBack then + if #lastBack ~= rec[3] then + why = string.format('读回 %d 字节', #lastBack) + else + why = '内容全同?' + for i = 1, #lastBack do + if lastBack:byte(i) ~= data:byte(i) then + why = string.format('首差@%d 得%02X 期%02X', i - 1, lastBack:byte(i), data:byte(i)) + break + end + end + end + end + logOut(string.format('> 失败 #%d off=0x%X len=%d %s', job.i, rec[2], rec[3], why)) + return 'err' + end + return 'ok' +end + +local function startJob(variant) + if job.run then return end + local list = PAYTBL.pay or {} + if #list == 0 then + setDesc1('负载表为空,已中止。', C_ERR) + return + end + job.run, job.list, job.i, job.ok, job.errs, job.vers = true, list, 0, 0, 0, 0 + job.skipRest = false + logLines = {} + logOut(ROLE == 'restore' and '> 开始还原' or '> 开始替换') + logOut(string.format('> %s 119 帧 / 共 %d 条', VOLUME, #list)) + logOut('> 读取 resource.bin') + if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end + if runningTitle then + pcall(function() + runningTitle:set { text = (ROLE == 'restore') and '正在还原' or '正在替换' } + end) + end + show(pages.running) +end + +local function tick() + tickCount = tickCount + 1 + -- 兜底:按下超过 1.6s 没收到 click(手指滑出等)就恢复高亮 + if pressed and (tickCount - pressed.t) > 40 then pressRelease() end + if not job.run or not job.list then return end + job.i = job.i + 1 + local n = #job.list + if job.i > n then + job.run = false + logOut(ROLE == 'restore' and '> 还原完毕,请重启手表。' + or '> 替换完毕,请重启手表。') + logOut(string.format('> 共 %d 条,成功 %d 失败 %d', n, job.ok, job.errs)) + if job.vers > 0 then + logOut(string.format('> 版本不符跳过 %d 条', job.vers)) + logOut('> 请勿重启,先装回匹配版本。') + else + logOut('> 若发现问题,请回滚修改。') + end + showReboot() + return + end + if job.skipRest then + job.vers = job.vers + 1 + else + local rb = PAYTBL.rb and PAYTBL.rb[job.i] + local r = writeOne(job.list[job.i], rb) + if r == 'ver' then + job.vers = job.vers + 1 + job.skipRest = true -- 版本不符:跳过 /dev/health 剩余记录 + logOut('> /dev/health 版本不符,已跳过剩余记录') + elseif r == 'ok' then + job.ok = job.ok + 1 + else + job.errs = job.errs + 1 + end + end + if job.i % 10 == 0 or job.i == n then + logOut(string.format('> 写入 %d/%d 成功 %d 失败 %d', job.i, n, job.ok, job.errs)) + end +end + +--------------------------------------------------------------------- 页面 +local restore = (ROLE == 'restore') + +local function buildHome() + local p = newPage() + if not p then return nil end + clockLabel(p) + -- 盘内标题 = 显示名(不再用中文描述名);x 按真 MiSans 31px 量出的宽度居中: + -- 'S5e Pt.2' = 125 -> x = (480-125)/2 = 177.5 -> 177 + -- 'S5e Pt.2 · 还原' = 225 -> x = (480-225)/2 = 127.5 -> 127 + if restore then + pageTitle(p, 'S5e Pt.2 · 还原', 127, 227) + else + pageTitle(p, 'S5e Pt.2', 177, 127) + end + listItem(p, 88, 'ic_help', '快速帮助', function() show(pages.help) end) + listItem(p, 186, 'ic_replace', restore and '开始还原' or '开始替换', function() + pendingVariant = 'replace' + confirmFrom = 'home' + show(pages.confirm) + end) + listItem(p, 285, 'ic_about', '关于', function() show(pages.about) end) + return p +end + +local function buildHelp() + local p = newPage() + if not p then return nil end + clockLabel(p) + backArrow(p, 167, function() show(pages.home) end) + local t = pageTitle(p, '快速帮助', 188, 124) + setClickable(t) + bind(t, function() show(pages.home) end) + + listItem(p, 88, 'ic_check', '环境预检查', function() doCheck() end) + desc1 = lab(p, { x = 67, y = 182, w = 348, h = 76, + text = '检查 health 分区与负载完整性。', + text_color = C_GRAY, font_size = 24, text_font = F_SMALL }) + + -- 拆盘之后:本盘只装一份负载,「回滚/恢复」由另一份盘负责 + listItem(p, 264, 'ic_rollback', '回滚修改', function() + if restore then + setDesc1('本盘负责还原,换回动画\n请装 S5e Pt.2。', C_GRAY) + else + setDesc1('本盘只负责替换,请装\nS5e Pt.2 · 还原。', C_GRAY) + end + end) + lab(p, { x = 67, y = 360, w = 348, h = 42, + text = restore and '把 119 帧动画换回原厂样式。' + or '恢复 119 帧动画的原厂样式。', + text_color = C_GRAY, font_size = 25, text_font = F_BODY }) + return p +end + +local function buildConfirm() + local p = newPage() + if not p then return nil end + clockLabel(p) + -- 确认页的返回:回到进来时的那个页面(首页 或 快速帮助) + local function goBack() show(pages[confirmFrom] or pages.home) end + backArrow(p, 167, goBack) + local t = pageTitle(p, restore and '开始还原' or '开始替换', 188, 124) + setClickable(t) + bind(t, goBack) + + -- 多色文本**不能**用 recolor:AP 里那几处 recolor 是图片重着色(bg_img_recolor) + -- 和表盘编辑器的主题重着色,不是文本属性;实测标记会被原样显示出来。 + -- 改为拆成多个标签 + 手工算 x(宽度用真 MiSans 25px 量出),框宽 ≥ 文本宽 + -- -> 保证不折行(折行会让手工算 x 的片段错位),并且整框都在圆内。 + -- 型号拆两行:圆屏在 y=243 处的弦半宽只有 ~190px,而 'Xiaomi Watch S5 eSIM 46mm' + -- 量得 367px,放一行会被圆边切掉(tools/s5e_check_clip.py 抓过这个)。 + local WARN = { + { 60, 95, 360, 34, '替换压力检测动画之前,', C_GRAY }, + { 60, 129, 360, 34, '请先确认自己的系统版本号为 ', C_GRAY }, + { 304, 129, 120, 34, '3.112.035', C_WHITE }, + { 422, 129, 40, 34, '!', C_GRAY }, + { 64, 195, 360, 34, '请确认自己的手表型号为', C_GRAY }, + { 64, 229, 360, 34, 'Xiaomi Watch S5', C_WHITE }, + { 64, 263, 360, 34, 'eSIM 46mm(p62lte)', C_WHITE }, + { 60, 341, 360, 34, '搞机有风险,变砖后果自负。', C_GRAY }, + } + for i = 1, #WARN do + local w = WARN[i] + lab(p, { x = w[1], y = w[2], w = w[3], h = w[4], text = w[5], + text_color = w[6], font_size = 25, text_font = F_BODY }) + end + + local b = bigButton(p, restore and '确认还原' or '确认替换') + bindTap(b, function() startJob(pendingVariant) end) + return p +end + +local function buildAbout() + local p = newPage() + if not p then return nil end + clockLabel(p) + backArrow(p, 198, function() show(pages.home) end) + local t = pageTitle(p, '关于', 220, 62) + setClickable(t) + bind(t, function() show(pages.home) end) + + -- 信息卡:底板 + 圆形 logo + 大标题 + 版本号 + pcall(function() + p:Object { x = 54, y = 81, w = 373, h = 210, pad_all = 0, + bg_color = C_PLATE, border_width = 0, radius = 31 } + end) + if IMG.logo then + pcall(function() p:Image { src = IMG.logo, x = 200, y = 111 } end) + end + -- 卡内 29px 量得:'S5e Pt.2' = 118 -> x = 54 + (373-118)/2 = 181.5 -> 181 + -- 'S5e Pt.2 · 还原' = 211 -> x = 54 + (373-211)/2 = 135(整) + if restore then + lab(p, { x = 135, y = 200, w = 213, h = 42, text = 'S5e Pt.2 · 还原', + text_color = C_WHITE, font_size = 29, text_font = F_ITEM }) + else + lab(p, { x = 181, y = 200, w = 120, h = 42, text = 'S5e Pt.2', + text_color = C_WHITE, font_size = 29, text_font = F_ITEM }) + end + -- '1.0.0' 21px 量得 49 -> 居中 x = 54 + (373-49)/2 = 216 + lab(p, { x = 222, y = 237, w = 49, h = 28, text = '1.0.0', + text_color = C_GRAY, font_size = 21, text_font = F_VER }) + + -- QQ 联系方式:结构与列表项一致(无动作,所以不给它绑点击) + listItem(p, 300, 'ic_qq', '732681995', nil) + return p +end + +local function buildRunning() + local p = newPage() + if not p then return nil end + clockLabel(p) + -- 本页没有返回图标(规格:顶栏同首页),所以标题要屏幕居中: + -- '正在替换'/'正在还原' 31px 量得 124 -> x = 240 - 124/2 = 178 + runningTitle = pageTitle(p, restore and '正在还原' or '正在替换', 178, 124) + logLabel = lab(p, { x = 67, y = 95, w = 348, h = 280, text = '', + text_color = C_WHITE, font_size = 24, text_font = F_SMALL }) + rebootBtn = bigButton(p, '重启手表') + -- 替换过程中不显示,替换完再显示 + if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end + bindTap(rebootBtn, function() + logOut('> 正在重启…') + sh("sync") + sh("reboot") + end) + return p +end + +--------------------------------------------------------------------- 组装 +local function build() + if built then return end + built = true + pages.home = buildHome() + pages.help = buildHelp() + pages.confirm = buildConfirm() + pages.about = buildAbout() + pages.running = buildRunning() + if pages.home then show(pages.home) end + clockTick() + pcall(function() + ui.timer = lvgl.Timer { period = 40, repeat_count = -1, cb = function() tick() end } + if ui.timer and ui.timer.resume then ui.timer:resume() end + end) + pcall(function() + ui.clockTimer = lvgl.Timer { period = 1000, repeat_count = -1, + cb = function() clockTick() end } + if ui.clockTimer and ui.clockTimer.resume then ui.clockTimer:resume() end + end) +end + +local okbuild, errbuild = pcall(build) +if not okbuild then + pcall(function() + local r = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000, + bg_opa = 255, border_width = 0 }) + r:Label { x = 20, y = 20, w = W - 40, h = H - 40, + text = 'BUILD FAILED:\n' .. tostring(errbuild), + text_color = '#ffffff', font_size = 13, text_font = F_BODY } + end) +end + +ui.init = function() + pcall(build) + return ui.root +end + +function pageOnPause() + if ui.timer then pcall(function() ui.timer:pause() end) end + if ui.clockTimer then pcall(function() ui.clockTimer:pause() end) end +end + +function pageOnResume() + if ui.timer then pcall(function() ui.timer:resume() end) end + if ui.clockTimer then pcall(function() ui.clockTimer:resume() end) end +end + +return ui diff --git a/s5e-pressure-anim/tools/iconpack.py b/s5e-pressure-anim/tools/iconpack.py new file mode 100644 index 0000000..4012c6e --- /dev/null +++ b/s5e-pressure-anim/tools/iconpack.py @@ -0,0 +1,447 @@ +"""Build / apply a system-app launcher-icon replacement for a Vela `vela_app.bin`. + +Why "same length" is the whole ball game +---------------------------------------- +ROMFS packs superblock -> inode -> name -> file data strictly back to back, and +every `next` / `spec` pointer is an ABSOLUTE offset. If a replacement file +changes length by even one byte, every later offset shifts and the image is +corrupt. So a replacement must encode to EXACTLY the original byte length: + + * source PNG == original dimensions -> encode, lengths match, done. + * source PNG != original dimensions -> two options: + (default) resize to the original dimensions (icon renders identically) + --pad keep the source dimensions, zero-pad the container to the + original length (renders smaller unless the launcher scales) + +Usage: + iconpack.py list + iconpack.py make [--pad] [--patched] + iconpack.py apply +""" +import os, sys, json, struct, hashlib, shutil +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from romfs import Romfs, img_header +from lvgl_bin import rle_compress, pad_rle +from PIL import Image + +CF_ARGB8888, CF_I8, MAGIC = 0x10, 0x0a, 0x19 +FMT = {CF_ARGB8888: 'ARGB8888', CF_I8: 'I8'} +RESAMPLE = Image.LANCZOS + + +# ----------------------------------------------------------------- encoders +def encode_argb8888(img): + rgba = img.convert('RGBA') + w, h = rgba.size + px = rgba.load() + out = bytearray() + for y in range(h): + row = bytearray() + for x in range(w): + r, g, b, a = px[x, y] + row += bytes((b, g, r, a)) + out += row + return bytes(out) + + +def encode_i8(img, alpha_levels=16, sharpen_up=0.0): + """I8 encoder with an ALPHA-AWARE palette. + + The naive version quantised RGB only and then stored one *averaged* alpha + per palette entry. Any colour shared by an opaque body pixel and a + semi-transparent edge pixel collapsed to a mid alpha, and fully transparent + pixels inherited that same entry -- so the transparent margin became faintly + visible (measured: perpetual_calendar haze 0.000 -> 1.000, i.e. a grey veil + around the whole icon). + + Here the palette is built over (quantised RGB, quantised alpha) PAIRS, so a + colour can own several entries at different alphas and alpha 0 stays alpha 0. + """ + rgba = img.convert('RGBA') + w, h = rgba.size + src = rgba.load() + + # average colour of the visible pixels: transparent pixels must not invent + # their own colour clusters + rs = gs = bs = n = 0 + for y in range(h): + for x in range(w): + r, g, b, a = src[x, y] + if a > 0: + rs += r; gs += g; bs += b; n += 1 + avg = (rs // n, gs // n, bs // n) if n else (0, 0, 0) + + step = max(1, 256 // max(1, alpha_levels)) + flat = Image.new('RGB', (w, h)) + fp = flat.load() + qa = [[0] * w for _ in range(h)] + for y in range(h): + for x in range(w): + r, g, b, a = src[x, y] + fp[x, y] = (r, g, b) if a > 0 else avg + q = int(round(a / float(step))) * step + qa[y][x] = 255 if q > 255 else q + + p = flat.quantize(colors=256, method=Image.MEDIANCUT) + pal_rgb = p.getpalette()[:768] + idx = p.tobytes() + + cnt = {} + for y in range(h): + row = y * w + for x in range(w): + k = (idx[row + x], qa[y][x]) + cnt[k] = cnt.get(k, 0) + 1 + keep = sorted(cnt.items(), key=lambda kv: -kv[1])[:256] + + pal = bytearray() + remap = {} + by_rgb = {} + for slot, ((pi, al), _c) in enumerate(keep): + r = pal_rgb[pi * 3] if pi * 3 + 2 < len(pal_rgb) else 0 + g = pal_rgb[pi * 3 + 1] if pi * 3 + 2 < len(pal_rgb) else 0 + b = pal_rgb[pi * 3 + 2] if pi * 3 + 2 < len(pal_rgb) else 0 + pal += bytes((b, g, r, al)) + remap[(pi, al)] = slot + by_rgb.setdefault(pi, []).append((al, slot)) + removed = 256 - len(keep) + if removed > 0: + pal += b'\x00\x00\x00\x00' * removed + for pi in by_rgb: + by_rgb[pi].sort() + + out = bytearray(w * h) + for y in range(h): + row = y * w + for x in range(w): + pi = idx[row + x] + al = qa[y][x] + s = remap.get((pi, al)) + if s is None: + cand = by_rgb.get(pi) + if cand: + s = min(cand, key=lambda t: abs(t[0] - al))[1] + else: + s = 0 + out[row + x] = s + return bytes(pal) + bytes(out) + + +def encode_i8_palette(img, pal_bgra, alpha_tol=12): + """Quantise to an EXISTING 256-entry BGRA palette (as stored in an I8 blob). + + Reusing the source icon's own palette preserves the artwork's gradient shades + and its anti-aliased edge levels. A freshly clustered palette cannot: the + plate gradient eats the 256 entries, the edge collapses to a few alpha steps + and dropped pixels land on wrong entries -- measured on remote_camera as a + jagged plate rim plus stray coloured speckles. + + The search is constrained by alpha so that + * fully transparent source pixels can only pick transparent entries + (no veil, no speckles), and + * fully opaque source pixels can only pick opaque entries + (the body never goes translucent). + """ + rgba = img.convert('RGBA') + w, h = rgba.size + px = rgba.load() + + ent = [(pal_bgra[i * 4 + 2], pal_bgra[i * 4 + 1], + pal_bgra[i * 4], pal_bgra[i * 4 + 3]) for i in range(256)] + clear = [i for i, e in enumerate(ent) if e[3] == 0] + if not clear: # guarantee a transparent entry exists + worst = min(range(256), key=lambda i: ent[i][3]) + ent[worst] = (0, 0, 0, 0) + clear = [worst] + solid = [i for i, e in enumerate(ent) if e[3] >= 248] + by_alpha = {} + for i, e in enumerate(ent): + by_alpha.setdefault(e[3], []).append(i) + + def premul(e): + r, g, b, a = e + return (r * a // 255, g * a // 255, b * a // 255, a) + + P = [premul(e) for e in ent] + cache = {} + out = bytearray(w * h) + for y in range(h): + row = y * w + for x in range(w): + r, g, b, a = px[x, y] + key = (r >> 2, g >> 2, b >> 2, a >> 3) + s = cache.get(key) + if s is None: + if a == 0: + cand = clear + elif a >= 248 and solid: + cand = solid + else: + cand = [] + for da in range(0, alpha_tol + 1): + cand += by_alpha.get(a + da, ()) if a + da <= 255 else () + if da: + cand += by_alpha.get(a - da, ()) if a - da >= 0 else () + if not cand: + cand = range(256) + pr, pg, pb, pa = premul((r, g, b, a)) + best, bd = cand[0], None + for i in cand: + q = P[i] + d = ((pr - q[0]) ** 2 + (pg - q[1]) ** 2 + (pb - q[2]) ** 2 + + 2 * (pa - q[3]) ** 2) + if bd is None or d < bd: + bd, best = d, i + s = best + cache[key] = s + out[row + x] = s + + pal = bytearray() + for (r, g, b, a) in ent: + pal += bytes((b, g, r, a)) + return bytes(pal) + bytes(out) + + +def encode(img, cf, w, h, flags=0, pad_to=None, palette=None): + if cf == CF_ARGB8888: + body = encode_argb8888(img) + elif palette is not None: + body = encode_i8_palette(img, palette) + else: + body = encode_i8(img) + stride = w * (4 if cf == CF_ARGB8888 else 1) + blob = struct.pack(' pad_to: + raise ValueError(f'encoded {len(blob)} > slot {pad_to}') + blob += b'\x00' * (pad_to - len(blob)) + return blob + + +# ----------------------------------------------------------------- helpers +def raw_i8_strided(img, stride, colors=256): + """I8 像素流:1024 B 调色板(BGRA)+ stride*h 个索引(行尾按 header 的 stride 补 0)。 + + RLE 槽位专用:用 FASTOCTREE 自适应减色 + 关闭抖动 —— 颜色越少、相邻像素越容易出现 + 长游程,RLE 流就越短(这是能不能塞进原 csize 的关键)。 + """ + q = img.convert('RGBA').quantize(colors=max(2, min(256, colors)), + method=Image.FASTOCTREE, dither=Image.NONE) + if q.palette.mode == 'RGBA': + pal = list(q.getpalette('RGBA')) + else: + rgb = q.getpalette('RGB') + pal = [v for i in range(256) for v in (rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2], 255)] + pal = (pal + [0] * 1024)[:1024] + pal_b = bytearray() + for i in range(256): # 存储序:B, G, R, A + r, g, b, a = pal[i * 4:i * 4 + 4] + pal_b += bytes((b, g, r, a)) + idx = q.tobytes() + w, h = img.size + rows = bytearray() + for y in range(h): + rows += idx[y * w:(y + 1) * w] + if stride > w: + rows += b'\x00' * (stride - w) + return bytes(pal_b) + bytes(rows) + + +# 从大到小试:优先保真,装不下再减色 +RLE_COLOR_STEPS = (256, 192, 160, 128, 96, 80, 64, 48, 40, 32, 24, 16, 12, 8, 6, 4) + + +def encode_into_slot(img, stock_blob, slot_len=None, colors=None): + """把 img 编成与 stock_blob **逐字节等长** 的 LVGL v9 blob(沿用原 header)。 + + 真实固件里这些 UI 素材有两种槽位形态,都必须支持,否则 ROMFS 后面所有偏移全平移: + * ARGB8888 未压缩(144x144 图标那种):像素流 + 零填充到原长度 + * I8 + RLE 压缩(walkie_talkie 里几乎全是):量化 -> RLE -> 用 ctrl=0 空指令补齐到原 csize + (颜色数从 256 逐级往下试,取第一个能塞进原 csize 的) + 返回 (blob, how);不支持 / 压缩后仍超长时返回 (None, 原因)。 + """ + h = img_header(stock_blob) + if h is None: + return None, '不是 LVGL bin' + w, ht, cf, flags = h['w'], h['h'], h['cf'], h['flags'] + if slot_len is None: + slot_len = len(stock_blob) + out_img = img.convert('RGBA') + if out_img.size != (w, ht): + # 约定:**长宽比不一致的素材直接不替换**(保持原厂图),而不是拉伸或裁切 —— + # 拉伸会把方块变长方形;裁切会切掉按钮两端。宁可保持原厂,也不给设备塞变形图。 + pw, ph = out_img.size + if abs(pw / ph - w / ht) / (w / ht) > 0.005: + return None, ('长宽比不一致(源 %dx%d = %.3f,槽位 %dx%d = %.3f)-> 按约定跳过,保持原厂' + % (pw, ph, pw / ph, w, ht, w / ht)) + out_img = out_img.resize((w, ht), RESAMPLE) + if cf == CF_ARGB8888 and not (flags & 0x08): + blob = stock_blob[:12] + encode_argb8888(out_img) + if len(blob) > slot_len: + return None, 'ARGB 编码 %d > 槽位 %d' % (len(blob), slot_len) + return blob + b'\x00' * (slot_len - len(blob)), 'ARGB(补 %d)' % (slot_len - len(blob)) + if cf == CF_I8 and (flags & 0x08): + method, csize, usize = struct.unpack(' csize %d(已减到 %d 色)' % (len(comp), csize, n_last) + n, raw, comp = best + comp = comp + bytes((1, raw[-1])) # 把流做到正好 usize 字节,和原厂一致 + padded = pad_rle(comp, csize) + if padded is None: + return None, '补齐失败' + blob = stock_blob[:12] + struct.pack(' inode for every launcher icon. + + alarm/launcher.bin -> alarm + sports/training/launcher.bin -> sports_training + phone/contacts_launcher.bin -> phone_contacts + """ + out = {} + for p, i in r.entries(): + if not p.rsplit('/', 1)[-1].endswith('launcher.bin'): + continue + stem = p[:-4] # drop '.bin' + if stem.endswith('_launcher'): + stem = stem[:-len('_launcher')] + elif stem.endswith('/launcher'): + stem = stem[:-len('/launcher')] + out[stem.replace('/', '_')] = i + return out + + +def describe(r, i): + h = img_header(r.read(i)) + if not h: + return '?', '?', '?' + return f'{h["w"]}x{h["h"]}', FMT.get(h['cf'], f'0x{h["cf"]:02x}'), h + + +def cmd_list(image): + r = Romfs(image) + print(f'{os.path.basename(image)} volume={r.volume} romfs_size={r.sb_size}') + for app, i in sorted(targets(r).items()): + dim, fmt, _ = describe(r, i) + print(f' {app:<26} data@0x{i.data_off:08x} len={i.size:<7} {dim} {fmt}') + + +def cmd_make(image, pngdir, outdir, pad=False, write_patched=False): + r = Romfs(image) + tg = targets(r) + os.makedirs(outdir, exist_ok=True) + payload_dir = os.path.join(outdir, 'payload') + os.makedirs(payload_dir, exist_ok=True) + patch = bytearray() + rollback = bytearray() + records, skipped = [], [] + for fn in sorted(os.listdir(pngdir)): + if not fn.lower().endswith('.png'): + continue + app = fn[:-4] + if app not in tg: + skipped.append((app, 'no such app')) + continue + ino = tg[app] + orig = r.read(ino) + _, fmt, h = describe(r, ino) + if h is None: + skipped.append((app, f'not LVGL image (len={ino.size})')) + continue + src = Image.open(os.path.join(pngdir, fn)).convert('RGBA') + src_dim = f'{src.size[0]}x{src.size[1]}' + resized = False + if src.size != (h['w'], h['h']): + if pad: + tw, th = src.size + blob = encode(src, h['cf'], tw, th, h['flags'], pad_to=len(orig)) + else: + src = src.resize((h['w'], h['h']), RESAMPLE) + tw, th = src.size + resized = True + blob = encode(src, h['cf'], tw, th, h['flags']) + else: + tw, th = src.size + blob = encode(src, h['cf'], tw, th, h['flags']) + if len(blob) != len(orig): + skipped.append((app, f'length {len(blob)} != {len(orig)}')) + continue + patch += struct.pack('12}{"bytes":>8} {"fmt":<10}{"stock":>9}{"source":>9} note') + for m in records: + note = 'resized' if m['resized'] else ('padded' if m['padded'] else '') + print(f'{m["app"]:<26}0x{m["offset"]:08x}{m["length"]:>8} {m["format"]:<10}' + f'{m["stock_dim"]:>9}{m["source_dim"]:>9} {note}') + for a, why in skipped: + print(f' SKIP {a}: {why}') + print(f'\n{len(records)} replacement(s)') + print(f' payload -> {payload_dir}') + print(f' patch -> {os.path.join(outdir, "icons.patch")} ({len(patch)} bytes)') + print(f' rollback -> {os.path.join(outdir, "icons.rollback")} ({len(rollback)} bytes)') + print(f' manifest -> {os.path.join(outdir, "manifest.json")}') + if write_patched: + out = os.path.join(outdir, 'vela_app_patched.bin') + cmd_apply(image, os.path.join(outdir, 'icons.patch'), out) + + +def cmd_apply(image, patch, out): + data = bytearray(open(image, 'rb').read()) + blob = open(patch, 'rb').read() + off = n = 0 + while off < len(blob): + addr, ln = struct.unpack(' {out}') + + +if __name__ == '__main__': + a = sys.argv[1:] + if not a: + print(__doc__) + elif a[0] == 'list': + cmd_list(a[1]) + elif a[0] == 'make': + cmd_make(a[1], a[2], a[3], '--pad' in a, '--patched' in a) + elif a[0] == 'apply': + cmd_apply(a[1], a[2], a[3]) + else: + print(__doc__) diff --git a/s5e-pressure-anim/tools/lua_balance.py b/s5e-pressure-anim/tools/lua_balance.py new file mode 100644 index 0000000..a33ebba --- /dev/null +++ b/s5e-pressure-anim/tools/lua_balance.py @@ -0,0 +1,72 @@ +"""Crude but effective Lua block-balance checker: strips comments and strings, +then counts block openers vs `end`.""" +import sys, re + +def strip(src): + out = [] + i, n = 0, len(src) + while i < n: + c = src[i] + # long bracket comment / string --[[ ]] , --[==[ ]==] + m = re.match(r'--\[(=*)\[', src[i:]) + if m: + close = ']' + m.group(1) + ']' + j = src.find(close, i) + i = n if j < 0 else j + len(close) + out.append(' ') + continue + if src.startswith('--', i): + j = src.find('\n', i) + i = n if j < 0 else j + out.append(' ') + continue + m = re.match(r'\[(=*)\[', src[i:]) + if m: + close = ']' + m.group(1) + ']' + j = src.find(close, i) + i = n if j < 0 else j + len(close) + out.append('""') + continue + if c in '"\'': + j = i + 1 + while j < n: + if src[j] == '\\': + j += 2 + continue + if src[j] == c: + break + j += 1 + i = j + 1 + out.append('""') + continue + out.append(c) + i += 1 + return ''.join(out) + + +def check(path): + src = open(path, encoding='utf-8').read() + code = strip(src) + def cnt(k): + return len(re.findall(r'(? n: + raise ValueError('truncated literal run') + out += data[i:i + nb] + i += nb + else: + cnt = ctrl + if i + blk > n: + raise ValueError('truncated repeat unit') + unit = data[i:i + blk] + i += blk + out += unit * cnt + if out_len is not None and len(out) >= out_len: + break + return bytes(out) + + +def rle_compress(raw, max_run=127): + """LVGL RLE 编码器(rle_decompress 的逆向,blk=1)。 + + 贪心:先找同字节游程(>=2 用「重复」指令),否则攒「原样」指令。 + 输出长度总是 <= 原图像素流,因为最坏情况每条 literal 只多 1 个控制字节。 + """ + out = bytearray() + i, n = 0, len(raw) + while i < n: + v = raw[i] + j = i + 1 + while j < n and raw[j] == v and j - i < max_run: + j += 1 + run = j - i + if run >= 2: + out.append(run); out.append(v); i = j + continue + lit = bytearray() + k = i + while k < n and len(lit) < max_run: + if k + 1 < n and raw[k] == raw[k + 1]: + break + lit.append(raw[k]); k += 1 + if not lit: # 理论上到不了这里 + lit.append(v); k = i + 1 + out.append(0x80 | len(lit)); out += lit + i = k + return bytes(out) + + +def pad_rle(comp, want): + """把 RLE 流补到 want 字节:用 ctrl=0(重复 0 次 = 产出 0 像素)的 2 字节空指令。 + + ctrl=0 是合法指令(`len = 0`,两个越界判断都不触发),所以设备端解码器读完 csize + 字节既不会越界、也不会返回 INVALID —— 这是「等长覆盖」能成立的关键。 + 差值为奇数时先把一条 literal 拆成两条(payload 不变、多 1 个控制字节)。 + """ + d = want - len(comp) + if d < 0: + return None + if d % 2: + b = bytearray(comp) + for idx in range(len(b) - 1, -1, -1): + if b[idx] & 0x80 and (b[idx] & 0x7F) >= 2: + cnt = b[idx] & 0x7F + lit = bytes(b[idx + 1: idx + 1 + cnt]) + half = cnt // 2 + b[idx: idx + 1 + cnt] = (bytes([0x80 | half]) + lit[:half] + + bytes([0x80 | (cnt - half)]) + lit[half:]) + break + else: + return None + comp = bytes(b) + d -= 1 + return comp + b'\x00\x00' * (d // 2) + + +def parse_header(blob): + if len(blob) < 12 or blob[0] != MAGIC: + return None + magic, cf, flags, w, h, stride, rsv = struct.unpack('') + + +class Romfs: + def __init__(self, path): + self.path = path + self.data = open(path, 'rb').read() + d = self.data + if d[0:8] != b'-rom1fs-': + raise ValueError('not a romfs image') + self.sb_size, self.sb_cksum = struct.unpack('>II', d[8:16]) + end = d.index(b'\x00', 16) + self.volume = d[16:end].decode('latin1') + self.root_off = 16 + ROUND16(end - 16 + 1) + + def inode(self, off): + i = Inode() + i.off = off + i.next, i.spec, i.size, i.cksum = struct.unpack('>IIII', self.data[off:off+16]) + p = off + 16 + end = self.data.find(b'\x00', p, p + 256) + if end < 0: + raise ValueError(f'bad name at 0x{off:x}') + i.name = self.data[p:end].decode('latin1') + i.namelen = end - p + i.data_off = off + 16 + ROUND16(i.namelen + 1) + return i + + def walk(self, first, limit, prefix=''): + """Yield (path, Inode) for every entry reachable from `first` below `limit`. + + `limit` bounds the sibling chain. The LAST entry of a chain has next==0, + but its own children still live after it: they inherit the parent's limit + instead of being bounded by that zero. + """ + off = first + seen = set() + while off and (limit == 0 or off < limit) and off not in seen: + seen.add(off) + i = self.inode(off) + path = prefix + i.name + yield path, i + if i.type == 1 and i.spec: # directory -> first child at spec + yield from self.walk(i.spec, i.next_off or limit, path + '/') + off = i.next_off + + def entries(self): + """All entries of the image, as (path, Inode).""" + return self.walk(self.root_off + 0x20, len(self.data)) + + def read(self, ino): + return self.data[ino.data_off:ino.data_off + ino.size] + + +def img_header(blob): + if len(blob) < 12 or blob[0] != 0x19: + return None + magic, cf, flags, w, h, stride, res = struct.unpack(' 2 and sys.argv[2] not in path: + continue + kind = ('DIR ' if i.type == 1 else 'LNK ' if i.type == 0 else 'FILE') + print(f'{kind} {i.size:>9} {path}') + print(f'-- total entries: {n}') diff --git a/s5e-pressure-anim/tools/s5e_anim.py b/s5e-pressure-anim/tools/s5e_anim.py new file mode 100644 index 0000000..e85bab3 --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_anim.py @@ -0,0 +1,562 @@ +# -*- coding: utf-8 -*- +"""把 S5 41mm(q63) 的 `pressure/measure/Measuring1..100.bin`(480x480)逐帧等长写进 +S5 eSIM(p62lte) `vela_health.bin` 的 `pressure/measure/Measuring1..119.bin`(464x464 I8+RLE)。 + +硬约束(为什么必须"逐字节等长") +-------------------------------- +ROMFS 里 `next` / `spec` 全是**绝对偏移**,文件数据又紧随 inode 之后;任何一条负载 +长度变化 1 字节,后面所有文件的偏移就整体平移 → 分区报废。所以: + + 12 B 头 + 12 B 压缩头 + csize 必须 == 原槽位长度 + csize 原样沿用(解码器按 csize 读),usize 原样沿用(= 1024 + 464*464 = 216321) + +四步流水线 +---------- + 1) 解码 q63 帧 → 缩放到 464x464(**缩小**,LANCZOS) + 2) 暗部归零:q63 帧的"黑"其实是一条均匀的 #1a1a1a(26,26,26) 底噪 + 抖动噪声, + 实测占 68% 的像素、熵极高(256 色几乎全糊在暗部,不处理时 RLE 压不动:172 KB)。 + 阈值以下的像素一律置 0,背景塌成整行单游程,体积掉到 1/4(~45 KB)。 + 3) 量化:按一条**单调质量阶梯**(缩放比例 + 颜色数一起动)挑能塞进 csize 的最高档。 + 4) LVGL RLE 压缩 → 先补「重复 N 次」把解码长度做到**正好 usize**, + 再用 `pad_rle_safe` 的合法空指令(00 00 / 80)补齐到 csize。 + +为什么用"质量单参数"而不是逐槽各自最优 +-------------------------------------- +槽位预算从 4.5 KB 到 196 KB 差 40 倍。逐帧各自最优会让相邻帧出现 +「2 色 x0.32」跳到「4 色 x0.80」这种忽大忽小(S4e 那版踩过)。这里把 +(颜色数, 缩放) 绑成一个连续量,先算每槽能撑住的最大档,再从峰值向两侧取 +「运行最小值」—— 曲线单峰且平滑,画面尺寸/色阶随预算连续变化。 + +产出 +---- + .work/anim/vela_health_anim.bin 改后镜像(长度 == 原镜像) + .work/anim/manifest.json 逐条清单(偏移/长度/映射/降级/保留) +""" +import os, sys, json, struct, shutil, hashlib, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import (PROJ, INPUTS, WORK, TARGET_IMAGE, SOURCE_IMAGE, TARGET_VOLUME, + DIRP, FACE_W, FACE_H) +from romfs import Romfs +import lvgl_bin as L +from PIL import Image +import numpy as np + +OUTDIR = os.path.join(WORK, 'anim') +PATCHED = os.path.join(OUTDIR, 'vela_health_anim.bin') +# 设备端解码器要求流解出的字节数**正好** == 头里的 usize。原厂 119 条全是 216321, +# 比"调色板 + 像素"多 1 字节(解码器只用到前 1024 + 464*464 字节,多出来的 1 字节 +# 落在行外、被 `y < h` 挡掉,所以既安全又是原厂行为)。实测值由 build() 从镜像里读出来, +# 不在这儿写常量 —— 写错过一次(写成 216320)就被下面的断言当场抓住。 +USIZE = 1024 + FACE_W * FACE_H # 216320:调色板 + 像素;真实值以镜像为准 +TAIL_EXTRA = 1 # 期望的原厂尾部额外字节数 +USIZE_EXTRA = TAIL_EXTRA # 实际值由 build() 从镜像读出来后覆盖 + +# ------------------------------------------------------------------ 暗部归零 +# q63 帧的底噪是 #1a1a1a(26),球体最暗的实体像素也 >100。取 40 吃掉整片底噪与抖动, +# 又不动球体本体;56 作为第二档兜底(更狠,留给塞不下的槽位)。 +DARK_LEVELS = (40, 56) + +# ------------------------------------------------------------------ 固定调色板(低预算兜底) +# 到 8 色以下时"重新聚类"会把仅剩的颜色浪费在底噪残渣上、球体反而变花; +# 改用这几套手工调色板(索引 0 一定是黑),画面干净、也更好压。 +FIXED_PALETTES = { + 2: [(0, 0, 0), (92, 132, 255)], + 3: [(0, 0, 0), (40, 60, 190), (120, 190, 255)], + 4: [(0, 0, 0), (28, 40, 150), (70, 120, 245), (140, 220, 210)], + 6: [(0, 0, 0), (18, 26, 110), (44, 66, 200), (80, 130, 250), + (140, 200, 245), (150, 220, 160)], + 8: [(0, 0, 0), (12, 18, 80), (26, 38, 140), (46, 70, 195), (72, 110, 240), + (110, 165, 252), (165, 215, 250), (150, 222, 168)], +} + +# ------------------------------------------------------------------ 质量阶梯 +# 从「最好」到「最省」单调排列:(缩放, 颜色数, 固定调色板色数|None) +LADDER = [ + (1.00, 256, None), (1.00, 192, None), (1.00, 128, None), (1.00, 96, None), + (0.95, 96, None), (0.95, 64, None), (0.90, 64, None), + (0.85, 64, None), (0.85, 48, None), (0.80, 48, None), + (0.75, 48, None), (0.75, 32, None), (0.70, 32, None), + (0.65, 32, None), (0.65, 24, None), (0.60, 24, None), + (0.55, 24, None), (0.55, 16, None), (0.50, 16, None), + (0.45, 16, None), (0.45, 12, None), (0.40, 12, None), + (0.35, 12, None), (0.35, 8, 8), (0.30, 8, 8), + (0.26, 6, 6), (0.22, 6, 6), (0.19, 4, 4), (0.16, 4, 4), + (0.13, 3, 3), (0.11, 3, 3), (0.09, 2, 2), (0.07, 2, 2), (0.05, 2, 2), +] +CI_KEEP = len(LADDER) # 哨兵:连最省档都塞不下 +# 平滑之后允许比目标档**大**多少缩放:0 表示只许更小。留一点余量是为了让球体尽量顶到 +# 预算上限,同时不给相邻帧制造肉眼可见的尺寸跳变(每档缩放差约 0.05)。 +SCALE_SLACK = 0.02 + + +def num_of(path): + return int(path.rsplit('Measuring', 1)[-1].replace('.bin', '')) + + +def collect(rom): + d = {} + for p, i in rom.entries(): + if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0: + continue + d[num_of(p)] = (p, i, rom.read(i)) + return d + + +# ------------------------------------------------------------------ 编码原语 +def palette_bytes(cols): + """1024 B BGRA;索引 0 一定是黑,没用的条目留 0 —— 调色板本身也好压。""" + out = bytearray(1024) + for k, (r, g, b) in enumerate(cols[:256]): + out[k * 4:k * 4 + 4] = bytes((b, g, r, 255)) + return bytes(out) + + +def kill_dark(img, dark): + a = np.asarray(img.convert('RGB')).copy() + a[a.max(axis=2) < dark] = 0 + return Image.fromarray(a, 'RGB') + + +def scale_image(img, sc): + if sc >= 0.999: + return img + s = max(4, int(round(FACE_W * sc))) + small = img.convert('RGBA').resize((s, s), Image.LANCZOS) + cv = Image.new('RGBA', (FACE_W, FACE_H), (0, 0, 0, 255)) + cv.paste(small, ((FACE_W - s) // 2, (FACE_H - s) // 2)) + return cv + + +def quantize_raw(rgb, ncol, fixed): + """-> 1024 B 调色板 + w*h 个索引(LVGL I8 的裸像素流)。 + + 自己实现量化,不再借 PIL 的调色板 API —— 这里踩过两个坑,留作警钟: + ① 为了"保证索引 0 是黑",先把图贴到 (W+8)x(H+8) 黑画布上再 quantize, + 然后 `q.tobytes()[:W*H]` 取像素。但 q 是 472 宽的,按 464 宽读就"每行错 8 列", + 整幅图被剪成斜条纹 —— RLE 流完全正确、解出来也和"量化结果"逐点一致, + 只有拿"量化结果的形状"去比**源图**才抓得住(见 silhouette())。 + ② 改成"循环移位调色板 + convert()/paste()"更糟:`quantize(palette=...)` 会把图重新 + 合并成它自己算出来的调色板,`paste()` 又只搬调色板,于是 64 色时整幅图变纯黑。 + 与其猜 PIL 的语义,不如把量化写成几行明确的 numpy。 + + 现在:黑单独占索引 0(背景因此塌成单游程),其余 ncol-1 个颜色在**非黑像素**上做 + 加权 k-means(按出现次数加权,梯度才不会被子像素级的近似黑拖偏)。固定迭代次数 => 确定。 + """ + src = np.asarray(rgb if rgb.mode == 'RGB' else rgb.convert('RGB')) + return quantize_arr(src, ncol, fixed) + + +def quantize_arr(src, ncol, fixed, cache=None, ckey=None): + """量化主体。src 是 (H,W,3) uint8;ckey 用来复用同一帧的颜色表。 + + 性能关键(第一版在这里把机器跑爆过内存 1.7 GB / CPU 卡死): + * 源图有 1.5~2 万个不同颜色、21.5 万个像素。**质心只在"不同颜色"上算**, + 绝不在 21.5 万像素上做 (N, K, 3) 的距离广播 + (那一步就是 N=215296 x K=255 的模式,一个临时数组 1.6 GB)。 + * 像素 -> 索引 走一张**粗查色立方** LUT:先把 RGB 量化到 step 网格 + (step=4 时 64³ = 262144 个格子),只对这 26 万个格子算一次最近邻, + 再让 21.5 万像素直接查表。代价是"近似最近邻"(最大偏 step/2 = 2 个色阶), + 对 8 位调色板完全够用。 + """ + H, W = src.shape[:2] + if cache is None: + cache = {} + ent = cache.get(ckey) + if ent is None: + flat = src.reshape(-1, 3) + bg = flat.max(axis=1) == 0 + upix, inv = np.unique(flat, axis=0, return_inverse=True) + upix = np.ascontiguousarray(upix, dtype=np.int32) + inv = inv.reshape(-1).astype(np.int32) + fg = np.nonzero(upix.max(axis=1) > 0)[0] + ent = (upix, inv, fg, bg, flat.shape[0]) + cache[ckey] = ent + upix, inv, fg, bg, npix = ent + if len(fg) == 0: + return palette_bytes([(0, 0, 0)]) + bytes(npix) + if fixed is not None: + cols = FIXED_PALETTES[fixed] + out = _lut_index(upix, cols)[inv] + out[bg] = 0 + # 背景必须落在索引 0(黑),这样 RLE 才是一条单游程 + if cols[0] != (0, 0, 0): + raise ValueError('固定调色板的索引 0 必须是黑') + return palette_bytes(cols) + out.tobytes() + k = max(1, min(255, int(ncol) - 1)) + ufg = upix[fg] + uq, cnt = np.unique(ufg, axis=0, return_counts=True) + if len(uq) <= k: + centers = uq.astype(np.float64) + else: + centers = uq[cnt.argsort()[::-1][:k]].astype(np.float64) # 出现最多的 k 色做种子 + w = cnt.astype(np.float64) + K = len(centers) + for _ in range(8): + lab = _nearest_argmin(uq, centers) + new = centers.copy() + for j in range(K): + sel = lab == j + if sel.any(): + ww = w[sel][:, None] + new[j] = (uq[sel] * ww).sum(axis=0) / ww.sum() + done = np.allclose(new, centers, atol=0.25) + centers = new + if done: + break + cols = [(0, 0, 0)] + [tuple(int(round(v)) for v in c) for c in centers] + out = _lut_index(upix, cols)[inv] + out[bg] = 0 + return palette_bytes(cols) + out.tobytes() + + +def _nearest_argmin(px, cent): + """返回每个 px 最近的 cent 下标;只用 float32(int32 的立方广播会反复申请上百 MB)。""" + a = np.asarray(px, dtype=np.float32) + c = np.asarray(cent, dtype=np.float32) + out = np.empty(len(a), dtype=np.int32) + chunk = 8192 + for s in range(0, len(a), chunk): + d = ((a[s:s + chunk, None, :] - c[None, :, :]) ** 2).sum(axis=2) + out[s:s + chunk] = d.argmin(axis=1) + return out + + +_GRID_CACHE = {} + + +def _cube_grid(step): + """粗查色立方的网格代表色(只算一次,全局缓存)。 + + 展平顺序是 idx = r*(bins*bins) + g*bins + b —— 与 `meshgrid(indexing='ij')` + + `reshape(-1,3)` 完全一致,所以立方体可以直接按这个公式索引。 + ★ 轴序写错会静默换色:查表必须用 (R,G,B) 顺序,写成 (B,G,R) 颜色就全串了。 + """ + g = _GRID_CACHE.get(step) + if g is None: + bins = 256 // step + 1 + ax = np.arange(bins, dtype=np.int32) * step + grid = np.stack(np.meshgrid(ax, ax, ax, indexing='ij'), axis=-1).reshape(-1, 3) + _GRID_CACHE[step] = (grid, bins) + return _GRID_CACHE[step] + return g + + +def _lut_index(px, cols): + """像素 -> 最近调色板项,走粗查色立方 LUT。 + + step=8 → 33³ = 35937 个格子,单通道最大偏差 step/2 = 4 个色阶; + 对这些低对比度、本来就带抖动的画面完全够用(step=4 的 26 万格子慢 8 倍)。 + 用 float32 算距离:int32 的 (35937, 256, 3) 中间数组会反复申请 ~100 MB, + 是之前把内存跑到 2.6 GB、整体跑十几分钟的元凶。 + """ + grid, bins = _cube_grid(8) + pal = np.asarray(cols, dtype=np.float32) + best = np.empty(len(grid), dtype=np.uint8) + gn = grid.astype(np.float32) + chunk = 8192 + for s in range(0, len(grid), chunk): + a = gn[s:s + chunk] + d = ((a[:, None, :] - pal[None, :, :]) ** 2).sum(axis=2) + best[s:s + chunk] = d.argmin(axis=1).astype(np.uint8) + q = np.clip((px.astype(np.int32) + 4) >> 3, 0, bins - 1) + flat = (q[:, 0] * bins + q[:, 1]) * bins + q[:, 2] + return best[flat] + + +def pad_to_usize(comp, raw_len): + """解码长度必须**正好 == usize**,否则设备端解码器直接判失败、整帧不画。 + + ★ 本会话踩过的坑:库里 rle_compress 的输出曾比 usize 少 1 个字节(末尾少一条指令)。 + 这里显式补「重复 N 次」token 把长度做满。补出来的余数可能是奇数(原厂 usize 就是 + 1024+464*464+1 = 216321,比"调色板+像素"多 1),所以最后允许一条「重复 1 次」的 + 2 字节指令;`00 00`(重复 0 次)也是合法空指令。 + """ + short = USIZE - raw_len + if short <= 0: + return comp + ex = bytearray() + while short >= 2: + k = min(short, 127) + ex += bytes((k, 0)) + short -= k + if short == 1: + ex += bytes((1, 0)) + return comp + bytes(ex) + + +def encode_cell(img, cell, qcache=None, qkey=None): + """(缩放, 颜色数, 固定) -> (RLE 流 bytes, 裸像素流 bytes)。不含 24 B 头。 + + qkey 要带上**缩放比例**:同一帧的不同缩放是不同的数组, + ★ 少了这个维度会让"不同颜色的表"永远命中不了缓存,每次都在 21.5 万像素上重算 + (第一版就是这样把内存跑到 1.7 GB、CPU 卡死的)。 + """ + sc, ncol, fixed = cell + rgb = scale_image(img, sc).convert('RGB') + raw = quantize_arr(np.asarray(rgb), ncol, fixed, qcache, + (qkey, sc) if qkey is not None else None) + return pad_to_usize(L.rle_compress(raw), len(raw)), raw + + +def raw_to_rgb(raw): + """把裸像素流还原成 RGB 图(用于"解出来的像素 == 量化结果"这条自证)。""" + pal = bytearray(768) + for i in range(256): + pal[i * 3 + 0] = raw[i * 4 + 2] + pal[i * 3 + 1] = raw[i * 4 + 1] + pal[i * 3 + 2] = raw[i * 4 + 0] + im = Image.frombytes('P', (FACE_W, FACE_H), raw[1024:1024 + FACE_W * FACE_H]) + im.putpalette(bytes(pal)) + return im.convert('RGB') + + +def silhouette(raw): + """(非黑像素数, bbox) —— 用来挡住"字节完全自洽、内容却整片错位"这类 bug。 + + ★ 这里就是因为只比字节、不比形状,差点放过去一版"斜条纹"图: + 当时 quantize 是按 472 宽取的像素,读成 464 宽 → 每行错 8 列 → 整幅图被剪成斜条纹。 + RLE 流本身完全正确、解出来的像素也和"量化结果"逐点一致,所以只有把 + "量化结果"再和**源图**比形状才抓得住。下面这个函数就是干这个的。 + """ + a = np.asarray(raw_to_rgb(raw)) + m = a.max(axis=2) > 24 + n = int(m.sum()) + if not n: + return 0, None + ys, xs = np.nonzero(m) + return n, (int(ys.min()), int(xs.min()), int(ys.max()), int(xs.max())) + + +# ------------------------------------------------------------------ 主流程 +def build(): + t0 = time.time() + global USIZE, USIZE_EXTRA + os.makedirs(OUTDIR, exist_ok=True) + stock, src = Romfs(TARGET_IMAGE), Romfs(SOURCE_IMAGE) + if stock.volume != TARGET_VOLUME: + raise SystemExit('目标镜像卷名是 %r,期望 %r —— 拿错分区了?' + % (stock.volume, TARGET_VOLUME)) + slots, srcs = collect(stock), collect(src) + tl, sl = sorted(slots), sorted(srcs) + h0 = L.parse_header(slots[tl[0]][2]) + print('目标槽位 %d 个(Measuring%d..Measuring%d,合计 %.2f MB)' % ( + len(tl), tl[0], tl[-1], sum(len(slots[n][2]) for n in tl) / 1048576)) + print('源帧 %d 个(Measuring%d..Measuring%d,合计 %.2f MB)' % ( + len(sl), sl[0], sl[-1], sum(len(srcs[n][2]) for n in sl) / 1048576)) + print('槽位格式 %dx%d cf=0x%02x flags=0x%02x stride=%d' % ( + h0['w'], h0['h'], h0['cf'], h0['flags'], h0['stride'])) + assert (h0['w'], h0['h']) == (FACE_W, FACE_H), '槽位不是 %dx%d' % (FACE_W, FACE_H) + assert (h0['cf'], h0['flags']) == (0x0a, 0x08), '槽位不是 I8 + RLE' + # ★ usize 从镜像里读出来,别写常量:原厂是 1024 + 464*464 + 1 = 216321 + n_usize = {struct.unpack(' (comp, raw) + src_sils = {} # (src_n, dark, ci) -> (非黑像素数, bbox) + qcache = {} + + def get_cell(src_n, dark, ci): + key = (src_n, dark, ci) + v = cells.get(key) + if v is None: + v = encode_cell(frames[src_n][dark], LADDER[ci], qcache, (src_n, dark)) + cells[key] = v + src_sils[key] = silhouette(v[1]) + return v + + def fits(src_n, dark, ci, csize): + return len(get_cell(src_n, dark, ci)[0]) <= csize + + # ---- 第一遍:逐槽量出「能塞进 csize 的最大阶梯档」---- + caps = [] + for k, n in enumerate(tl): + csize = struct.unpack(' best[1]: + best = ((ci, dark), key) + rec_name = '%03d_%s_%d' % (len(records), path.rsplit('/', 1)[-1][:-4], n) + if best is None: + kept.append((n, csize)) + records.append(dict(name=rec_name, src='measure%d' % src_n, offset=ino.data_off, + length=len(blob), data=blob, csize=csize, how='保留原厂', + ci=None, dark=None, scale=1.0, colors=0, pad=0, enc=0)) + print(' !! 槽 %d(csize %d)塞不下 -> 保留原厂' % (n, csize)) + continue + (ci, dark), _key = best + comp, raw = get_cell(src_n, dark, ci) + new = blob[:12] + struct.pack('= 0.995 else ' 缩放%.2f' % sc + how += ' 暗部<%d' % dark + if ci > start: + degrade.append((n, csize, start, ci, how)) + how += ' [平滑目标第%d档放不下,退到第%d档]' % (start, ci) + records.append(dict(name=rec_name, src='measure%d' % src_n, offset=ino.data_off, + length=len(blob), data=new, csize=csize, how=how, ci=ci, + dark=dark, scale=round(sc, 4), colors=ncol, fixed=fixed, + pad=csize - len(comp), enc=len(comp), target_ci=start)) + + # ---- 写出镜像 + 清单 ---- + shutil.copyfile(TARGET_IMAGE, PATCHED) + with open(PATCHED, 'r+b') as f: + for r in records: + f.seek(r['offset']) + f.write(r['data']) + manifest = dict( + device='Xiaomi Watch S5 eSIM 46mm (p62lte)', + partition='vela_health.bin volume=health device node=/dev/health', + source='Xiaomi Watch S5 41mm (q63, v4.101.020) pressure/measure frames', + target_dir=DIRP, + mapping='第 k 个槽位(k=1..%d) <- q63 第 round((k-1)/(%d-1)*(%d-1))+1 帧(1..%d)' + % (len(tl), len(tl), len(sl), len(sl)), + face_wh=[FACE_W, FACE_H], slot_count=len(tl), source_count=len(sl), usize=USIZE, + image_sha256=hashlib.sha256(stock.data).hexdigest(), + ladder=[dict(ci=i, scale=c[0], colors=c[1], fixed=c[2]) for i, c in enumerate(LADDER)], + records=[dict(name=r['name'], offset=r['offset'], length=r['length'], + src=r['src'], how=r['how'], ci=r['ci'], dark=r['dark'], + scale=r['scale'], colors=r['colors'], fixed=r['fixed'], + pad=r['pad'], enc=r['enc'], target_ci=r['target_ci'], + csize=r['csize']) for r in records], + kept_stock=[dict(n=n, csize=c, name=slots[n][0]) for n, c in kept], + degraded=[dict(n=n, csize=c, from_ci=a, to_ci=b, how=h) for n, c, a, b, h in degrade]) + json.dump(manifest, open(os.path.join(OUTDIR, 'manifest.json'), 'w', encoding='utf-8'), + indent=1, ensure_ascii=False) + + # ---- 汇总 ---- + print('\n阶梯使用情况(颜色数, 缩放, 暗部 -> 帧数):') + used = {} + for r in records: + if r['ci'] is None: + continue + k2 = (r['colors'], r['scale'], r['dark']) + used[k2] = used.get(k2, 0) + 1 + for kk, v in sorted(used.items(), key=lambda kv: (-kv[0][1], -kv[0][0])): + print(' %3d 色 缩放%.2f 暗部<%d -> %3d 帧' % (kk[0], kk[1], kk[2], v)) + print(' 保留原厂:%d 帧 %s' % (len(kept), [n for n, _ in kept])) + print(' 平滑目标档放不下、被迫退档:%d 帧 %s' + % (len(degrade), [(n, h) for n, _c, _a, _b, h in degrade][:8])) + pads = [p[3] for p in stat] + print(' 槽位填充:实际用掉 %.1f%% 的 csize(填充 min %d / max %d / 平均 %.0f B,' + '全是合法空指令 00 00 / 80)' + % (100.0 * sum(p[2] for p in stat) / sum(p[1] for p in stat), + min(pads), max(pads), sum(pads) / len(pads))) + print(' 回解自证:正好 usize 且像素一致 %d / %d' % (decode_ok, len(records) - len(kept))) + + # ---- 独立自证 ---- + r2 = Romfs(PATCHED) + a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()] + b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in r2.entries()] + print('\ninode 表逐位不变:%s(%d 条)' % (a == b, len(a))) + print('superblock 一致:%s(size %d / cksum %08x)' % ( + stock.sb_size == r2.sb_size and stock.sb_cksum == r2.sb_cksum, + r2.sb_size, r2.sb_cksum)) + print('镜像长度一致:%s(%d B)' % (len(stock.data) == len(r2.data), len(r2.data))) + sh = open(PATCHED, 'rb').read() + replay = all(sh[r['offset']:r['offset'] + r['length']] == r['data'] for r in records) + print('回放(改后镜像 == 计划字节):%s' % replay) + lens_ok = all(r['length'] == len(slots[int(r['name'].rsplit('_', 1)[1])][2]) for r in records) + print('每条负载长度 == 原槽位长度:%s' % lens_ok) + print('\n负载合计 %.2f MB;耗时 %.0fs;写出 %s' + % (sum(r['length'] for r in records) / 1048576, time.time() - t0, + os.path.join(OUTDIR, 'manifest.json'))) + return manifest + + +if __name__ == '__main__': + only = sys.argv[1] if len(sys.argv) > 1 else 'all' + if only not in ('all', 'replace', 'restore'): + raise SystemExit('用法: s5e_anim.py [all|replace|restore]') + build() diff --git a/s5e-pressure-anim/tools/s5e_build.py b/s5e-pressure-anim/tools/s5e_build.py new file mode 100644 index 0000000..19b9cfb --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_build.py @@ -0,0 +1,312 @@ +# -*- coding: utf-8 -*- +"""两遍构建:表盘源码 + 资源 -> dist/S5ePAnim.face / dist/S5ePAnimRestore.face + + main.lua <- **源码**,含 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 占位符 + .work/overlay/ <- 中间产物(回填后的 Lua 落在这里) + .work/build/ <- 编译工作区(Compiler.exe 的输入) + dist/.face <- 交付物 + +为什么两遍 +---------- +.face 里各资源的偏移取决于打包顺序;我们靠在 .face 里**搜字节**来定位它们。 +Lua 源码排在所有资源之后,所以回填(Lua 变长)不会移动前面的资源 —— 脚本最后逐条校验 0 漂移。 + +为什么最终产物要"再裸编一次" +---------------------------- +两遍构建走的是 vendor 的 scripts/build_face.ps1,它在编译后调 set_face_id.ps1, +把**头里第 5 字节写成 id 长度 10**,而固件自带表盘的 b5 是 0。 +所以交付物改从裸 `Compiler.exe -b` 的输出取(b5=0),只覆盖 40..49 = id。 +断言:裸产物与 toolchain 产物必须"只差 b5 + id 这 8 个字节",否则直接拒绝交付。 + +两份盘共用同一个 watchface id(462150102):装一份就顶掉另一份,设备上只占一份空间。 +""" +import os, re, sys, json, shutil, hashlib, struct, subprocess +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import PROJ, WORK, DIST, TEMPLATE, WF_ID, SCREEN, DEVICE_TYPE, PREVIEW +import s5e_overlay as OV + +ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() +assert ROLE in ('replace', 'restore'), ROLE +NAME = 'S5ePAnim' if ROLE == 'replace' else 'S5ePAnimRestore' # 路径/标识(不要改) +TITLE = 'S5e Pt.2' # 手表上显示的表盘名(replace / restore 两张盘同名,须与 s5e_overlay.TITLE 一致) +RECDIR = 'payload' if ROLE == 'replace' else 'payload_restore' +TOOLS = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(PROJ, 'main.lua') +OVER = os.path.join(WORK, 'overlay', NAME) +BUILD = os.path.join(WORK, 'build', NAME) +RESOLVED = os.path.join(OVER, 'watchface', 'fprj', 'app', 'lua', 'main.lua') +LOGLINES = [] + + +def log(s): + print(s) + LOGLINES.append(s) + + +def write_lua(lua_text): + """把 Lua 写进编译工作区(两遍构建的唯一区别就在这里)。""" + p = os.path.join(BUILD, 'watchface', 'fprj', 'app', 'lua', 'main.lua') + os.makedirs(os.path.dirname(p), exist_ok=True) + open(p, 'w', encoding='utf-8', newline='\n').write(lua_text) + + +def prepare(lua_text, role): + """把官方模板拷成编译工作区,写 config / fprj / preview / lua / 资源。""" + if not os.path.exists(TEMPLATE): + raise SystemExit('找不到官方模板:%s\n请把 LuaDevTemplate-main 放到 vendor/ 下' % TEMPLATE) + if os.path.exists(BUILD): + shutil.rmtree(BUILD) + shutil.copytree(TEMPLATE, BUILD) + old = os.path.join(BUILD, 'watchface', 'fprj', 'LuaDevTemplate.fprj') + if os.path.exists(old): + os.remove(old) + + cfg = {"projectName": TITLE, "watchfaceId": WF_ID, "power_consumption": "3", + "resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE", + "input": "watchface/fprj/images/preview.png", "name": "preview"}} + json.dump(cfg, open(os.path.join(BUILD, 'watchface.config.json'), 'w', encoding='utf-8'), + indent=4, ensure_ascii=False) + + fprj = ('\r\n' + '\r\n' + ' \r\n' + ' \r\n' + ' \r\n\r\n') % (DEVICE_TYPE, TITLE, SCREEN, SCREEN) + open(os.path.join(BUILD, 'watchface', 'fprj', NAME + '.fprj'), 'w', + encoding='utf-16', newline='').write(fprj) + + # 预览图 = 真实首页渲染 -> 圆形 -> 326x326(Compiler.exe 对 462 只收这个尺寸) + prev = os.path.join(OVER, 'watchface', 'fprj', 'images', 'preview.png') + OV.make_preview(prev) + shutil.copyfile(prev, os.path.join(BUILD, 'watchface', 'fprj', 'images', 'preview.png')) + + write_lua(lua_text) + + imgdir = os.path.join(BUILD, 'watchface', 'fprj', 'app', 'images') + for sub, src in (('pay', os.path.join(PROJ, 'blob_' + role)), + ('ui', os.path.join(PROJ, 'ui_images'))): + if not os.path.isdir(src): + raise SystemExit('缺少 %s/ —— 先跑 tools/s5e_payload.py %s' % (src, role)) + dst = os.path.join(imgdir, sub) + if os.path.exists(dst): + shutil.rmtree(dst) + os.makedirs(dst) + for fn in sorted(os.listdir(src)): + shutil.copyfile(os.path.join(src, fn), os.path.join(dst, fn)) + + +def run_build(): + ps = os.path.join(BUILD, 'scripts', 'build_face.ps1') + # 显式给 -FaceName:vendor 脚本默认用 config 的 projectName 当输出名,而 projectName 现在 + # 是**显示名**(S5e Pt.2),交付文件名必须还是 NAME.face。 + r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps, + '-FaceName', NAME + '.face'], + capture_output=True, text=True, errors='replace') + workspace_fprj() # vendor 可能把 .fprj 改名成显示名,编完改回 NAME.fprj + out = ((r.stdout or '') + (r.stderr or '')).splitlines() + for l in [x for x in out if x.strip()][-4:]: + log(' | ' + l) + face = os.path.join(BUILD, 'bin', NAME + '.face') + if not os.path.exists(face): + raise SystemExit('编译失败:没有产出 %s' % face) + return open(face, 'rb').read() + + +def workspace_fprj(): + """把工作区里的 .fprj 归一成 NAME.fprj,并返回它的路径。 + + 我们写下去时就是 NAME.fprj,但 vendor 的 build_face.ps1 会先跑 + sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名** S5e Pt.2)找 + .fprj,找不到就把唯一那份改名过去(Title 也被改写成 projectName)。 + 编完这里再改回 NAME.fprj —— 工作区里始终只有一份 .fprj(放两份会被 Compiler.exe 并成 + 双倍资源,实测资源整体翻倍),文件名也保持项目标识不变。 + """ + d = os.path.join(BUILD, 'watchface', 'fprj') + fs = sorted(f for f in os.listdir(d) if f.endswith('.fprj')) + if len(fs) != 1: + raise SystemExit('watchface/fprj 下的 .fprj 应恰好 1 份,实际:%s' % fs) + want = os.path.join(d, NAME + '.fprj') + if fs[0] != NAME + '.fprj': + os.replace(os.path.join(d, fs[0]), want) + return want + + +def compile_raw(): + """裸 Compiler.exe —— 拿**忠实产物**(不经 vendor 的 set_face_id.ps1 改头)。 + + ★ 为什么必须多这一步(b5=10 的根因) + run_build() 走的是 vendor 的 build_face.ps1,它编完之后会调 + scripts/internal/set_face_id.ps1,那个脚本里有一句 + $bytes[5] = [byte]$faceIdSize # = 10 + 也就是把「id 字符串长度」写进了头里第 5 字节。固件自带表盘的 b5 是 0, + 交付物照着 0 走;所以最终产物必须取自**裸编译输出**(b5 由编译器给,实测 0), + 然后只把 40..49 覆盖成我们的 id,b5 一个字节都不碰。 + (s5e-icons 的 s5e_build.py 就是 compile_raw() + 补 id 这一套,真机验证过。) + """ + exe = os.path.join(BUILD, 'watchface', 'tools', 'Compiler.exe') + fprj = workspace_fprj() + out = os.path.join(BUILD, 'raw') + if os.path.exists(out): + shutil.rmtree(out) + os.makedirs(out) + r = subprocess.run([exe, '-b', fprj, out, NAME + '.face', WF_ID], + capture_output=True, text=True, errors='replace') + p = os.path.join(out, NAME + '.face') + if not os.path.exists(p): + raise SystemExit('裸编译失败:%s' % ((r.stdout or '') + (r.stderr or ''))[-400:]) + return open(p, 'rb').read() + + +def hits(face, blob): + out, s = [], 0 + while True: + i = face.find(blob, s) + if i < 0: + return out + out.append(i) + s = i + 1 + + +UI_NAMES = ['back', 'ic_help', 'ic_replace', 'ic_about', 'ic_check', 'ic_rollback', + 'ic_qq', 'logo'] + + +def locate(face, blobs, pay_blob, fp_blob): + """量出 UI 图片 / 负载 / 指纹在 .face 里的偏移,并逐条字节校验。""" + ui, bad = [], 0 + for name in UI_NAMES: + blob = open(os.path.join(PROJ, 'ui_images', name + '.bin'), 'rb').read() + h = hits(face, blob) + if len(h) != 1: + log(' !! UI %s 命中 %d 次(应为 1)' % (name, len(h))) + bad += 1 + if h: + ui.append((h[0], len(blob), name)) + if bad: + raise SystemExit('有 UI 资源无法唯一定位') + ml, mlf = blobs['magic_len'], blobs['magic_fp_len'] + if face.count(pay_blob[:ml]) != 1: + raise SystemExit('payload 魔数命中 %d 次(应为 1)' % face.count(pay_blob[:ml])) + if face.count(fp_blob[:mlf]) != 1: + raise SystemExit('fp 魔数命中 %d 次(应为 1)' % face.count(fp_blob[:mlf])) + pay_base = face.find(pay_blob) + fp_base = face.find(fp_blob) + if pay_base < 0: + raise SystemExit('payload 不是原样连续存放') + if fp_base < 0: + raise SystemExit('fp 不是原样连续存放') + # 用生成负载时的 sha256 逐条独立校验(避免"自己比自己"这种恒真检查) + for tag, base, path, key, mag in ( + ('pay', pay_base, os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'), + 'pay_sha', ml), + ('fp', fp_base, os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'fp_sha', mlf)): + data = open(path, 'rb').read() + if face.find(data) != base: + raise SystemExit('%s 文件不是原样连续存放' % tag) + for i, r in enumerate(blobs['records']): + res = base + mag + (r['rel'] if tag == 'pay' else r['fprel']) + ln = r['length'] if tag == 'pay' else r['fplen'] + seg = face[res:res + ln] + if len(seg) != ln or hashlib.sha256(seg).hexdigest() != r[key]: + raise SystemExit('%s[%d] 在 0x%x 处字节不吻合' % (tag, i, res)) + pay = [(pay_base + ml + r['rel'], r['target'], r['length']) for r in blobs['records']] + rb = [(fp_base + mlf + r['fprel'], r['target'], r['fplen']) for r in blobs['records']] + return ui, pay, rb, pay_base, fp_base + + +def main(): + tpl = open(SRC, encoding='utf-8').read() + for ph in ('__UIIMG__', '__PAY__', '__RB__', '__ROLE__'): + if ph not in tpl: + raise SystemExit('main.lua 里没有占位符 %s —— 它应该是源码模板' % ph) + log('=== %s(role=%s,id=%s)===' % (NAME, ROLE, WF_ID)) + blobs = json.load(open(os.path.join(PROJ, 'blob_' + ROLE, 'offsets.json'), + encoding='utf-8')) + for r in blobs['records']: + if 'pay_sha' not in r or 'fp_sha' not in r: + raise SystemExit('offsets.json 里没有 sha256 —— 请重跑 tools/s5e_payload.py %s' % ROLE) + pay_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'), 'rb').read() + fp_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'rb').read() + ml, mlf = blobs['magic_len'], blobs['magic_fp_len'] + + log('第 1 遍:用占位符编一次,量出资源在 .face 里的位置') + prepare(tpl, ROLE) + face1 = run_build() + log(' face %d bytes' % len(face1)) + ui, pay, rb, pay_base, fp_base = locate(face1, blobs, pay_blob, fp_blob) + for o, l, n in ui: + log(' %-12s off=0x%06x len=%d' % (n, o, l)) + log(' payload base=0x%06x(%d B) / fp base=0x%06x(%d B),共 %d 条记录' + % (pay_base, len(pay_blob), fp_base, len(fp_blob), len(pay))) + + ui_txt = '\n'.join(' {%d,%d,"%s"},' % (o, l, n) for o, l, n in ui) + pay_txt = ' pay = {\n' + '\n'.join(' {%d,%d,%d},' % t for t in pay) + '\n },' + rb_txt = ' rb = {\n' + '\n'.join(' {%d,%d,%d},' % t for t in rb) + '\n },' + resolved = (tpl.replace('__UIIMG__', ui_txt).replace('__PAY__', pay_txt) + .replace('__RB__', rb_txt).replace('__ROLE__', ROLE)) + assert '__UIIMG__' not in resolved and '__ROLE__' not in resolved + assert '__PAY__' not in resolved and '__RB__' not in resolved + os.makedirs(os.path.dirname(RESOLVED), exist_ok=True) + open(RESOLVED, 'w', encoding='utf-8', newline='\n').write(resolved) + log('第 2 遍:回填后的 Lua(%d bytes)重新编译' % len(resolved)) + # ★ 第 1 遍编的是**占位符版**;第 2 遍必须把回填后的 Lua 写回编译工作区再编, + # 否则交付物里还是 __PAY__/__RB__/__ROLE__(表盘在设备上直接"负载表为空")。 + write_lua(resolved) + face2 = run_build() + + # ---- 0 漂移校验 ---- + drift = 0 + ui2, pay2, rb2, pb2, fb2 = locate(face2, blobs, pay_blob, fp_blob) + if ui2 != ui: + log(' !! UI 偏移漂移'); drift += 1 + if (pb2, fb2) != (pay_base, fp_base): + log(' !! 负载偏移漂移'); drift += 1 + if (pay2, rb2) != (pay, rb): + log(' !! 负载表内容漂移'); drift += 1 + files = [(f, open(os.path.join(PROJ, RECDIR, f), 'rb').read()) + for f in sorted(os.listdir(os.path.join(PROJ, RECDIR))) if f.endswith('.bin')] + ok = sum(1 for off, tgt, ln in pay + if any(len(x) == ln and face2[off:off + ln] == x for _n, x in files)) + if ok != len(pay): + log(' !! pay 只有 %d/%d 条与 %s/ 吻合' % (ok, len(pay), RECDIR)); drift += 1 + log('偏移校验:%s' % ('全部吻合,0 漂移' if drift == 0 else '!! %d 处异常' % drift)) + + # ---- 最终产物:id 必须逐字节正确(b5=0,40..49 = id + NUL)---- + # 交付物取**裸编译**的忠实产物:b5 保持编译器给的值(实测 0),只覆盖 id 那 10 字节。 + # (vendor 的 build_face.ps1 会顺手把 b5 写成 id 长度 10,不能拿它当交付物。) + raw = compile_raw() + if raw[0:4] != b'\x5a\xa5\x34\x12': + raise SystemExit('bad face magic') + nd = [i for i in range(min(len(raw), len(face2))) if raw[i] != face2[i]] + stray = [i for i in nd if not (i == 5 or 40 <= i < 50)] + if len(raw) != len(face2) or stray: + raise SystemExit('忠实产物与 toolchain 产物不是"只差 b5 + id":' + 'len %d vs %d,差异 %d 字节,越界位置 %s' + % (len(raw), len(face2), len(nd), stray[:8])) + if raw[5] != 0: + raise SystemExit('裸编译产物的 b5 = %d(期望 0)—— 设备表盘的约定变了,得重查' % raw[5]) + d = bytearray(raw) + d[40:50] = WF_ID.encode() + b'\x00' * (10 - len(WF_ID)) + os.makedirs(DIST, exist_ok=True) + out = os.path.join(DIST, NAME + '.face') + open(out, 'wb').write(bytes(d)) + a32, = struct.unpack('8 且离圆心 >240 的像素都算越界。 +""" +import os, sys, math +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import s5e_render_ui as R +from s5e_pages import PAGES + +W = H = 480 +CX = CY = 240.0 +RAD = 240.0 + +print('%-10s %8s %11s worst point' % ('page', 'max r', 'outside px')) +print('-' * 62) +bad = 0 +for name, items in PAGES.items(): + im = R.render_raw(items) + px = im.load() + worst, wpt, outside = 0.0, None, 0 + for y in range(H): + for x in range(W): + if px[x, y][3] > 8: + dd = math.hypot(x - CX, y - CY) + if dd > worst: + worst, wpt = dd, (x, y) + if dd > RAD: + outside += 1 + print('%-10s %8.1f %11d %s%s' % (name, worst, outside, wpt, + '' if worst <= RAD else ' <-- CLIPPED')) + if outside: + bad += 1 +print('-' * 62) +print('圆心 (240,240) 半径 240 画布 480x480 越界页数:%d' % bad) +if R.OVERFLOW: + print('!! 文本溢出框 %d 处:' % len(R.OVERFLOW)) + for o in R.OVERFLOW: + print(' ', o) +sys.exit(1 if (bad or R.OVERFLOW) else 0) diff --git a/s5e-pressure-anim/tools/s5e_lua_static.py b/s5e-pressure-anim/tools/s5e_lua_static.py new file mode 100644 index 0000000..adab1d0 --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_lua_static.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""main.lua 的静态体检 —— 编译前把「真机上才会炸」的几类错抓出来。 + +S4e 那版的真机事故是:`segs/ddWrite/ddRead` 被放在 `slurp/sh` **之前**, +于是函数体里引用的 `sh` 不是那个 local,而是**全局 nil**,一调用就 +`attempt to call a nil value (global 'sh')` —— 而且当时整条流水线全绿 +(Compiler.exe 只打包 Lua、不解析它)。所以本文件专门查: + + ① 块配平(function/if/for/while/do/end/repeat/until) + ② **先用后声明**:某个名字在文件里是 `local`,但第一次调用发生在它声明之前 + ③ 调用了既不是 local、也不在 Lua 标准库白名单里的名字(拼写错 / 忘了 local) + ④ 模板约定:4 个占位符各 1 次、`--[[` 只在文件头、以 `return ui\\n` 结尾 + (tools/s5e_verify.py 会从 .face 里反解 Lua,这几条是它定位 Lua 的前提) + +用法:python tools/s5e_lua_static.py [main.lua] +""" +import os, re, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import PROJ +from lua_balance import strip + +LUAC = os.path.join(PROJ, 'main.lua') + +# Lua 5.4 标准库 + 本项目用到的宿主全局(都不是拼写错) +WHITELIST = set(""" +require pcall xpcall type pairs ipairs next select tostring tonumber rawget rawset +rawequal rawlen setmetatable getmetatable error assert print unpack load +string table math io os coroutine utf8 debug collectgarbage +""".split()) +KEYWORDS = set(""" +and break do else elseif end false for function goto if in local nil not or +repeat return then true until while +""".split()) +REQUIRED_PH = ('__UIIMG__', '__PAY__', '__RB__', '__ROLE__') + +fail = 0 + + +def bad(msg): + global fail + fail += 1 + print(' FAIL ' + msg) + + +def ck(cond, msg): + print((' OK ' if cond else ' FAIL ') + msg) + if not cond: + global fail + fail += 1 + + +def lineno(src, idx): + return src.count('\n', 0, idx) + 1 + + +def decls(src, code): + """名字 -> 它在 code 里第一次被 local 声明(或作为函数参数出现)的位置。""" + out = {} + for m in re.finditer(r'\blocal\s+function\s+([A-Za-z_]\w*)', code): + out.setdefault(m.group(1), m.start(1)) + for m in re.finditer(r'\blocal\s+([A-Za-z_][\w,\s]*?)\s*(?:=|$)', code, re.M): + base = m.start(1) + for piece in m.group(1).split(','): + nm = piece.strip() + if re.fullmatch(r'[A-Za-z_]\w*', nm): + out.setdefault(nm, base) + # 函数参数也是 local(`function(a, b)` / `function f(a)`),否则会误报 + for m in re.finditer(r'\bfunction\b\s*[A-Za-z_.:]*\s*\(([^)]*)\)', code): + for piece in m.group(1).split(','): + nm = piece.strip() + if re.fullmatch(r'[A-Za-z_]\w*', nm): + out.setdefault(nm, m.start()) + return out + + +def calls(code): + """所有函数调用点(排除 `function NAME(` 这种定义)。""" + out = [] + for m in re.finditer(r'(? /dev/health:%s' % (m.group(1).strip() if m else None)) + ck("local DEV = DEVS.health" in src, + 'DEV 来自 DEVS.health(表里的键名真的被用于取节点,不是摆设)') + paytbl = re.search(r'local PAYTBL = \{\n__PAY__\n__RB__\n\}', src) + ck(paytbl is not None, 'PAYTBL 里只有 __PAY__ / __RB__ 两处占位符(没有硬编码节点名)') + ck('466' not in src, '没有残留的 466(旧 S4e 尺寸)') + + print('\n=== %s ===' % ('全部通过' if not fail else '%d 项失败' % fail)) + return 1 if fail else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/s5e-pressure-anim/tools/s5e_overlay.py b/s5e-pressure-anim/tools/s5e_overlay.py new file mode 100644 index 0000000..62c2e67 --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_overlay.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""为官方 LuaDevTemplate 生成可编译的 overlay。 + +两份盘(role): + replace -> S5ePAnim 负载 = q63 的帧 + restore -> S5ePAnimRestore 负载 = 原厂字节 +两份盘**共用同一个 watchface id**(462150102):装一份就顶掉另一份,设备上只占一份空间。 + +要点(都是踩过的坑): + * preview.png 必须是 326x326 —— Compiler.exe 对 DeviceType 462 只收这个尺寸 + * fprj 必须是 UTF-16(带 BOM)且用 CRLF,否则 Compiler.exe 直接拒绝 + * 表盘界面是 480x480(Screen Width/Height),预览图只是表盘列表里的缩略图 + * 负载只放 blob_/ 那一份「合并后的单条目」 +""" +import os, sys, json, shutil +from PIL import Image +from paths import PROJ, WORK, SCREEN, PREVIEW, WF_ID, DEVICE_TYPE +from s5e_pages import PAGES +import s5e_render_ui as R + +ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() +assert ROLE in ('replace', 'restore'), ROLE +NAME = 'S5ePAnim' if ROLE == 'replace' else 'S5ePAnimRestore' # 路径/标识(不要改) +TITLE = 'S5e Pt.2' # 手表上显示的表盘名(两份盘同一个名字)= fprj 的 +OUT = os.path.join(WORK, 'overlay') +ENTRY_WIDGET = 'app_lua%2Fmain.lua' + + +def make_preview(path): + """480 首页 -> 圆形 alpha -> 326x326(Compiler.exe 只收这个尺寸)。""" + R.W = R.H = SCREEN + img = R.render_raw(PAGES['home']) + base = Image.new('RGBA', (SCREEN, SCREEN), (0, 0, 0, 255)) + base.alpha_composite(img) + mask = Image.new('L', (SCREEN, SCREEN), 0) + from PIL import ImageDraw + ImageDraw.Draw(mask).ellipse([0, 0, SCREEN - 1, SCREEN - 1], fill=255) + base.putalpha(mask) + os.makedirs(os.path.dirname(path), exist_ok=True) + base.resize((PREVIEW, PREVIEW), Image.LANCZOS).save(path) + + +def write_fprj(path, title): + xml = ('\r\n' + '\r\n' + ' \r\n' + ' \r\n' + ' \r\n\r\n' + % (DEVICE_TYPE, title, ENTRY_WIDGET, SCREEN, SCREEN)) + open(path, 'w', encoding='utf-16', newline='').write(xml) + + +def build(name, role): + base = os.path.join(OUT, name) + if os.path.exists(base): + shutil.rmtree(base) + fprj = os.path.join(base, 'watchface', 'fprj') + os.makedirs(os.path.join(fprj, 'images'), exist_ok=True) + os.makedirs(os.path.join(fprj, 'app', 'lua'), exist_ok=True) + + cfg = {"projectName": TITLE, "watchfaceId": WF_ID, "power_consumption": "3", + "resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE", + "input": "watchface/fprj/images/preview.png", "name": "preview"}} + json.dump(cfg, open(os.path.join(base, 'watchface.config.json'), 'w', encoding='utf-8'), + indent=4, ensure_ascii=False) + write_fprj(os.path.join(fprj, name + '.fprj'), TITLE) + make_preview(os.path.join(fprj, 'images', 'preview.png')) + shutil.copyfile(os.path.join(PROJ, 'main.lua'), + os.path.join(fprj, 'app', 'lua', 'main.lua')) + blob = os.path.join(PROJ, 'blob_' + role) + if not os.path.isdir(blob): + raise SystemExit('缺少 blob_%s/ —— 先跑 tools/s5e_payload.py %s' % (role, role)) + for src, rel in ((blob, 'pay'), (os.path.join(PROJ, 'ui_images'), 'ui')): + dst = os.path.join(fprj, 'app', 'images', rel) + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree(src, dst) + return base + + +if __name__ == '__main__': + build(NAME, ROLE) + print('overlay written to', os.path.join(OUT, NAME)) diff --git a/s5e-pressure-anim/tools/s5e_pages.py b/s5e-pressure-anim/tools/s5e_pages.py new file mode 100644 index 0000000..1ab8225 --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_pages.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""Xiaomi Watch S5 eSIM (p62lte) 480x480 页面规格。 + +坐标系就是 `main.lua` 用的那一套;`s5e_render_ui.py` / `s5e_check_clip.py` 画的是同一份规格, +所以预览图是"诚实"的(不是另画一套)。与 S5 图标包那份 480 设计稿同一套语言, +只是页面文案改成压力动画替换器。 +""" +import os + +from paths import PROJ, DOCS, ASSETS +W = H = 480 +BG = (0, 0, 0) +GRAY = '999999' +WHITE = 'ffffff' +PLATE = '002927' +CIRCLE = '4f4f4f' +OK = '43cf7c' +ERR = 'd43030' +S600, S500, S400 = 'semibold', 'medium', 'regular' + +# 列表项几何 +# radius=40 不是审美选择:y=88 那一行离圆心 152px,圆屏在那里的弦半宽只有 √(240²-152²)=185.8, +# 也就是该行 x 只能在 54.2..425.8 之内。底板是 x=50 w=379,圆角不够大时四个角会越过圆边 +# (r=35 时角点半径 240.7 → check_clip 记为越界)。r=40 正好把角收进去。 +ITEM_X, ITEM_W, ITEM_H, ITEM_R = 50, 379, 91, 40 +ICONC_DX, ICONC_DY, ICONC_SZ = 21, 20, 52 +ICON_DX, ICON_DY, ICON_SZ = 31, 30, 31 +TITLE_DX, TITLE_DY, TITLE_W, TITLE_H = 82, 26, 214, 42 +ICON_PNG = {'ic_help': 'MdiLightbulbOutline.png', 'ic_replace': 'MdiBandage.png', + 'ic_about': 'MdiInformationOutline.png', 'ic_check': 'MdiClipboardTextSearchOutline.png', + 'ic_rollback': 'MdiReplyAllOutline.png', 'ic_qq': 'qq.png'} + + +def item(y, iconkey, title): + return [ + dict(kind='rect', x=ITEM_X, y=y, w=ITEM_W, h=ITEM_H, bg=PLATE, radius=ITEM_R), + dict(kind='circle', x=ITEM_X + ICONC_DX, y=y + ICONC_DY, w=ICONC_SZ, h=ICONC_SZ, bg=CIRCLE), + dict(kind='image', x=ITEM_X + ICON_DX, y=y + ICON_DY, w=ICON_SZ, h=ICON_SZ, + src=os.path.join(ASSETS, ICON_PNG[iconkey])), + dict(kind='label', x=ITEM_X + TITLE_DX, y=y + TITLE_DY, w=TITLE_W, h=TITLE_H, + text=title, size=29, weight=S500, color=WHITE, align='left', vtop=True), + ] + + +def clock(t='10:24'): + return dict(kind='label', x=201, y=4, w=78, h=40, text=t, size=29, + weight=S600, color=GRAY, align='left', vtop=True) + + +def title(text, x, w=260): + return dict(kind='label', x=x, y=36, w=w, h=42, text=text, size=31, + weight=S500, color=WHITE, align='left', vtop=True) + + +def back_arrow(x=167): + return dict(kind='image', x=x, y=44, w=18, h=29, src=os.path.join(ASSETS, 'backarrow.png')) + + +def big_button(y, text): + return [ + dict(kind='rect', x=111, y=y, w=258, h=82, bg=PLATE, radius=41), + dict(kind='label', x=182, y=y + 22, w=126, h=40, text=text, size=29, + weight=S500, color=WHITE, align='left', vtop=True), + ] + + +# ------------------------------------------------------------------ 页面 +# 所有文字的像素宽度都用真 MiSans 量过(tools/s5e_render_ui.py 同一套字体): +# 31px: 'S5e Pt.2'=125 '快速帮助'/'开始替换'/'正在替换'=124 '关于'=62 +# 29px: '确认替换'/'重启手表'=116 '环境预检查'=145 '732681995'=155 +# 25px: '检查 health 分区与负载完整性。'=369 '恢复 119 帧动画的原厂样式。'=331 +# '预检查通过 | 119 条 | 15.49MB'=349 'Xiaomi Watch S5 eSIM 46mm'=367 +# 圆屏在高度 y 处的弦半宽 = √(240² - (y-240)²),所以**越靠下的行越窄** —— +# 型号那行原来放在 y=229(弦半宽只有 171),'Xiaomi Watch S5 eSIM 46mm' 宽 367 直接被切掉。 +# 现在把该行拆成两行、整体上移到 y=195 / 228,两行都短于那里的弦。 +PAGE_HOME = [clock(), title('S5e Pt.2', 177, 127), + *item(88, 'ic_help', '快速帮助'), + *item(186, 'ic_replace', '开始替换'), + *item(285, 'ic_about', '关于')] + +_HELP_HEAD = [clock(), back_arrow(167), title('快速帮助', 188, 124)] +# 说明文字必须**单行**放下(折行后第 2 行会越出圆屏,check_clip 抓过): +# y=182 处圆屏弦半宽 = √(240²-58²) = 232.9,x 从 64 起时最多能到 64+232.9 = 296.9... +# 实际用 380 的框宽是安全的(文字宽 354px < 380,且落在 64..444 内)。 +_HELP_D1 = dict(kind='label', x=64, y=182, w=380, h=35, + text='检查 health 分区与负载完整性。', size=24, + weight=S500, color=GRAY, align='left', vtop=True) +_HELP_I2 = item(264, 'ic_rollback', '回滚修改') +_HELP_D2 = dict(kind='label', x=67, y=360, w=348, h=35, + text='恢复 119 帧动画的原厂样式。', size=25, + weight=S500, color=GRAY, align='left', vtop=True) +PAGE_HELP = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [_HELP_D1] + _HELP_I2 + [_HELP_D2] + +# 预检查结果:最多 2 行(24px 行高 33,框高 76 >= 2*33) +HELP_DESC_OK = dict(kind='label', x=67, y=182, w=348, h=76, + text='预检查通过 | 119 条 | 15.49MB\nhealth 分区可读。', + size=24, weight=S500, color=OK, align='left', vtop=True) +HELP_DESC_ERR = dict(kind='label', x=67, y=182, w=348, h=76, + text='预检查失败:设备节点读不到,\n或分区头不是 ROMFS。', + size=24, weight=S500, color=ERR, align='left', vtop=True) +PAGE_HELP_OK = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_OK] + _HELP_I2 + [_HELP_D2] +PAGE_HELP_ERR = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_ERR] + _HELP_I2 + [_HELP_D2] + +# 警告页:每行单独一个框(h=34 ≈ 25px 真行高 35),行距 34。 +# 最下面那行放在 y=341:圆屏在 y=375 处正好收成一点,所以底边必须留在 375 以上。 +WARN_LABELS = [ + dict(kind='label', x=60, y=95, w=360, h=35, + text='替换压力检测动画之前,', size=25, weight=S500, color=GRAY, + align='left', vtop=True), + dict(kind='label', x=60, y=129, w=360, h=35, + text='请先确认自己的系统版本号为 ', size=25, weight=S500, color=GRAY, align='left', vtop=True), + dict(kind='label', x=304, y=129, w=120, h=35, text='3.112.035', + size=25, weight=S500, color=WHITE, align='left', vtop=True), + dict(kind='label', x=422, y=129, w=40, h=35, text='!', + size=25, weight=S500, color=GRAY, align='left', vtop=True), + # 型号拆成两行:y=195 处弦半宽 195,y=228 处 173 —— 367px 的长行只能拆 + dict(kind='label', x=64, y=195, w=360, h=35, text='请确认自己的手表型号为', + size=25, weight=S500, color=GRAY, align='left', vtop=True), + dict(kind='label', x=64, y=229, w=360, h=35, text='Xiaomi Watch S5', + size=25, weight=S500, color=WHITE, align='left', vtop=True), + dict(kind='label', x=64, y=263, w=360, h=35, text='eSIM 46mm(p62lte)', + size=25, weight=S500, color=WHITE, align='left', vtop=True), + dict(kind='label', x=60, y=341, w=360, h=35, text='搞机有风险,变砖后果自负。', + size=25, weight=S500, color=GRAY, align='left', vtop=True), +] +PAGE_CONFIRM = [clock(), back_arrow(167), title('开始替换', 188, 124)] + WARN_LABELS + big_button(376, '确认替换') + +LOG_MID = ('> 开始替换\n' + '> health 119 条\n' + '> 写入 60/119 成 60 失 0\n' + '> 替换完毕,请重启手表。\n' + '> 若发现问题,请回滚修改。') +PAGE_RUNNING = [clock(), title('正在替换', 178, 124), + dict(kind='label', x=67, y=95, w=348, h=280, text=LOG_MID, size=24, + weight=S500, color=WHITE, align='left', vtop=True), + *big_button(376, '重启手表')] + +PAGE_ABOUT = [ + clock(), back_arrow(198), title('关于', 220, 62), + dict(kind='rect', x=54, y=81, w=373, h=210, bg=PLATE, radius=31), + dict(kind='image', x=200, y=111, w=80, h=80, src=os.path.join(DOCS, 'logo_preview_480.png')), + dict(kind='label', x=181, y=200, w=120, h=40, text='S5e Pt.2', + size=29, weight=S500, color=WHITE, align='left', vtop=True), + dict(kind='label', x=222, y=237, w=49, h=28, text='1.0.0', + size=21, weight=S500, color=GRAY, align='left', vtop=True), + *item(300, 'ic_qq', '732681995'), +] + +PAGES = {'home': PAGE_HOME, 'help': PAGE_HELP, 'help_ok': PAGE_HELP_OK, 'help_err': PAGE_HELP_ERR, + 'confirm': PAGE_CONFIRM, 'running': PAGE_RUNNING, 'about': PAGE_ABOUT} diff --git a/s5e-pressure-anim/tools/s5e_payload.py b/s5e-pressure-anim/tools/s5e_payload.py new file mode 100644 index 0000000..3e65f6b --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_payload.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""按「角色」切负载:replace(只负责替换)/ restore(只负责还原)。 + +为什么拆成两张盘 +---------------- +「负载 + 回滚」合在一张盘 = 两份 15.5 MB ≈ 31 MB,超过 .face 的体积上限。 +拆开之后每张盘只装一份负载(≈15.5 MB)。 + +拆盘后版本校验怎么办 +-------------------- +每张盘再带一张**指纹表**:每条记录存「另一侧字节的前 64 B」。 + * replace 盘:pay = q63 的帧(新字节);fp = **原厂字节**前 64 B + * restore 盘:pay = 原厂字节; fp = **新字节**前 64 B +于是「写入前版本校验」和「写入后回读比对」两道保险都还在,代价只有 119*64 ≈ 7.4 KB。 + +关键约定(S4e 那版踩过的坑) +---------------------------- +Lua 表里存的是**分区键名**('health'),运行期才由 `DEVS[键]` 拼成 `/dev/health`。 +若把 '/dev/health' 直接存进表里,拼出来就是 `if=nil` 或 `if=/dev//dev/health`。 + +产出 +---- + payload/ NNN_.bin + payload.lua (replace 盘的负载) + payload_restore/ NNN_.bin + payload.lua (restore 盘的负载) + blob_replace/ payload.bin(魔数 + 顺序拼接)+ fp.bin + offsets.json + blob_restore/ 同上 + payload.json 机器可读清单 +""" +import os, sys, json, hashlib, struct +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import PROJ, WORK, TARGET_IMAGE + +ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() +assert ROLE in ('replace', 'restore'), ROLE +PATCHED = os.path.join(WORK, 'anim', 'vela_health_anim.bin') +OUT = PROJ +DEV = 'health' # ← Lua 表里的键名(不是 '/dev/health') +FP_LEN = 64 + +recs_in = json.load(open(os.path.join(WORK, 'anim', 'manifest.json'), encoding='utf-8')) +stock = open(TARGET_IMAGE, 'rb').read() +patched = open(PATCHED, 'rb').read() +assert len(stock) == len(patched), '改后镜像长度变了 —— 等长覆盖的前提被破坏' +assert stock[:8] == b'-rom1fs-' + +SRC = patched if ROLE == 'replace' else stock # 要写进设备的字节 +OTHER = stock if ROLE == 'replace' else patched # 另一侧字节(做指纹) +FPDIR = os.path.join(OUT, 'payload' if ROLE == 'replace' else 'payload_restore') +BLOBDIR = os.path.join(OUT, 'blob_' + ROLE) + +records = [] +for r in recs_in['records']: + off, ln = r['offset'], r['length'] + records.append(dict(name=r['name'], offset=off, length=ln, data=SRC[off:off + ln], + changed=(stock[off:off + ln] != patched[off:off + ln]), + fp=OTHER[off:off + FP_LEN])) +# replace 盘跳过「本来就没变」的记录(省体积);restore 盘一条都不能少 +emitted = [r for r in records if (r['changed'] or ROLE == 'restore')] +dropped = [r for r in records if r not in emitted] +for r in emitted: + assert len(r['data']) == r['length'], r['name'] + assert len(r['fp']) == FP_LEN, r['name'] + + +def write_pack(d): + os.makedirs(d, exist_ok=True) + for f in os.listdir(d): + os.remove(os.path.join(d, f)) + rows = [] + for i, r in enumerate(emitted): + fn = '%03d_%s.bin' % (i, r['name']) + open(os.path.join(d, fn), 'wb').write(r['data']) + assert r['offset'] % 4 == 0, r['name'] + rows.append(dict(name=r['name'], file=fn, offset=r['offset'], length=r['length'])) + with open(os.path.join(d, 'payload.lua'), 'w', encoding='utf-8', newline='\n') as f: + f.write('-- generated by tools/s5e_payload.py (%s) -- do not hand-edit\n' % ROLE) + f.write('-- dd: -if / -of /dev/%s bs=4 seek=offset/4 count=length/4\n' % DEV) + f.write('return {\n') + for r in rows: + f.write(' { name = "%s", file = "%s", offset = %d, length = %d },\n' + % (r['name'], r['file'], r['offset'], r['length'])) + f.write('}\n') + return rows + + +lua_rows = write_pack(FPDIR) + +# 魔数必须全局唯一,且**长度不要手写常量**(S4e/S5e 图标包都因为手写成 16 导致偏移差 1) +MAGIC_PAY = b'\x00S5P-PAYv1\x00\xa5\x5a\xc3\x3c' +MAGIC_FP = b'\x00S5P-FPv1\x00\x5a\xa5\x3c\xc3' +os.makedirs(BLOBDIR, exist_ok=True) +for f in os.listdir(BLOBDIR): + os.remove(os.path.join(BLOBDIR, f)) +pay_blob = MAGIC_PAY + b''.join(r['data'] for r in emitted) +fp_blob = MAGIC_FP + b''.join(r['fp'] for r in emitted) +open(os.path.join(BLOBDIR, 'payload.bin'), 'wb').write(pay_blob) +open(os.path.join(BLOBDIR, 'fp.bin'), 'wb').write(fp_blob) +rel, off, fpoff = [], 0, 0 +for r in emitted: + # 每条都存独立 sha256:.face 构建时用它校验"确实原样连续存放", + # 而不是拿 .face 跟自己比(那种检查恒真、抓不到 bug) + rel.append(dict(name=r['name'], rel=off, fprel=fpoff, target=r['offset'], + length=r['length'], fplen=len(r['fp']), + pay_sha=hashlib.sha256(pay_blob[len(MAGIC_PAY) + off: + len(MAGIC_PAY) + off + r['length']]).hexdigest(), + fp_sha=hashlib.sha256(fp_blob[len(MAGIC_FP) + fpoff: + len(MAGIC_FP) + fpoff + len(r['fp'])]).hexdigest())) + off += r['length'] + fpoff += len(r['fp']) +assert fpoff == len(fp_blob) - len(MAGIC_FP), (fpoff, len(fp_blob)) +assert off == len(pay_blob) - len(MAGIC_PAY), (off, len(pay_blob)) +json.dump(dict(role=ROLE, device=DEV, magic_len=len(MAGIC_PAY), magic_fp_len=len(MAGIC_FP), + fp_len=FP_LEN, records=rel, pay_bytes=len(pay_blob), fp_bytes=len(fp_blob), + dropped_identical=[r['name'] for r in dropped]), + open(os.path.join(BLOBDIR, 'offsets.json'), 'w', encoding='utf-8'), indent=1) +print('blob_%s/ 单条目: payload.bin %d B + fp.bin %d B(%d 条记录,跳过 %d 条未变)' + % (ROLE, len(pay_blob), len(fp_blob), len(rel), len(dropped))) + +json.dump(dict(device='Xiaomi Watch S5 eSIM 46mm (p62lte)', target='/dev/' + DEV, dev_key=DEV, + partition='vela_health.bin', role=ROLE, kind='pressure-measure-anim', + dd='dd if=/ of=/dev/%s bs=4 seek= count=' % DEV, + block_size=4, records=lua_rows, + dropped_identical=[r['name'] for r in dropped]), + open(os.path.join(OUT, 'payload.json'), 'w', encoding='utf-8'), indent=1, + ensure_ascii=False) +total = sum(r['length'] for r in lua_rows) +print('role=%s: emitted %d records, %.2f MB; dropped %d' + % (ROLE, len(lua_rows), total / 1048576, len(dropped))) diff --git a/s5e-pressure-anim/tools/s5e_render_ui.py b/s5e-pressure-anim/tools/s5e_render_ui.py new file mode 100644 index 0000000..94b479d --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_render_ui.py @@ -0,0 +1,196 @@ +"""按 480x480 页面规格渲染预览图(真 MiSans 字体 + 真素材 + 圆形遮罩)。 + +支持 kind: rect / circle / image / label +label 支持 align / vtop / vcenter / lh,以及 LVGL 的 recolor 写法 "#RRGGBB 文字#" +""" +import os, re, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from PIL import Image, ImageDraw, ImageFont + +from paths import PROJ, INPUTS, FONTS, WORK +ROOT = PROJ +FONT_FILES = {'semibold': 'MiSans-Semibold.ttf', 'medium': 'MiSans-Medium.ttf', + 'regular': 'MiSans-Regular.ttf'} +W = H = 480 +_fc = {} + + +def _font_from_firmware(name): + """从 inputs/target/vela_font.bin 里现取 MiSans(固件里就自带这几个 ttf)。""" + p = os.path.join(INPUTS, 'target', 'vela_font.bin') + if not os.path.exists(p): + return None + try: + from romfs import Romfs + r = Romfs(p) + except Exception: + return None + for path, ino in r.entries(): + if path == name: + d = os.path.join(WORK, 'fonts') + os.makedirs(d, exist_ok=True) + out = os.path.join(d, name) + open(out, 'wb').write(r.read(ino)) + return out + return None + + +def font(weight, size): + k = (weight, size) + if k in _fc: + return _fc[k] + name = FONT_FILES.get(weight, FONT_FILES['medium']) + p = os.path.join(FONTS, name) + if not os.path.exists(p): + p = _font_from_firmware(name) or p + if not os.path.exists(p): + for alt in (r'C:\Windows\Fonts\msyh.ttc', r'C:\Windows\Fonts\msyhbd.ttc', + r'C:\Windows\Fonts\arial.ttf'): + if os.path.exists(alt): + p = alt + print(' [warn] %s 缺失,预览改用 %s(只影响预览缩略图)' + % (name, os.path.basename(alt))) + break + _fc[k] = ImageFont.truetype(p, size) if os.path.exists(p) else ImageFont.load_default() + return _fc[k] + + +def _rgb(c): + return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4)) + + +def split_recolor(text, base): + runs, buf, cur, incol = [], '', base, False + i = 0 + while i < len(text): + if text[i] == '#': + if re.match(r'[0-9a-fA-F]{6}', text[i + 1:i + 7]): + runs.append((cur, buf)); buf = '' + cur = text[i + 1:i + 7].lower(); incol = True + i += 7 + continue + if incol: + runs.append((cur, buf)); buf = '' + cur = base; incol = False + i += 1 + continue + buf += text[i]; i += 1 + runs.append((cur, buf)) + return runs + + +def wrap_runs(d, runs, f, maxw): + lines = [[]] + for color, chunk in runs: + for k, seg in enumerate(chunk.split('\n')): + if k: + lines.append([]) + if not seg: + continue + cur = '' + for ch in seg: + if d.textlength(cur + ch, font=f) > maxw and cur: + lines[-1].append((color, cur)) + lines.append([]) + cur = ch + else: + cur += ch + if cur: + lines[-1].append((color, cur)) + return lines + + +OVERFLOW = [] # (文本前 14 字, lh, 行数, 块高, 框高) + + +def draw_label(img, d, it): + f = font(it.get('weight', 'medium'), it['size']) + runs = split_recolor(it['text'], it.get('color', 'ffffff')) \ + if it.get('recolor') else [(it.get('color', 'ffffff'), it['text'])] + asc, desc = f.getmetrics() + natural = asc + desc + lh = max(it.get('lh', 0), natural) + lines = wrap_runs(d, runs, f, it['w']) + total = lh * len(lines) + if total > it['h'] + 0.5: + OVERFLOW.append((it['text'][:14].replace('\n', '/'), round(it.get('lh', 0), 2), + len(lines), round(total, 1), it['h'], round(natural, 2))) + if it.get('vcenter'): + y = it['y'] + (it['h'] - total) / 2 + elif it.get('vtop'): + y = it['y'] + else: + y = it['y'] + (it['h'] - total) / 2 + for ln in lines: + wsum = sum(d.textlength(t, font=f) for _, t in ln) + if it.get('align') == 'center': + x = it['x'] + (it['w'] - wsum) / 2 + elif it.get('align') == 'right': + x = it['x'] + it['w'] - wsum + else: + x = it['x'] + for color, t in ln: + d.text((x, y), t, font=f, fill=_rgb(color) + (255,)) + x += d.textlength(t, font=f) + y += lh + + +def render_raw(items): + """画成 RGBA(不遮罩、不铺黑底)—— 切边检查就用这个。""" + img = Image.new('RGBA', (W, H), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + for it in items: + k = it.get('kind') + if k == 'rect': + box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1] + r = it.get('radius', 0) + if r: + d.rounded_rectangle(box, radius=r, fill=_rgb(it['bg']) + (255,)) + else: + d.rectangle(box, fill=_rgb(it['bg']) + (255,)) + elif k == 'circle': + box = [it['x'], it['y'], it['x'] + it['w'] - 1, it['y'] + it['h'] - 1] + d.ellipse(box, fill=_rgb(it['bg']) + (255,)) + elif k == 'image': + p = it['src'] + if os.path.exists(p): + im = Image.open(p).convert('RGBA').resize((it['w'], it['h']), Image.LANCZOS) + img.alpha_composite(im, (it['x'], it['y'])) + elif k == 'label': + draw_label(img, d, it) + return img + + +def render(items, out, mask=True, ring=False): + img = render_raw(items) + if mask: + m = Image.new('L', (W, H), 0) + ImageDraw.Draw(m).ellipse([0, 0, W - 1, H - 1], fill=255) + img.putalpha(m) + base = Image.new('RGB', (W, H), (0, 0, 0)) + base.paste(img, (0, 0), img) + if ring: + ImageDraw.Draw(base).ellipse([0, 0, W - 1, H - 1], outline=(80, 80, 90)) + os.makedirs(os.path.dirname(out), exist_ok=True) + base.save(out) + return out + + +if __name__ == '__main__': + from s5e_pages import PAGES + outs = [] + for name, items in PAGES.items(): + p = os.path.join(ROOT, 'docs', 'ui_%s.png' % name) + render(items, p) + outs.append(p) + print('wrote', p) + cols = 3 + rows = (len(outs) + cols - 1) // cols + sheet = Image.new('RGB', (cols * (W + 12) + 12, rows * (H + 12) + 12), (28, 28, 32)) + for i, p in enumerate(outs): + sheet.paste(Image.open(p), (12 + (i % cols) * (W + 12), 12 + (i // cols) * (H + 12))) + sp = os.path.join(ROOT, 'docs', 'ui_all.png') + sheet.save(sp) + print('wrote', sp, sheet.size) + if OVERFLOW: + print('!! 文本溢出 %d 处:%s' % (len(OVERFLOW), OVERFLOW)) diff --git a/s5e-pressure-anim/tools/s5e_ui_images.py b/s5e-pressure-anim/tools/s5e_ui_images.py new file mode 100644 index 0000000..c8087ec --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_ui_images.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""UI 素材 PNG -> LVGL v9 .bin(480x480 表盘用)+ 80px 圆形 logo。 + +格式(真机验证过):12 B 头 magic=0x19|cf|flags|w|h|stride|rsv + BGRA 像素。 +编码器复用 iconpack.encode(与图标包同一套)。 +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import iconpack as ip +from PIL import Image, ImageDraw +from paths import PROJ, DOCS, ASSETS + +OUT = os.path.join(PROJ, 'ui_images') +os.makedirs(OUT, exist_ok=True) + +SPEC = [('back', 'backarrow.png', 18, 29), + ('ic_help', 'MdiLightbulbOutline.png', 31, 31), + ('ic_replace', 'MdiBandage.png', 31, 31), + ('ic_about', 'MdiInformationOutline.png', 31, 31), + ('ic_check', 'MdiClipboardTextSearchOutline.png', 31, 31), + ('ic_rollback', 'MdiReplyAllOutline.png', 31, 31), + ('ic_qq', 'qq.png', 31, 31)] +for name, png, w, h in SPEC: + p = os.path.join(ASSETS, png) + if not os.path.exists(p): + print(' missing', png) + continue + img = Image.open(p).convert('RGBA') + if img.size != (w, h): + img = img.resize((w, h), Image.LANCZOS) + blob = ip.encode(img, ip.CF_ARGB8888, w, h, 0) + open(os.path.join(OUT, name + '.bin'), 'wb').write(blob) + print(' %-12s <- %-34s %dx%d %d bytes' % (name, png, w, h, len(blob))) + +L = 80 +img = Image.open(os.path.join(ASSETS, 'cover.png')).convert('RGBA').resize((L, L), Image.LANCZOS) +mask = Image.new('L', (L, L), 0) +ImageDraw.Draw(mask).ellipse([0, 0, L - 1, L - 1], fill=255) +img.putalpha(mask) +open(os.path.join(OUT, 'logo.bin'), 'wb').write(ip.encode(img, ip.CF_ARGB8888, L, L, 0)) +os.makedirs(DOCS, exist_ok=True) +img.save(os.path.join(DOCS, 'logo_preview_480.png')) +print(' %-12s <- cover.png(circular) %dx%d' % ('logo', L, L)) +print('->', OUT) diff --git a/s5e-pressure-anim/tools/s5e_verify.py b/s5e-pressure-anim/tools/s5e_verify.py new file mode 100644 index 0000000..d4c4dd9 --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_verify.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +"""独立校验交付的两张 .face —— 只读交付物 + 固件镜像,不信任任何中间产物。 + + role=replace -> dist/S5ePAnim.face pay 应等于**改后镜像**的字节,rb 应等于**原厂**前 64 B + role=restore -> dist/S5ePAnimRestore.face pay 应等于**原厂**字节, rb 应等于**改后**前 64 B + +查的东西(缺一不可): + ① .face 头:magic / b4 / b5 / id / 预览块尺寸 326x326 + ② 从 .face 里**反解**加载的 Lua 源码:ROLE 正确、占位符已回填、 + 分区键是 'health'(不是 '/dev/health',那样运行时会拼成 if=nil) + ③ pay / rb 表逐条:目标偏移 == 原厂槽位 data_off、长度 == 原槽位长度(等长覆盖的底线) + ④ 每条 pay 长度 == 原槽位长度,且能按 LVGL RLE 解出**正好 usize 字节**、解成 464x464 + ⑤ 每条 pay 字节 == 另一侧镜像对应位置;每条 rb 指纹 == 另一侧前 64 B + ⑥ 改后镜像的 inode 表 / superblock / 分区长度与原厂**逐位一致** + ⑦ 负载在 .face 里原样连续存放(偏移 == 文件基址 + 魔数长度 + 相对偏移 的前提) +""" +import os, re, sys, json, struct, hashlib +sys.stdout.reconfigure(encoding='utf-8', errors='replace') +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import (PROJ, INPUTS, WORK, DIST, TARGET_IMAGE, DIRP, FACE_W, FACE_H, + WF_ID, PREVIEW, SCREEN) +from romfs import Romfs +import lvgl_bin as L + +ROLE = (sys.argv[1] if len(sys.argv) > 1 else 'replace').lower() +assert ROLE in ('replace', 'restore'), ROLE +NAME = 'S5ePAnim' if ROLE == 'replace' else 'S5ePAnimRestore' +FACE = os.path.join(DIST, NAME + '.face') +PATCHED = os.path.join(WORK, 'anim', 'vela_health_anim.bin') +FP_LEN = 64 +ok = fail = 0 + + +def ck(cond, msg): + global ok, fail + if cond: + ok += 1 + print(' OK ' + msg) + else: + fail += 1 + print(' FAIL ' + msg) + + +print('=' * 78) +print('%s(role=%s) %s' % (NAME, ROLE, FACE)) +print('=' * 78) +if not os.path.exists(FACE): + raise SystemExit('交付物不存在:%s' % FACE) +face = open(FACE, 'rb').read() +stock_b = open(TARGET_IMAGE, 'rb').read() +patch_b = open(PATCHED, 'rb').read() +stock = Romfs(TARGET_IMAGE) +patched = Romfs(PATCHED) + +# ---------------------------------------------------------------- ① 头 +print('\n[1] .face 头') +ck(face[0:4] == b'\x5a\xa5\x34\x12', 'magic 5a a5 34 12') +ck(face[4] in (0, 1, 2), 'b4 = %d' % face[4]) +ck(face[5] == 0, 'b5 = %d(固件自带表盘 b5 也是 0)' % face[5]) +ck(face[40:50] == WF_ID.encode() + b'\x00', 'id = %r(期望 %r)' % (face[40:50], WF_ID)) +a32, = struct.unpack(' 0, '找到 Lua 结尾 "return ui\\n"') +_start = face.rfind(b'--[[', 0, _end) +lua = face[_start:_end + len(b'return ui\n')] +ck(lua.startswith(b'--[[--') and b'local UIIMG = {' in lua, 'Lua 提取成功(%d B)' % len(lua)) +txt = lua.decode('utf-8') +ck(face.count(lua) == 1, 'Lua 原样出现 1 次(偏移 0x%x)' % face.find(lua)) +c = re.search(r"local ROLE = '(\w+)'", txt) +ck(c is not None and c.group(1) == ROLE, "Lua 里的 ROLE == '%s'" % ROLE) +ck('__UIIMG__' not in txt and '__PAY__' not in txt and '__RB__' not in txt + and '__ROLE__' not in txt, '占位符已全部回填') +ck(re.search(r'local W, H = %d, %d' % (SCREEN, SCREEN), txt) is not None, + '界面尺寸 W,H = %d,%d(480x480 圆屏)' % (SCREEN, SCREEN)) +# ★ S4e 那版踩过的坑:Lua 表里存 '/dev/app',运行时又拼一次 -> if=nil +mdev = re.search(r'local DEVS = \{([^}]*)\}', txt) +ck(mdev is not None and '/dev/health' in mdev.group(1), + 'DEVS 里有 health -> /dev/health:%s' % (mdev.group(1).strip() if mdev else None)) + +# ---------------------------------------------------------------- 偏移(自己找,不信 Lua) +print('\n[3] 资源偏移(独立在 .face 里搜字节,不信 Lua 表)') +ML_PAY = b'\x00S5P-PAYv1\x00\xa5\x5a\xc3\x3c' +ML_FP = b'\x00S5P-FPv1\x00\x5a\xa5\x3c\xc3' +offs = json.load(open(os.path.join(PROJ, 'blob_' + ROLE, 'offsets.json'), encoding='utf-8')) +pay_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'payload.bin'), 'rb').read() +fp_blob = open(os.path.join(PROJ, 'blob_' + ROLE, 'fp.bin'), 'rb').read() +ck(face.count(ML_PAY) == 1, 'payload 魔数(%d B)在 .face 里唯一' % len(ML_PAY)) +ck(face.count(ML_FP) == 1, 'fp 魔数(%d B)在 .face 里唯一' % len(ML_FP)) +pay_base = face.find(ML_PAY) +fp_base = face.find(ML_FP) +ck(face.find(pay_blob) == pay_base, 'payload.bin 原样连续存放在 0x%x' % pay_base) +ck(face.find(fp_blob) == fp_base, 'fp.bin 原样连续存放在 0x%x' % fp_base) +recs = offs['records'] +ck(len(recs) == 119, '负载记录 %d 条(期望 119)' % len(recs)) +# 整个负载文件必须**原样连续**放在 base 处 —— 这是"偏移 == base + 魔数 + rel"成立的前提 +ck(face[pay_base:pay_base + len(pay_blob)] == pay_blob, + 'payload.bin %d B 在 .face 里逐字节连续' % len(pay_blob)) +ck(face[fp_base:fp_base + len(fp_blob)] == fp_blob, + 'fp.bin %d B 在 .face 里逐字节连续' % len(fp_blob)) + +# Lua 表里的偏移必须和独立量出来的一致 +pay = [tuple(int(x) for x in r) for r in + re.findall(r'\{(\d+),(\d+),(\d+)\}', re.search(r'pay = \{(.*?)\n \},', txt, re.S).group(1))] +rb = [tuple(int(x) for x in r) for r in + re.findall(r'\{(\d+),(\d+),(\d+)\}', re.search(r'rb = \{(.*?)\n \},', txt, re.S).group(1))] +ck(len(pay) == len(recs) and len(rb) == len(recs), + 'Lua 表 pay %d 条 / rb %d 条' % (len(pay), len(rb))) +bad_off = 0 +for i, r in enumerate(recs): + if pay[i] != (pay_base + len(ML_PAY) + r['rel'], r['target'], r['length']): + bad_off += 1 + if rb[i] != (fp_base + len(ML_FP) + r['fprel'], r['target'], r['fplen']): + bad_off += 1 +ck(bad_off == 0, 'Lua 表里的 %d 条偏移 == 文件基址 + 魔数长度 + 相对偏移' % len(recs)) + +# ---------------------------------------------------------------- 原厂槽位 +slots = {} +for p, i in stock.entries(): + if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0: + continue + slots[i.data_off] = (p, i.size, i) +ck(len(slots) == 119, '原厂 %s 下 %d 个槽位' % (DIRP, len(slots))) +usizes = {struct.unpack(' 100, '%d/%d 条的"原厂前 64 B"与"改后前 64 B"确实不同(指纹有区分度)' + % (diff_fp, len(recs))) + +# ---------------------------------------------------------------- ⑥ 镜像完整性 +print('\n[6] 改后镜像的完整性') +a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()] +b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in patched.entries()] +ck(a == b, 'inode 表(路径/偏移/next/spec/size/cksum)%d 条逐位不变' % len(a)) +ck(stock.sb_size == patched.sb_size and stock.sb_cksum == patched.sb_cksum, + 'superblock 一致(size %d / cksum %08x)' % (patched.sb_size, patched.sb_cksum)) +ck(stock.volume == patched.volume == 'health', '卷名 health 不变') +ck(len(stock_b) == len(patch_b), '分区长度不变(%d B)' % len(patch_b)) +chg = [i for i in range(len(stock_b)) if stock_b[i] != patch_b[i]] +last_slot_end = max(o + slots[o][1] for o in slots) +ck(chg and chg[0] >= min(slots) and chg[-1] < last_slot_end, + '改动只落在负载槽位区间内(首差 0x%x,末差 0x%x,槽位区间 0x%x..0x%x)' + % (chg[0], chg[-1], min(slots), last_slot_end)) +# 每个槽位都必须真的被改过(不是"看起来写了、其实字节没变") +unchanged = [slots[o][0] for o in slots + if stock_b[o:o + slots[o][1]] == patch_b[o:o + slots[o][1]]] +ck(not unchanged, '119 个槽位全部都真的写入了新字节(未变的槽位:%s)' % (unchanged or '无')) + +# ---------------------------------------------------------------- ⑦ UI 图 +print('\n[7] UI 图片') +uipairs = re.findall(r'\{(\d+),(\d+),"([a-z_]+)"\}', txt) +ck(len(uipairs) == 8, 'UIIMG 条目 %d 条(期望 8)' % len(uipairs)) +badui = 0 +for o, l, n in uipairs: + blob = open(os.path.join(PROJ, 'ui_images', n + '.bin'), 'rb').read() + if int(l) != len(blob) or face[int(o):int(o) + int(l)] != blob: + badui += 1 +ck(badui == 0, '8 张界面素材都与 ui_images/*.bin 逐字节一致') + +# ---------------------------------------------------------------- 体积 +print('\n[8] 体积') +ck(len(face) < 20 * 1048576, '交付物 %.2f MB < 20 MB' % (len(face) / 1048576)) +print(' 两张盘同 id:装一份就顶掉另一份,设备上只占一份空间') + +print('\n' + '=' * 78) +print('%s:%d 项通过,%d 项失败' % (NAME, ok, fail)) +if fail: + sys.exit(1) +print('全部通过 —— %s(%d B,sha256 %s)' + % (os.path.basename(FACE), len(face), hashlib.sha256(face).hexdigest()[:16])) diff --git a/s5e-pressure-anim/tools/s5e_verify_indep.py b/s5e-pressure-anim/tools/s5e_verify_indep.py new file mode 100644 index 0000000..bbb69f2 --- /dev/null +++ b/s5e-pressure-anim/tools/s5e_verify_indep.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +"""独立总校验 —— 只读两张交付物 + 固件镜像,**不读任何构建中间产物**。 + +与 tools/s5e_verify.py 的分工 +----------------------------- +s5e_verify.py 会读 `blob_/offsets.json` 来核对 Lua 表里的偏移。本脚本刻意 +**不读** offsets.json / .work/overlay / .work/build,改为: + + * 自己从 `.face` 里反解 Lua(占位符、ROLE、pay/rb/UIIMG 三张表) + * 自己从**固件镜像**里量出 119 个槽位(名字 / 偏移 / 长度) + * 自己证明两张表「自洽 + 连续」:pay[0] 必须紧跟在魔数之后、pay[i+1] == pay[i]+len + * 自己证明**第 k 条负载落在 Measuring{k}.bin 上**(名字对得上,不只是偏移对得上) + * 逐条把负载解成像素:RLE 解出**恰好 usize**,且多出来的那 1 字节正好在像素区之后 + (这是"少 1 像素整帧不画"的那个坑) + * 两张交付物**互相印证**(replace 的 pay 前 64 B == restore 的 rb,反之亦然)—— + 这一条完全不碰 .work,也不碰固件镜像 + * 镜像级:inode 表 / superblock / 卷名 / 长度逐位不变;除 119 个槽位外, + **其余每一个文件的每一个字节都逐字节不变**(不是只做区间统计) + +用法:python tools/s5e_verify_indep.py +""" +import os, re, sys, hashlib, struct +sys.stdout.reconfigure(encoding='utf-8', errors='replace') +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from paths import PROJ, DIST, TARGET_IMAGE, DIRP, FACE_W, FACE_H, WF_ID, PREVIEW, SCREEN, WORK +from romfs import Romfs +import lvgl_bin as L + +MAGIC_PAY = b'\x00S5P-PAYv1\x00\xa5\x5a\xc3\x3c' +MAGIC_FP = b'\x00S5P-FPv1\x00\x5a\xa5\x3c\xc3' +FACES = {'replace': 'S5ePAnim.face', 'restore': 'S5ePAnimRestore.face'} +PATCHED = os.path.join(WORK, 'anim', 'vela_health_anim.bin') +UI_NAMES = ['back', 'ic_help', 'ic_replace', 'ic_about', 'ic_check', 'ic_rollback', + 'ic_qq', 'logo'] +ok = fail = 0 + + +def ck(cond, msg): + global ok, fail + if cond: + ok += 1 + print(' OK ' + msg) + else: + fail += 1 + print(' FAIL ' + msg) + + +def parse_face(path): + """把一张 .face 反解成:头 + Lua 文本 + 三张表 + 魔数位置。""" + d = open(path, 'rb').read() + end = d.find(b'return ui\n') + start = d.rfind(b'--[[', 0, end) + lua = d[start:end + len(b'return ui\n')] + txt = lua.decode('utf-8') + out = dict(data=d, lua=lua, txt=txt, lua_off=start) + m = re.search(r"local ROLE = '(\w+)'", txt) + out['role'] = m.group(1) if m else None + for key, pat in (('pay', r'pay = \{(.*?)\n \},'), ('rb', r'rb = \{(.*?)\n \},')): + blk = re.search(pat, txt, re.S) + out[key] = ([tuple(int(x) for x in t) + for t in re.findall(r'\{(\d+),(\d+),(\d+)\}', blk.group(1))] + if blk else []) + out['ui'] = re.findall(r'\{(\d+),(\d+),"([a-z_]+)"\}', txt) + out['pay_base'] = d.find(MAGIC_PAY) + out['fp_base'] = d.find(MAGIC_FP) + out['pay_magic_n'] = d.count(MAGIC_PAY) + out['fp_magic_n'] = d.count(MAGIC_FP) + out['lua_n'] = d.count(lua) + return out + + +def slots_of(stock): + """固件镜像里的 119 个槽位:名字 -> (偏移, 长度),并给出名字顺序的列表。""" + sl = {} + for p, i in stock.entries(): + if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin') or i.size == 0: + continue + sl[os.path.basename(p)] = (i.data_off, i.size) + return sl + + +def main(): + stock_b = open(TARGET_IMAGE, 'rb').read() + patch_b = open(PATCHED, 'rb').read() + stock = Romfs(TARGET_IMAGE) + patched = Romfs(PATCHED) + sl = slots_of(stock) + exp_name = ['Measuring%d.bin' % k for k in range(1, len(sl) + 1)] + print('=' * 78) + print('独立总校验(不读 blob_*/offsets.json、不读 .work/overlay、不读 .work/build)') + print(' 交付物 : dist/%s + dist/%s' % (FACES['replace'], FACES['restore'])) + print(' 固件 : inputs/target/vela_health.bin %d B(卷名 %s)' + % (len(stock_b), stock.volume)) + print(' 改后 : .work/anim/vela_health_anim.bin %d B' % len(patch_b)) + print('=' * 78) + ck(len(sl) == 119, '固件里 %s 下 %d 个槽位(期望 119)' % (DIRP, len(sl))) + ck(sorted(sl.keys()) == sorted(exp_name), '槽位名字就是 Measuring1..119.bin') + + faces = {} + for role, fn in FACES.items(): + f = parse_face(os.path.join(DIST, fn)) + faces[role] = f + other = patch_b if role == 'replace' else stock_b + print('\n' + '-' * 78) + print('[%s] %s %d B sha256 %s' + % (role, fn, len(f['data']), hashlib.sha256(f['data']).hexdigest())) + print('-' * 78) + + # ---- ① 头 + Lua ---- + d = f['data'] + ck(d[0:4] == b'\x5a\xa5\x34\x12', 'magic') + ck(d[5] == 0, 'b5 = %d(固件自带表盘是 0)' % d[5]) + ck(d[4] == 0, 'b4 = %d' % d[4]) + ck(d[40:50] == WF_ID.encode() + b'\x00', 'id = %r' % d[40:50]) + a32, = struct.unpack(' /dev/health') + ck(len(f['ui']) == 8, 'UIIMG %d 条(期望 8)' % len(f['ui'])) + + # ---- ② 表自洽 + 在文件里连续(完全不看 offsets.json)---- + pay, rb = f['pay'], f['rb'] + ck(len(pay) == 119 and len(rb) == 119, 'pay %d 条 / rb %d 条(期望 119/119)' + % (len(pay), len(rb))) + ck(f['pay_magic_n'] == 1 and f['fp_magic_n'] == 1, + '两个魔数各命中 1 次(payload 0x%x / fp 0x%x)' % (f['pay_base'], f['fp_base'])) + cont = 0 + for i, (o, _t, ln) in enumerate(pay): + if i == 0: + if o != f['pay_base'] + len(MAGIC_PAY): + cont += 1 + elif o != pay[i - 1][0] + pay[i - 1][2]: + cont += 1 + for i, (o, _t, ln) in enumerate(rb): + if i == 0: + if o != f['fp_base'] + len(MAGIC_FP): + cont += 1 + elif o != rb[i - 1][0] + rb[i - 1][2]: + cont += 1 + ck(cont == 0, '两张表的偏移自洽:首条紧跟魔数,之后逐条紧邻(%d 处不连续)' % cont) + ck(all(rb[i][2] == 64 for i in range(len(rb))), 'rb 每条都是 64 B') + + # ---- ③ 第 k 条落在 Measuring{k}.bin 上 ---- + bad_name = bad_len = 0 + for i, (o, t, ln) in enumerate(pay): + if t != sl[exp_name[i]][0]: + bad_name += 1 + if ln != sl[exp_name[i]][1] or rb[i][1] != t: + bad_len += 1 + ck(bad_name == 0, '第 k 条负载的目标偏移 == Measuring{k}.bin 的偏移(%d 处不符)' % bad_name) + ck(bad_len == 0, '每条长度 == 该槽位原厂长度,且 rb 的 target 与 pay 一致(%d 处不符)' + % bad_len) + ck(all(t % 512 == 0 for _o, t, _l in pay), '119 个目标偏移全部 512 对齐') + odd = sum(1 for _o, _t, l in pay if l % 4 != 0) + print(' (%d/119 条长度不是 4 的倍数 -> Lua 侧必须走 bs=512+4+1 分段写,' + '否则退化成 bs=1)' % odd) + + # ---- ④ 负载字节 == 对应镜像;⑤ 解码 ---- + bad_bytes = bad_dec = bad_hdr = 0 + big_e = 0 + for i, (o, t, ln) in enumerate(pay): + blob = d[o:o + ln] + if blob != other[t:t + ln]: + bad_bytes += 1 + if blob == (stock_b if role == 'replace' else patch_b)[t:t + ln]: + big_e += 1 + h = L.parse_header(blob) + if (h is None or h['cf'] != 0x0a or not (h['flags'] & L.FLAG_COMPRESSED) + or (h['w'], h['h']) != (FACE_W, FACE_H) or h['stride'] != FACE_W): + bad_hdr += 1 + continue + _m, cs, us = struct.unpack(' 像素区之后**正好** 1 字节尾巴(usize_tail) + if (len(raw) != us or us != 1024 + FACE_W * FACE_H + 1 + or len(raw) - 1024 - FACE_W * FACE_H != 1): + bad_dec += 1 + continue + try: + img = L.decode_lvgl_bin(blob)[1] + if img is None or img.size != (FACE_W, FACE_H): + bad_dec += 1 + except Exception: + bad_dec += 1 + ck(bad_bytes == 0, '每条负载字节 == %s镜像对应位置(%d 处不符)' + % ('改后' if role == 'replace' else '原厂', bad_bytes)) + ck(big_e == 0, '每条负载都真的与"另一侧"不同(%d 条与另一侧相同 —— 应当是 0)' % big_e) + ck(bad_hdr == 0, '每条都是 I8(0x0a) + 压缩 + 464x464 + stride 464(%d 条不符)' % bad_hdr) + ck(bad_dec == 0, '每条 RLE 解出**恰好** usize=216321 且 1024+464*464 之后正好 1 字节尾巴' + '(%d 条不符)' % bad_dec) + + # ---- ⑥ 指纹有区分度 ---- + diff_fp = sum(1 for i in range(len(pay)) + if stock_b[pay[i][1]:pay[i][1] + 64] != patch_b[pay[i][1]:pay[i][1] + 64]) + ck(diff_fp == 119, '%d/119 条的"原厂前 64 B"与"改后前 64 B"确实不同' % diff_fp) + ck(all(rb[i] == (rb[i][0], sl[exp_name[i]][0], 64) for i in range(len(rb))), + 'rb 的 target 也指向对应槽位') + + # ---- ⑦ UI 素材 ---- + badui = 0 + for o, l, n in f['ui']: + blob = open(os.path.join(PROJ, 'ui_images', n + '.bin'), 'rb').read() + if int(l) != len(blob) or d[int(o):int(o) + int(l)] != blob: + badui += 1 + ck(badui == 0, '8 张界面素材与 ui_images/*.bin 逐字节一致(%d 处不符)' % badui) + + # ================================================================ 两张盘互证 + print('\n' + '=' * 78) + print('[互证] 两张交付物互相印证(完全不碰 .work / 固件镜像)') + print('=' * 78) + rp, rr = faces['replace'], faces['restore'] + ck(len(rp['data']) == len(rr['data']), '两张盘字节数相同(%d)' % len(rp['data'])) + ck(rp['data'][:50] == rr['data'][:50], + '头 50 B(magic / b4 / b5 / id)逐字节相同') + cross = 0 + for i in range(119): + if rp['pay'][i][1] != rr['pay'][i][1]: + cross += 1 + continue + o1, t, l1 = rp['pay'][i] + o2, _t2, l2 = rr['pay'][i] + if rp['data'][o1:o1 + 64] != rr['data'][rr['rb'][i][0]:rr['rb'][i][0] + 64]: + cross += 1 + if rr['data'][o2:o2 + 64] != rp['data'][rp['rb'][i][0]:rp['rb'][i][0] + 64]: + cross += 1 + ck(cross == 0, 'replace 的负载前 64 B == restore 的指纹;restore 的负载前 64 B == ' + 'replace 的指纹(%d 处不符)' % cross) + same_ui = all(rp['data'][int(o):int(o) + int(l)] == rr['data'][int(o):int(o) + int(l)] + for o, l, _n in rp['ui']) + ck(same_ui, '两张盘的 8 张界面素材逐字节相同(同一套界面)') + ck(rp['pay_base'] == rr['pay_base'] and rp['fp_base'] == rr['fp_base'], + '两张盘的资源基址相同(0x%x / 0x%x)' % (rp['pay_base'], rr['fp_base'])) + ndiff = sum(1 for i in range(119) + if rp['data'][rp['pay'][i][0]:rp['pay'][i][0] + rp['pay'][i][2]] != + rr['data'][rr['pay'][i][0]:rr['pay'][i][0] + rr['pay'][i][2]]) + ck(ndiff == 119, '119/119 条负载在两张盘里都不一样(%d)' % ndiff) + + # ================================================================ 镜像完整性 + print('\n' + '=' * 78) + print('[镜像] 改后镜像 vs 原厂镜像') + print('=' * 78) + a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock.entries()] + b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in patched.entries()] + ck(a == b, 'inode 表(路径/偏移/next/spec/size/cksum)%d 条逐位不变' % len(a)) + ck((stock.sb_size, stock.sb_cksum, stock.volume) == + (patched.sb_size, patched.sb_cksum, patched.volume), + 'superblock 与卷名不变(size %d / cksum %08x / %r)' + % (patched.sb_size, patched.sb_cksum, patched.volume)) + ck(len(stock_b) == len(patch_b), '分区长度不变(%d B)' % len(patch_b)) + slot_offs = {o for o, _l in sl.values()} + bad_file = 0 + checked = 0 + for _p, i in stock.entries(): + if i.type == 1 or i.data_off in slot_offs: + continue # 目录项 / 119 个槽位本身:槽位在下面单独查 + checked += 1 + if stock_b[i.data_off:i.data_off + i.size] != patch_b[i.data_off:i.data_off + i.size]: + bad_file += 1 + ck(bad_file == 0, '除 119 个槽位外,其余 %d 个文件的字节**逐字节**不变(%d 处不符)' + % (checked, bad_file)) + chg = [i for i in range(len(stock_b)) if stock_b[i] != patch_b[i]] + lo, hi = min(slot_offs), max(o + sl[n][1] for n, (o, _s) in sl.items()) + ck(chg and chg[0] >= lo and chg[-1] < hi, + '变化区间 0x%x..0x%x 完全落在槽位区间 0x%x..0x%x 内' + % (chg[0], chg[-1], lo, hi)) + ident = [n for n, (o, _s) in sl.items() if stock_b[o:o + sl[n][1]] == patch_b[o:o + sl[n][1]]] + ck(not ident, '119 个槽位全部被改写(未变的:%s)' % (ident or '无')) + + print('\n' + '=' * 78) + print('独立总校验:%d 项通过,%d 项失败' % (ok, fail)) + print('=' * 78) + return 1 if fail else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/s5e-pressure-anim/ui_images/back.bin b/s5e-pressure-anim/ui_images/back.bin new file mode 100644 index 0000000..76eb40c Binary files /dev/null and b/s5e-pressure-anim/ui_images/back.bin differ diff --git a/s5e-pressure-anim/ui_images/ic_about.bin b/s5e-pressure-anim/ui_images/ic_about.bin new file mode 100644 index 0000000..2476014 Binary files /dev/null and b/s5e-pressure-anim/ui_images/ic_about.bin differ diff --git a/s5e-pressure-anim/ui_images/ic_check.bin b/s5e-pressure-anim/ui_images/ic_check.bin new file mode 100644 index 0000000..177ead3 Binary files /dev/null and b/s5e-pressure-anim/ui_images/ic_check.bin differ diff --git a/s5e-pressure-anim/ui_images/ic_help.bin b/s5e-pressure-anim/ui_images/ic_help.bin new file mode 100644 index 0000000..9ed2ddb Binary files /dev/null and b/s5e-pressure-anim/ui_images/ic_help.bin differ diff --git a/s5e-pressure-anim/ui_images/ic_qq.bin b/s5e-pressure-anim/ui_images/ic_qq.bin new file mode 100644 index 0000000..7db71e4 Binary files /dev/null and b/s5e-pressure-anim/ui_images/ic_qq.bin differ diff --git a/s5e-pressure-anim/ui_images/ic_replace.bin b/s5e-pressure-anim/ui_images/ic_replace.bin new file mode 100644 index 0000000..a231ebd Binary files /dev/null and b/s5e-pressure-anim/ui_images/ic_replace.bin differ diff --git a/s5e-pressure-anim/ui_images/ic_rollback.bin b/s5e-pressure-anim/ui_images/ic_rollback.bin new file mode 100644 index 0000000..19debcb Binary files /dev/null and b/s5e-pressure-anim/ui_images/ic_rollback.bin differ diff --git a/s5e-pressure-anim/ui_images/logo.bin b/s5e-pressure-anim/ui_images/logo.bin new file mode 100644 index 0000000..e79e0fa Binary files /dev/null and b/s5e-pressure-anim/ui_images/logo.bin differ