增添 S5 支持
This commit is contained in:
@@ -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 归一成 <name>.fprj,并返回它的路径。
|
||||
|
||||
我们拷进去时就是 <name>.fprj,但 vendor 的 build_face.ps1 会先跑
|
||||
sync_watchface_config.ps1:它按 config 的 projectName(现在是**显示名** S5e Pt.3)找
|
||||
<projectName>.fprj,找不到就把唯一那份改名过去(Title 也被改写成 projectName)。
|
||||
编完这里再改回 <name>.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 改名成显示名,编完改回 <name>.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')
|
||||
@@ -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)
|
||||
@@ -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)把 <name>.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()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""规范化构建:表盘源码 + 资源 -> .face(两遍,自动回填 resource.bin 内的偏移)
|
||||
|
||||
main.lua <- **源码**,含 __UIIMG__ / __PAY__ / __RB__ / __ROLE__ 占位符
|
||||
.work/overlay/<NAME> <- 中间产物(回填后的 Lua 落在这里)
|
||||
.work/build/<NAME> <- 编译工作区
|
||||
dist/<NAME>.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()
|
||||
@@ -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 <vela_system.bin>
|
||||
iconpack.py make <vela_system.bin> <pngdir> <outdir> [--pad] [--patched]
|
||||
iconpack.py apply <vela_system.bin> <patch.bin> <out.bin>
|
||||
"""
|
||||
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('<BBHHHHH', MAGIC, cf, flags, w, h, stride, 0) + body
|
||||
if pad_to is not None:
|
||||
if len(blob) > 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('<III', stock_blob[12:24])
|
||||
stride = h['stride'] or w
|
||||
best = None
|
||||
for n in (colors,) if colors else RLE_COLOR_STEPS:
|
||||
raw = raw_i8_strided(out_img, stride, n)
|
||||
comp = rle_compress(raw)
|
||||
# 末尾还要补一条「重复 1 次」指令(2 B):原厂流解出来正好是 usize 字节
|
||||
# (= 1024 + stride*h + 1),少这 1 字节设备端解码器可能直接判失败。
|
||||
if len(comp) + 2 <= csize:
|
||||
best = (n, raw, comp)
|
||||
break
|
||||
if best is None:
|
||||
n_last = RLE_COLOR_STEPS[-1]
|
||||
comp = rle_compress(raw_i8_strided(out_img, stride, n_last))
|
||||
return None, 'RLE 压缩后 %d > 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('<III', 1, csize, usize) + padded
|
||||
if len(blob) != slot_len:
|
||||
return None, '组装后 %d != 槽位 %d' % (len(blob), slot_len)
|
||||
return blob, 'RLE %d色 %d/%d' % (n, len(comp), csize)
|
||||
return None, 'cf=0x%02x flags=%d 不支持' % (cf, flags)
|
||||
|
||||
|
||||
def targets(r):
|
||||
"""Map a flat, file-name-safe key -> 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('<II', ino.data_off, len(blob)) + blob
|
||||
rollback += struct.pack('<II', ino.data_off, len(orig)) + orig
|
||||
open(os.path.join(payload_dir, app.replace('/', '_') + '.bin'), 'wb').write(blob)
|
||||
records.append(dict(app=app, offset=ino.data_off, length=len(blob),
|
||||
format=fmt, stock_dim=f'{h["w"]}x{h["h"]}',
|
||||
source=fn, source_dim=src_dim,
|
||||
resized=resized, padded=bool(pad and src.size != (h['w'], h['h'])),
|
||||
sha256=hashlib.sha256(blob).hexdigest()))
|
||||
|
||||
open(os.path.join(outdir, 'icons.patch'), 'wb').write(patch)
|
||||
open(os.path.join(outdir, 'icons.rollback'), 'wb').write(rollback)
|
||||
manifest = dict(image=os.path.basename(image), volume=r.volume,
|
||||
image_sha256=hashlib.sha256(r.data).hexdigest(),
|
||||
replace_count=len(records), records=records,
|
||||
skipped=[{'app': a, 'reason': b} for a, b in skipped])
|
||||
json.dump(manifest, open(os.path.join(outdir, 'manifest.json'), 'w'),
|
||||
indent=1, ensure_ascii=False)
|
||||
|
||||
print(f'{"app":<26}{"offset":>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('<II', blob[off:off+8])
|
||||
chunk = blob[off+8:off+8+ln]
|
||||
assert len(chunk) == ln and addr + ln <= len(data), 'corrupt patch'
|
||||
data[addr:addr+ln] = chunk
|
||||
off += 8 + ln
|
||||
n += 1
|
||||
open(out, 'wb').write(data)
|
||||
print(f'applied {n} record(s) -> {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__)
|
||||
@@ -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'(?<![\w.])' + k + r'(?![\w])', code))
|
||||
# every while/for consumes exactly one `do`, wherever it sits (may wrap lines)
|
||||
do_total = cnt('do')
|
||||
do_standalone = do_total - cnt('while') - cnt('for')
|
||||
openers = cnt('function') + cnt('if') + cnt('while') + cnt('for') + do_standalone
|
||||
enders = cnt('end')
|
||||
repeat_n, until_n = cnt('repeat'), cnt('until')
|
||||
print(f'{path}')
|
||||
print(f' function={cnt("function")} if={cnt("if")} while={cnt("while")} '
|
||||
f'for={cnt("for")} do(standalone)={do_standalone} repeat={repeat_n}')
|
||||
print(f' openers={openers} end={enders} until={until_n}')
|
||||
ok = (openers == enders) and (repeat_n == until_n)
|
||||
print(f' BALANCED: {ok}')
|
||||
if not ok:
|
||||
print(' ^^^ 括号/块不匹配,需要检查')
|
||||
return ok
|
||||
|
||||
|
||||
allok = True
|
||||
for p in sys.argv[1:]:
|
||||
allok &= check(p)
|
||||
print('\nALL OK' if allok else '\nPROBLEM')
|
||||
@@ -0,0 +1,168 @@
|
||||
"""LVGL v9 RLE codec (LV_IMAGE_COMPRESS_RLE).
|
||||
|
||||
Control byte grammar (upstream `lv_rle_decompress`):
|
||||
high bit CLEAR (0x00..0x7F) : repeat the next single `blk`-byte block `ctrl` times
|
||||
high bit SET (0x80|count) : copy `count` blocks verbatim (literal run)
|
||||
count is 1..=127
|
||||
For palette formats (I8) the stream decodes to 256*4 palette bytes followed by
|
||||
w*h palette indices.
|
||||
"""
|
||||
import struct
|
||||
from PIL import Image
|
||||
|
||||
MAGIC = 0x19
|
||||
FLAG_COMPRESSED = 0x08
|
||||
METHOD_NONE, METHOD_RLE, METHOD_LZ4 = 0, 1, 2
|
||||
|
||||
|
||||
def rle_decompress(data, blk, out_len=None):
|
||||
out = bytearray()
|
||||
i, n = 0, len(data)
|
||||
while i < n:
|
||||
ctrl = data[i]; i += 1
|
||||
if ctrl & 0x80:
|
||||
cnt = ctrl & 0x7F
|
||||
nb = cnt * blk
|
||||
if i + nb > 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('<BBHHHHH', blob[:12])
|
||||
return dict(magic=magic, cf=cf, flags=flags, w=w, h=h, stride=stride, reserved=rsv)
|
||||
|
||||
|
||||
def decode_lvgl_bin(blob):
|
||||
"""Return (header, RGBA PIL image) for uncompressed / RLE LVGL v9 .bin.
|
||||
|
||||
NOTE stride: LVGL pads each row to the header's `stride`, which is NOT always
|
||||
equal to w (e.g. the S5 41mm control-center icons are 116 wide with stride 128).
|
||||
Reading them as w*h contiguous pixels yields rainbow garbage, so the row pitch
|
||||
below is taken from the header.
|
||||
"""
|
||||
h = parse_header(blob)
|
||||
if h is None:
|
||||
return None, None
|
||||
cf, w, ht = h['cf'], h['w'], h['h']
|
||||
body = blob[12:]
|
||||
if h['flags'] & FLAG_COMPRESSED:
|
||||
method, csize, usize = struct.unpack('<III', body[:12])
|
||||
if method != METHOD_RLE:
|
||||
raise ValueError(f'unsupported compression method {method}')
|
||||
blk = 4 if cf == 0x10 else 1 # ARGB8888 是 4 字节一块,I8 是 1 字节
|
||||
raw = rle_decompress(body[12:12 + csize], blk, usize)
|
||||
else:
|
||||
raw = body
|
||||
img = Image.new('RGBA', (w, ht))
|
||||
px = img.load()
|
||||
if cf == 0x10: # ARGB8888 (BGRA)
|
||||
pitch = max(h['stride'] or 0, w * 4)
|
||||
if len(raw) < pitch * ht:
|
||||
return h, None
|
||||
for y in range(ht):
|
||||
row = y * pitch
|
||||
for x in range(w):
|
||||
b, g, r, a = raw[row + x * 4:row + x * 4 + 4]
|
||||
px[x, y] = (r, g, b, a)
|
||||
elif cf == 0x0a: # I8: palette then indices
|
||||
pitch = max(h['stride'] or 0, w)
|
||||
if len(raw) < 1024 + pitch * ht:
|
||||
return h, None
|
||||
pal = raw[:1024]
|
||||
palette = [(pal[i*4+2], pal[i*4+1], pal[i*4+0], pal[i*4+3]) for i in range(256)]
|
||||
for y in range(ht):
|
||||
row = 1024 + y * pitch
|
||||
for x in range(w):
|
||||
px[x, y] = palette[raw[row + x]]
|
||||
else:
|
||||
return h, None
|
||||
return h, img
|
||||
|
||||
|
||||
def pad_rle_safe(comp, want):
|
||||
"""把 RLE 流补齐到 want 字节 —— 只用两类**产出 0 像素**的合法指令:
|
||||
ctrl=0x00 + unit : 重复 0 次(2 字节)
|
||||
0x80 : 原样 0 个(1 字节,用来修奇偶)
|
||||
两者都能通过上游 lv_rle_decompress 的边界判断(len 为 0,不越界)。
|
||||
注意:不要像 pad_rle 那样去“拆一条 literal”修奇偶 —— 那是按裸字节扫描的,
|
||||
会命中负载里的普通字节、把流改坏(本工具就是踩了这个坑才发现)。"""
|
||||
d = want - len(comp)
|
||||
if d < 0:
|
||||
return None
|
||||
out = bytearray(comp)
|
||||
out += b'\x00\x00' * (d // 2)
|
||||
if d % 2:
|
||||
out.append(0x80)
|
||||
assert len(out) == want
|
||||
return bytes(out)
|
||||
@@ -0,0 +1,296 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 S5 41mm(q63) 的充电动画搬进 S5 eSIM 46mm(p62lte) 的 /dev/system(逐帧等长覆盖)。
|
||||
|
||||
目标:inputs/target/vela_system.bin charging/charge0..60.bin(**480x480** I8+RLE,61 帧)
|
||||
源 :inputs/source/vela_system.bin charging/charge0..60.bin(**464x464** I8+RLE,61 帧)
|
||||
映射:**帧号 1:1(n -> 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('<III', blob[12:24])
|
||||
raw = L.rle_decompress(blob[24:24 + csize], 1, usize)
|
||||
pal = np.frombuffer(raw[:1024], dtype=np.uint8).reshape(256, 4)
|
||||
st = max(h['stride'], h['w'])
|
||||
idx = np.frombuffer(raw[1024:1024 + st * h['h']], dtype=np.uint8).reshape(h['h'], st)[:, :h['w']]
|
||||
return h, pal, idx
|
||||
|
||||
|
||||
src_indexed = decode_fast
|
||||
|
||||
|
||||
def rgb_tab(pal):
|
||||
"""(256,3) int32 RGB 表(调色板存的是 BGRA)。"""
|
||||
return pal[:, [2, 1, 0]].astype(np.int32)
|
||||
|
||||
|
||||
def src_rgb(pal, idx):
|
||||
return rgb_tab(pal)[idx]
|
||||
|
||||
|
||||
def lanczos_ref(pal, idx, w, h):
|
||||
"""源帧 -> 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('<III', blob[12:24])
|
||||
assert len(blob) == 24 + csize, '槽位长度 != 24+csize'
|
||||
assert (w, ht) == (W, H), '目标槽位不是 %dx%d' % (W, H)
|
||||
assert usize == 1024 + w * ht, 'usize 不是 1024+w*h'
|
||||
sh, spal, sidx = src_indexed(srcs[n][2])
|
||||
assert (sh['w'], sh['h']) == (464, 464), '源帧不是 464x464'
|
||||
|
||||
comp, idx_out, how = encode_frame(spal, sidx, w, ht, csize, usize)
|
||||
new = blob[:24] + L.pad_rle_safe(comp, csize)
|
||||
assert len(new) == len(blob), '替换负载长度 != 原槽位长度'
|
||||
assert len(comp) <= csize
|
||||
|
||||
# 回解自检:① 解码长度恰好 usize ② 解出来的图与"计划写进去的图"逐像素一致
|
||||
hh, pal2, idx2 = decode_fast(new)
|
||||
raw_len = len(L.rle_decompress(new[24:24 + csize], 1, None))
|
||||
same = (idx2.shape == idx_out.shape) and bool((idx2 == idx_out).all())
|
||||
decode_ck += 1 if (same and raw_len == usize and (pal2 == spal).all()) else 0
|
||||
# 质量:与 Lanczos 放大基准比(吸附误差)+ 暗区误差
|
||||
ref = lanczos_ref(spal, sidx, w, ht)
|
||||
got = rgb_tab(spal)[idx_out]
|
||||
err = float(np.abs(got - ref).mean())
|
||||
dark = ref.max(axis=2) < 64
|
||||
e_dk = float(np.abs(got[dark] - ref[dark]).mean()) if dark.any() else 0.0
|
||||
used[how] = used.get(how, 0) + 1
|
||||
print(' 帧 %-3d <- q63 第 %-3d 帧 槽位 %6d B 编码 %6d B(留白 %5d) '
|
||||
'Lanczos 均差 %.3f(暗区 %.3f) 色数 %3d %s'
|
||||
% (n, n, len(blob), len(comp), csize - len(comp), err, e_dk,
|
||||
len(np.unique(idx_out)), how))
|
||||
records.append(dict(name='charge%d' % n, src='charge%d' % n, offset=ino.data_off,
|
||||
length=len(blob), data=new, how=how, csize=len(comp),
|
||||
slack=csize - len(comp), err=round(err, 4)))
|
||||
if n in (0, 20, 40, 60):
|
||||
samples.append((n, spal, sidx, idx_out))
|
||||
print('\n回解自检通过 %d / %d(解码长度恰好 usize 且逐像素等于计划图)' % (decode_ck, len(records)))
|
||||
print('编码方式分布:%s' % used)
|
||||
|
||||
shutil.copyfile(STOCK, PATCHED)
|
||||
with open(PATCHED, 'r+b') as f:
|
||||
for r in records:
|
||||
f.seek(r['offset'])
|
||||
f.write(r['data'])
|
||||
json.dump(dict(device='Xiaomi Watch S5 eSIM 46mm (p62lte) vela_system.bin', target_dir=DIRP,
|
||||
source='Xiaomi Watch S5 41mm (q63) charging frames (464x464)',
|
||||
frames=len(records), size=[W, H], mapping='n -> n',
|
||||
sample='all' if SAMPLE is None else SAMPLE,
|
||||
image_sha256=hashlib.sha256(stock.data).hexdigest(),
|
||||
records=[dict(name=r['name'], offset=r['offset'], length=r['length'],
|
||||
src=r['src'], how=r['how'], csize=r['csize']) for r in records]),
|
||||
open(os.path.join(OUTDIR, 'manifest.json'), 'w', encoding='utf-8'), indent=1,
|
||||
ensure_ascii=False)
|
||||
|
||||
# ---------------- 整镜像级自证 ----------------
|
||||
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(sb_size=%d / %d,sb_cksum=%d / %d)'
|
||||
% (stock.sb_size == r2.sb_size and stock.sb_cksum == r2.sb_cksum,
|
||||
stock.sb_size, r2.sb_size, stock.sb_cksum, r2.sb_cksum))
|
||||
sh = open(PATCHED, 'rb').read()
|
||||
print('回放(改后镜像 == 计划字节):%s' % all(
|
||||
sh[r['offset']:r['offset'] + r['length']] == r['data'] for r in records))
|
||||
sa = np.frombuffer(stock.data, np.uint8)
|
||||
sb = np.frombuffer(sh, np.uint8)
|
||||
diff = np.nonzero(sa != sb)[0]
|
||||
inside = np.zeros(len(sa), dtype=bool)
|
||||
for r in records:
|
||||
inside[r['offset']:r['offset'] + r['length']] = True
|
||||
outside = int((~inside[diff]).sum()) if len(diff) else 0
|
||||
print('逐字节差异:共 %d 字节,全部落在 %d 个槽位内:%s(槽位外 %d 字节)'
|
||||
% (len(diff), len(records), outside == 0, outside))
|
||||
print('负载合计 %.2f MB;写出 %s' % (sum(r['length'] for r in records) / 1048576,
|
||||
os.path.join(OUTDIR, 'manifest.json')))
|
||||
if samples and DRAW_DOCS:
|
||||
draw_docs(samples)
|
||||
|
||||
|
||||
def draw_docs(samples):
|
||||
"""对照图:上排 q63 原帧(放大到 480 只为本机预览),下排改后镜像里解出来的 480x480。"""
|
||||
os.makedirs(DOCS, exist_ok=True)
|
||||
from PIL import ImageDraw
|
||||
cell = 240
|
||||
rows, cols = 2, len(samples)
|
||||
sheet = Image.new('RGB', (cols * (cell + 8) + 8, rows * (cell + 34) + 8), (28, 28, 32))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
for i, (n, spal, sidx, idx_out) in enumerate(samples):
|
||||
top = Image.fromarray(src_rgb(spal, sidx).astype(np.uint8), 'RGB').resize(
|
||||
(cell, cell), Image.LANCZOS)
|
||||
bot = Image.fromarray(rgb_tab(spal)[idx_out].astype(np.uint8), 'RGB').resize(
|
||||
(cell, cell), Image.NEAREST)
|
||||
x = 8 + i * (cell + 8)
|
||||
sheet.paste(top, (x, 28))
|
||||
sheet.paste(bot, (x, 28 + cell + 26))
|
||||
d.text((x + 4, 8), 'q63 charge%d (464)' % n, fill=(200, 200, 200))
|
||||
d.text((x + 4, 28 + cell + 8), 'S5e charge%d (480)' % n, fill=(120, 220, 160))
|
||||
p = os.path.join(DOCS, 'before_after.png')
|
||||
sheet.save(p)
|
||||
print('对照图(上=源帧 下=改后解出):%s' % p)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""按「角色」切负载:replace(只负责替换)/ restore(只负责还原)。
|
||||
|
||||
拆盘的要点:一个 .face 只装一份负载,体积减半。
|
||||
* replace 盘:pay = q63 的帧(新字节);rb = **原厂字节前 64 B 的指纹**(只用于版本校验)
|
||||
* restore 盘:pay = 原厂字节; rb = **新字节前 64 B 的指纹**
|
||||
指纹表让「写入前版本校验 + 写入后回读比对」两道保险都还在,代价只有 61*64 ≈ 3.9 KB。
|
||||
两份盘用同一个 watchface id,装其中一份就会顶掉另一份,设备上始终只占一份空间。
|
||||
|
||||
产出:
|
||||
<out>/payload[_restore]/ NNN_<name>.bin + payload.lua
|
||||
<out>/blob_<role>/payload.bin 单条目:魔数 + 顺序拼接
|
||||
<out>/blob_<role>/fp.bin 指纹单条目
|
||||
<out>/flash.sh | restore.sh 线性 nsh 脚本(replace 盘还有 restore.sh 的对应物)
|
||||
<out>/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 <this dir>/<file> -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=<dir>/<file> of=/dev/system bs=4 seek=<offset/4> count=<length/4>',
|
||||
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)))
|
||||
@@ -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 的 <Screen Title>
|
||||
|
||||
|
||||
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 = ('<?xml version="1.0" encoding="utf-16" ?>\r\n'
|
||||
f'<FaceProject DeviceType="{DEVICE_TYPE}">\r\n'
|
||||
f' <Screen Title="{title}" Bitmap="preview.png">\r\n'
|
||||
f' <Widget Shape="34" Name="{ENTRY_WIDGET}" '
|
||||
f'X="0" Y="0" Width="{SCREEN_SIZE}" Height="{SCREEN_SIZE}" Alpha="0" />\r\n'
|
||||
' </Screen>\r\n</FaceProject>\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_<role>/) —— .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))
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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'<Inode @0x{self.off:x} {self.KIND[self.type]}{"X" if self.exec_bit else " "} '
|
||||
f'{self.name!r} size={self.size}>')
|
||||
|
||||
|
||||
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('<BBHHHHH', blob[:12])
|
||||
return dict(magic=magic, cf=cf, flags=flags, w=w, h=h, stride=stride, reserved=res)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
r = Romfs(sys.argv[1])
|
||||
print(f'{os.path.basename(sys.argv[1])}: volume={r.volume!r} size={r.sb_size} '
|
||||
f'file={len(r.data)} root=0x{r.root_off:x}')
|
||||
n = 0
|
||||
for path, i in r.entries():
|
||||
n += 1
|
||||
if len(sys.argv) > 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}')
|
||||
@@ -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"
|
||||
@@ -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('<III', blob[12:24])
|
||||
raw = L.rle_decompress(blob[24:24 + csize], 1, usize)
|
||||
pal = np.frombuffer(raw[:1024], dtype=np.uint8).reshape(256, 4)
|
||||
st = max(h['stride'], h['w'])
|
||||
idx = np.frombuffer(raw[1024:1024 + st * h['h']], dtype=np.uint8).reshape(h['h'], st)[:, :h['w']]
|
||||
return h, m, csize, usize, len(raw), pal, idx
|
||||
|
||||
|
||||
def rgb_of(pal, idx):
|
||||
return pal[idx][:, :, [2, 1, 0]]
|
||||
|
||||
|
||||
def sig(rgb):
|
||||
"""64x64 灰度特征(用于"最像哪一帧"的快速最近邻)。"""
|
||||
im = Image.fromarray(np.asarray(rgb, np.uint8), 'RGB').resize((64, 64), Image.BILINEAR)
|
||||
return np.asarray(im, np.float32).mean(axis=2).ravel()
|
||||
|
||||
|
||||
def parse_tables(face):
|
||||
src = face.decode('latin1')
|
||||
m = re.search(r'pay = \{(.*?)\n \},', src, re.S)
|
||||
m2 = re.search(r'rb = \{(.*?)\n \},', src, re.S)
|
||||
if not m or not m2:
|
||||
return None, None
|
||||
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))]
|
||||
return pay, rb
|
||||
|
||||
|
||||
log('=' * 78)
|
||||
log('S5eChg 独立总校验(只读 dist/*.face + inputs/*/vela_system.bin)')
|
||||
log('=' * 78)
|
||||
|
||||
stock_b = open(STOCK, 'rb').read()
|
||||
src_rom = Romfs(SOURCE)
|
||||
src_blob = {}
|
||||
for p, i in src_rom.entries():
|
||||
if i.type == 1 or not p.startswith(DIRP) or not p.endswith('.bin'):
|
||||
continue
|
||||
src_blob[num_of(p)] = src_rom.read(i)
|
||||
stock_rom, slot = slots_of(STOCK)
|
||||
log('目标镜像 %s:%d 字节,charging/ 槽位 %d 个' % (os.path.basename(STOCK), len(stock_b), len(slot)))
|
||||
log('源镜像 %s:%d 帧' % (os.path.basename(SOURCE), len(src_blob)))
|
||||
|
||||
faces = {}
|
||||
log('\n--- A 交付物形态 ---')
|
||||
for role, path in FACES.items():
|
||||
if not os.path.exists(path):
|
||||
ck(False, '%s 存在' % path)
|
||||
continue
|
||||
b = open(path, 'rb').read()
|
||||
faces[role] = b
|
||||
log(' %-18s %10d 字节 sha256=%s' % (os.path.basename(path), len(b),
|
||||
hashlib.sha256(b).hexdigest()))
|
||||
log(' %-18s sha256[:16]=%s' % ('', hashlib.sha256(b).hexdigest()[:16]))
|
||||
ck(b[0:4] == b'\x5a\xa5\x34\x12', '%s 魔数正确' % role)
|
||||
ck(b[40:50].rstrip(b'\x00') == WF_ID.encode(), '%s id == %s' % (role, WF_ID), repr(b[40:50]))
|
||||
ck(b[5] == 0, '%s b5 == 0' % role, 'b5=%d' % b[5])
|
||||
ck(len(b) < 20 * 1048576, '%s 体积 < 20MB' % role)
|
||||
ck(b'__PAY__' not in b and b'__ROLE__' not in b, '%s 占位符已回填' % role)
|
||||
|
||||
log('\n--- B Lua 表(从 .face 反解)---')
|
||||
tables = {}
|
||||
for role, b in faces.items():
|
||||
pay, rb = parse_tables(b)
|
||||
ck(pay is not None and rb is not None, '%s 找到 pay / rb 表' % role)
|
||||
if pay is None:
|
||||
continue
|
||||
tables[role] = (pay, rb)
|
||||
log(' %s:pay %d 条 / rb %d 条' % (role, len(pay), len(rb)))
|
||||
ck(len(pay) == 61, '%s pay 条数 == 61' % role, str(len(pay)))
|
||||
ck(len(rb) == 61, '%s rb 条数 == 61' % role, str(len(rb)))
|
||||
ck(re.search(r"local ROLE = '(\w+)'", b.decode('latin1')).group(1) == role,
|
||||
'%s Lua 里的 ROLE == %s' % (role, role))
|
||||
|
||||
log('\n--- C 等长 + 目标偏移 / D 可解码(480x480,恰好 usize)---')
|
||||
decoded = {}
|
||||
for role in ('replace', 'restore'):
|
||||
if role not in tables:
|
||||
continue
|
||||
pay, rb = tables[role]
|
||||
bad_len = bad_off = bad_dec = 0
|
||||
imgs = {}
|
||||
for i, (res, tgt, ln) in enumerate(pay):
|
||||
blob = faces[role][res:res + ln]
|
||||
if len(blob) != ln or tgt not in slot or slot[tgt][1] != ln:
|
||||
bad_len += 1
|
||||
continue
|
||||
if slot[tgt][0] != i:
|
||||
bad_off += 1
|
||||
try:
|
||||
h, m, cs, us, rawlen, pal, idx = decode(blob)
|
||||
except Exception:
|
||||
bad_dec += 1
|
||||
continue
|
||||
if (h is None or h['w'] != W or h['h'] != H or h['cf'] != 0x0a
|
||||
or not (h['flags'] & L.FLAG_COMPRESSED) or m != 1
|
||||
or cs != ln - 24 or us != 1024 + W * H or rawlen != us
|
||||
or not (pal[:, 3] == 255).all()):
|
||||
bad_dec += 1
|
||||
continue
|
||||
imgs[i] = (pal, idx)
|
||||
ck(bad_len == 0, '%s:61 条长度 == 原厂槽位长度' % role, '不符 %d 条' % bad_len)
|
||||
ck(bad_off == 0, '%s:目标偏移一一对应 chargeN' % role, '错位 %d 条' % bad_off)
|
||||
ck(bad_dec == 0, '%s:61 条都解出 480x480 且 RLE 恰好 usize' % role, '不符 %d 条' % bad_dec)
|
||||
decoded[role] = imgs
|
||||
|
||||
log('\n--- E 帧内容:与 q63 源帧的独立 Lanczos 放大比对 ---')
|
||||
src_sig = {j: sig(rgb_of(*src_indexed(src_blob[j])[1:])) for j in sorted(src_blob)}
|
||||
errs, mismatch = [], []
|
||||
for i in sorted(decoded.get('replace', {})):
|
||||
pal, idx = decoded['replace'][i]
|
||||
got = rgb_of(pal, idx).astype(np.int32)
|
||||
_, spal, sidx = src_indexed(src_blob[i])
|
||||
ref = np.asarray(Image.fromarray(rgb_of(spal, sidx).astype(np.uint8), 'RGB')
|
||||
.resize((W, H), Image.LANCZOS), np.int32)
|
||||
errs.append(float(np.abs(got - ref).mean()))
|
||||
g = sig(got.astype(np.uint8))
|
||||
best = min(src_sig, key=lambda j: float(np.abs(src_sig[j] - g).mean()))
|
||||
if best != i:
|
||||
mismatch.append((i, best))
|
||||
log(' 61 帧平均差:mean %.3f max %.3f(第 %d 帧)'
|
||||
% (float(np.mean(errs)), max(errs), int(np.argmax(errs))))
|
||||
ck(max(errs) < 1.0, '每一帧与独立 Lanczos 基准的平均差 < 1.0/255', 'max=%.3f' % max(errs))
|
||||
log(' 最像的源帧号与自身不一致的帧:%d 个 %s'
|
||||
% (len(mismatch), mismatch[:8] if mismatch else ''))
|
||||
ck(len(mismatch) <= 2, '交付物第 n 帧就是 q63 第 n 帧(最多容忍 2 帧近邻歧义)',
|
||||
'%d 个不一致' % len(mismatch))
|
||||
|
||||
log('\n--- F 版本指纹 / 还原字节 ---')
|
||||
if 'replace' in tables and 'restore' in tables:
|
||||
rpay, rrb = tables['replace']
|
||||
apay, arb = tables['restore']
|
||||
bad = sum(1 for res, tgt, ln in rrb
|
||||
if ln != 64 or faces['replace'][res:res + 64] != stock_b[tgt:tgt + 64])
|
||||
ck(bad == 0, 'replace 盘 rb == 原厂前 64 B(对固件镜像比对)', '不符 %d 条' % bad)
|
||||
bad = 0
|
||||
for res, tgt, ln in apay:
|
||||
if ln != slot[tgt][1] or faces['restore'][res:res + ln] != stock_b[tgt:tgt + ln]:
|
||||
bad += 1
|
||||
ck(bad == 0, 'restore 盘 pay == 原厂整条字节(逐字节)', '不符 %d 条' % bad)
|
||||
bad = 0
|
||||
for i, (res, tgt, ln) in enumerate(arb):
|
||||
new64 = faces['replace'][rpay[i][0]:rpay[i][0] + 64]
|
||||
if ln != 64 or faces['restore'][res:res + 64] != new64:
|
||||
bad += 1
|
||||
ck(bad == 0, 'restore 盘 rb == replace 盘 pay 前 64 B(两份交付物互证)', '不符 %d 条' % bad)
|
||||
|
||||
log('\n--- G 整镜像(用 replace 盘负载重建改后镜像)---')
|
||||
if 'replace' in tables:
|
||||
rpay, _ = tables['replace']
|
||||
patched = bytearray(stock_b)
|
||||
for res, tgt, ln in rpay:
|
||||
patched[tgt:tgt + ln] = faces['replace'][res:res + ln]
|
||||
patched = bytes(patched)
|
||||
os.makedirs(DOCS, exist_ok=True)
|
||||
tmp = os.path.join(DOCS, '_patched_tmp.bin')
|
||||
open(tmp, 'wb').write(patched)
|
||||
p_rom = Romfs(tmp)
|
||||
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in stock_rom.entries()]
|
||||
b2 = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in p_rom.entries()]
|
||||
ck(a == b2, 'inode 表逐位不变(%d 条)' % len(a))
|
||||
ck(stock_rom.sb_size == p_rom.sb_size and stock_rom.sb_cksum == p_rom.sb_cksum,
|
||||
'superblock 逐位不变', 'size=%d cksum=%d' % (stock_rom.sb_size, stock_rom.sb_cksum))
|
||||
ck(len(patched) == len(stock_b), '分区长度不变')
|
||||
sa = np.frombuffer(stock_b, np.uint8)
|
||||
sb = np.frombuffer(patched, np.uint8)
|
||||
diff = np.nonzero(sa != sb)[0]
|
||||
inside = np.zeros(len(sa), dtype=bool)
|
||||
for res, tgt, ln in rpay:
|
||||
inside[tgt:tgt + ln] = True
|
||||
outside = int((~inside[diff]).sum()) if len(diff) else 0
|
||||
ck(outside == 0, '除 %d 个槽位外没有任何字节变化' % len(rpay),
|
||||
'差异 %d 字节,槽位外 %d 字节' % (len(diff), outside))
|
||||
mid = os.path.join(PROJ, '.work', 'charge', 'vela_system_charge.bin')
|
||||
if os.path.exists(mid):
|
||||
log(' [info] 与构建中间产物逐字节一致:%s' % (open(mid, 'rb').read() == patched))
|
||||
os.remove(tmp)
|
||||
|
||||
log('\n--- H 预览图(交付物解出来的帧)---')
|
||||
if decoded.get('replace'):
|
||||
cols, cell = 8, 120
|
||||
n = len(decoded['replace'])
|
||||
rows = (n + cols - 1) // cols
|
||||
sheet = Image.new('RGB', (cols * cell, rows * cell), (18, 18, 20))
|
||||
for i in sorted(decoded['replace']):
|
||||
pal, idx = decoded['replace'][i]
|
||||
sheet.paste(Image.fromarray(rgb_of(pal, idx).astype(np.uint8), 'RGB')
|
||||
.resize((cell, cell), Image.LANCZOS), ((i % cols) * cell, (i // cols) * cell))
|
||||
p1 = os.path.join(DOCS, 'decode_preview.png')
|
||||
sheet.save(p1)
|
||||
log(' 61 帧总览:%s' % p1)
|
||||
samp, c2 = [0, 15, 30, 45, 60], 200
|
||||
sh2 = Image.new('RGB', (len(samp) * (c2 + 8) + 8, 2 * (c2 + 30) + 8), (28, 28, 32))
|
||||
d = ImageDraw.Draw(sh2)
|
||||
for k, i in enumerate(samp):
|
||||
pal, idx = decoded['replace'][i]
|
||||
_, spal, sidx = src_indexed(src_blob[i])
|
||||
x = 8 + k * (c2 + 8)
|
||||
sh2.paste(Image.fromarray(rgb_of(spal, sidx).astype(np.uint8), 'RGB')
|
||||
.resize((c2, c2), Image.LANCZOS), (x, 28))
|
||||
sh2.paste(Image.fromarray(rgb_of(pal, idx).astype(np.uint8), 'RGB')
|
||||
.resize((c2, c2), Image.NEAREST), (x, 28 + c2 + 26))
|
||||
d.text((x + 4, 8), 'q63 charge%d (464)' % i, fill=(200, 200, 200))
|
||||
d.text((x + 4, 28 + c2 + 8), 'face charge%d (480)' % i, fill=(120, 220, 160))
|
||||
p2 = os.path.join(DOCS, 'face_before_after.png')
|
||||
sh2.save(p2)
|
||||
log(' 抽样对照:%s' % p2)
|
||||
|
||||
log('\n' + '=' * 78)
|
||||
log('RESULT: %s —— %d 项通过,%d 项失败' % ('全部通过' if fail == 0 else '有失败', ok, fail))
|
||||
log('=' * 78)
|
||||
open(os.path.join(DOCS, 'selfcheck_report.txt'), 'w', encoding='utf-8').write('\n'.join(REPORT) + '\n')
|
||||
print('report -> ' + os.path.join(DOCS, 'selfcheck_report.txt'))
|
||||
sys.exit(1 if fail else 0)
|
||||
@@ -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('<III', blob[12:24])
|
||||
if len(L.rle_decompress(blob[24:24 + _cs], 1, None)) != _us:
|
||||
bad_dec += 1; continue
|
||||
try:
|
||||
if L.decode_lvgl_bin(blob)[1] is None:
|
||||
bad_dec += 1
|
||||
except Exception:
|
||||
bad_dec += 1
|
||||
ck(bad_len == 0, '有 %d 条长度 != 原厂槽位长度' % bad_len)
|
||||
ck(bad_dec == 0, '有 %d 条解不出 480x480' % bad_dec)
|
||||
ck(bad_bytes == 0, '有 %d 条 != %s 的字节' % (bad_bytes, '改后镜像' if ROLE == 'replace' else '原厂镜像'))
|
||||
print(' pay 检查 %d 条:长度全对 %s,可解码 %s,字节与%s一致 %s'
|
||||
% (len(data), bad_len == 0, bad_dec == 0, '改后镜像' if ROLE == 'replace' else '原厂镜像', bad_bytes == 0))
|
||||
|
||||
bad_fp = 0
|
||||
for o, t, ln in rb:
|
||||
if ln == 0 or (t == 0 and ln == 32): # canary 那条的指纹是空表
|
||||
continue
|
||||
if ln != FP_LEN or t not in slots or face[o:o + ln] != EXPECT_FP[t:t + ln]:
|
||||
bad_fp += 1
|
||||
ck(bad_fp == 0, '有 %d 条指纹不对(应为另一侧前 64 B)' % bad_fp)
|
||||
print(' rb 指纹 %d 条:全部 64 B 且与另一侧一致 %s' % (len(rb), bad_fp == 0))
|
||||
|
||||
p2 = 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 p2.entries()]
|
||||
ck(a == b, 'inode 表变了')
|
||||
ck(stock.sb_size == p2.sb_size and stock.sb_cksum == p2.sb_cksum, 'superblock 变了')
|
||||
ck(len(stock_b) == len(patch_b), '分区长度变了')
|
||||
ck(len(face) < 20 * 1048576, '交付物体积异常:%d B' % len(face))
|
||||
print('\n%s —— %d 项通过,%d 项失败' % ('全部通过' if fail == 0 else '有失败', ok, fail))
|
||||
sys.exit(1 if fail else 0)
|
||||
Reference in New Issue
Block a user