267 lines
12 KiB
Python
267 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Two-pass build for the S5 eSIM icon face.
|
||
|
||
watchface_s5e/main.lua 源码(含 __UIIMG__ / __PAY__ / __RB__ 占位符)
|
||
build_overlay_s5e/S5eIcons 中间产物(回填后的 Lua 落在这里)
|
||
_build_s5e/S5eIcons 编译工作区(Compiler.exe 的输入)
|
||
_out/faces/S5eIcons.face 交付物
|
||
|
||
为什么两遍:.face 里各资源的偏移取决于打包顺序,我们靠在 .face 里搜字节来定位。
|
||
Lua 源码排在所有资源之后,所以回填(Lua 变长)不会移动前面的资源;脚本最后逐条校验 0 漂移。
|
||
"""
|
||
import os, sys, json, shutil, subprocess, hashlib, struct
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
from PIL import Image, ImageDraw
|
||
import render_ui as R
|
||
from s5e_pages import PAGES
|
||
|
||
from paths import PROJ, WORK, DIST, TEMPLATE
|
||
WF = PROJ # 源码就在项目根
|
||
TPL = TEMPLATE
|
||
OVER = os.path.join(WORK, 'overlay', 'S5eIcons')
|
||
BUILD = os.path.join(WORK, 'build', 'S5eIcons')
|
||
OUTFACE = DIST
|
||
NAME = 'S5eIcons'
|
||
WF_ID = '462150101' # 462 = S5 家族(固件自带 AOD 表盘是 462150001..8)
|
||
DEVICE_TYPE = '462'
|
||
SCREEN = 480
|
||
PREVIEW = 326
|
||
MAGIC_PAY = b'\x00S5E-PAYv1\x00\xa5\x5a\xc3\x3c'
|
||
MAGIC_RB = b'\x00S5E-RBKv1\x00\x5a\xa5\x3c\xc3'
|
||
MAGIC_LEN = len(MAGIC_PAY) # 15 —— 别再手写常量,之前写 16 让偏移整体差 1
|
||
LOG = []
|
||
|
||
|
||
def log(s):
|
||
print(s)
|
||
LOG.append(s)
|
||
|
||
|
||
def make_preview(path):
|
||
"""480 首页 -> 圆形 alpha -> 326x326(Compiler.exe 只收这个尺寸)"""
|
||
R.W = R.H = SCREEN
|
||
items = PAGES['home']
|
||
img = Image.new('RGBA', (SCREEN, SCREEN), (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(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, SCREEN), (0, 0, 0, 255))
|
||
base.alpha_composite(img)
|
||
mask = Image.new('L', (SCREEN, SCREEN), 0)
|
||
ImageDraw.Draw(mask).ellipse([0, 0, SCREEN - 1, SCREEN - 1], fill=255)
|
||
base.putalpha(mask)
|
||
base.resize((PREVIEW, PREVIEW), Image.LANCZOS).save(path)
|
||
|
||
|
||
def prepare(lua_text):
|
||
if os.path.exists(BUILD):
|
||
shutil.rmtree(BUILD)
|
||
shutil.copytree(TPL, BUILD)
|
||
old = os.path.join(BUILD, 'watchface', 'fprj', 'LuaDevTemplate.fprj')
|
||
if os.path.exists(old):
|
||
os.remove(old)
|
||
|
||
cfg = {"projectName": NAME, "watchfaceId": WF_ID, "power_consumption": "3",
|
||
"resourceBin": {"lvglVersion": 9, "colorFormat": "I8", "compress": "NONE",
|
||
"input": "watchface/fprj/images/preview.png", "name": "preview"}}
|
||
json.dump(cfg, open(os.path.join(BUILD, 'watchface.config.json'), 'w', encoding='utf-8'),
|
||
indent=4, ensure_ascii=False)
|
||
|
||
fprj = ('<?xml version="1.0" encoding="utf-16" ?>\r\n'
|
||
'<FaceProject DeviceType="%s">\r\n'
|
||
' <Screen Title="%s" Bitmap="preview.png">\r\n'
|
||
' <Widget Shape="34" Name="app_lua%%2Fmain.lua" X="0" Y="0" '
|
||
'Width="%d" Height="%d" Alpha="0" />\r\n'
|
||
' </Screen>\r\n</FaceProject>\r\n') % (DEVICE_TYPE, NAME, SCREEN, SCREEN)
|
||
open(os.path.join(BUILD, 'watchface', 'fprj', NAME + '.fprj'), 'w',
|
||
encoding='utf-16', newline='').write(fprj)
|
||
|
||
fprjdir = os.path.join(BUILD, 'watchface', 'fprj')
|
||
make_preview(os.path.join(fprjdir, 'images', 'preview.png'))
|
||
open(os.path.join(fprjdir, 'app', 'lua', 'main.lua'), 'w',
|
||
encoding='utf-8', newline='\n').write(lua_text)
|
||
|
||
imgdir = os.path.join(fprjdir, 'app', 'images')
|
||
for sub, src in (('pay', os.path.join(WF, 'payload')),
|
||
('ui', os.path.join(WF, 'ui_images'))):
|
||
dst = os.path.join(imgdir, sub)
|
||
if os.path.exists(dst):
|
||
shutil.rmtree(dst)
|
||
os.makedirs(dst)
|
||
for fn in sorted(os.listdir(src)):
|
||
shutil.copyfile(os.path.join(src, fn), os.path.join(dst, fn))
|
||
shutil.copyfile(os.path.join(WF, 'payload_rollback', 'rollback.bin'),
|
||
os.path.join(imgdir, 'pay', 'rollback.bin'))
|
||
|
||
|
||
def run_build(script=True):
|
||
ps = os.path.join(BUILD, 'scripts', 'build_face.ps1')
|
||
r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps],
|
||
capture_output=True, text=True, errors='replace')
|
||
tail = [l for l in ((r.stdout or '') + (r.stderr or '')).splitlines() if l.strip()][-6:]
|
||
for l in tail:
|
||
log(' | ' + l)
|
||
face = os.path.join(BUILD, 'bin', NAME + '.face')
|
||
if not os.path.exists(face):
|
||
raise SystemExit('build failed: no ' + face)
|
||
return open(face, 'rb').read()
|
||
|
||
|
||
def compile_raw():
|
||
exe = os.path.join(BUILD, 'watchface', 'tools', 'Compiler.exe')
|
||
fprj = os.path.join(BUILD, 'watchface', 'fprj', NAME + '.fprj')
|
||
out = os.path.join(BUILD, 'raw')
|
||
if os.path.exists(out):
|
||
shutil.rmtree(out)
|
||
os.makedirs(out)
|
||
subprocess.run([exe, '-b', fprj, out, NAME + '.face', WF_ID],
|
||
capture_output=True, text=True, errors='replace')
|
||
p = os.path.join(out, NAME + '.face')
|
||
if not os.path.exists(p):
|
||
raise SystemExit('raw compile failed')
|
||
return open(p, 'rb').read()
|
||
|
||
|
||
def hits(face, blob):
|
||
out, s = [], 0
|
||
while True:
|
||
i = face.find(blob, s)
|
||
if i < 0:
|
||
return out
|
||
out.append(i)
|
||
s = i + 1
|
||
|
||
|
||
def locate(face, rels):
|
||
"""量出 UI 图片 / 负载 在 .face 里的偏移,并逐条字节校验"""
|
||
ui = []
|
||
for fn in sorted(os.listdir(os.path.join(WF, 'ui_images'))):
|
||
if not fn.endswith('.bin'):
|
||
continue
|
||
blob = open(os.path.join(WF, 'ui_images', fn), 'rb').read()
|
||
h = hits(face, blob)
|
||
if len(h) != 1:
|
||
raise SystemExit('UI %s 命中 %d 次' % (fn, len(h)))
|
||
ui.append((h[0], len(blob), fn[:-4]))
|
||
pay_base = hits(face, MAGIC_PAY)
|
||
rb_base = hits(face, MAGIC_RB)
|
||
if len(pay_base) != 1 or len(rb_base) != 1:
|
||
raise SystemExit('payload magic 命中 %d/%d 次(应为 1)' % (len(pay_base), len(rb_base)))
|
||
pay_base, rb_base = pay_base[0], rb_base[0]
|
||
|
||
hashes = {'pay': {i: r['sha256'] for i, r in enumerate(recs)},
|
||
'rb': {i: r['rb_sha256'] for i, r in enumerate(recs)}}
|
||
for tag, base, path in (('pay', pay_base, os.path.join(WF, 'payload', 'payload.bin')),
|
||
('rb', rb_base, os.path.join(WF, 'payload_rollback', 'rollback.bin'))):
|
||
data = open(path, 'rb').read()
|
||
# 整个负载文件必须**原样连续**放在 base 处 —— 这是"偏移=base+magic+rel"成立的前提
|
||
if face.find(data) != base:
|
||
raise SystemExit('%s 文件不是原样连续存放(找不到或位置不符)' % tag)
|
||
for i, (off, ln) in enumerate(rels[tag]):
|
||
res = base + MAGIC_LEN + off
|
||
if i == 0:
|
||
log(' %s 资源偏移 0x%x(%%4=%d,运行时走 /tmp 中转,不依赖对齐)'
|
||
% (tag, res, res % 4))
|
||
# 用独立的 sha256(来自生成负载时的 manifest)校验,避免"自己比自己"这种恒真检查
|
||
seg = face[res:res + ln]
|
||
if len(seg) != ln or hashlib.sha256(seg).hexdigest() != hashes[tag][i]:
|
||
raise SystemExit('%s[%d] 在 0x%x 处字节不吻合' % (tag, i, res))
|
||
return ui, pay_base, rb_base
|
||
|
||
|
||
FACE = None
|
||
rels = {'pay': [], 'rb': []}
|
||
recs = json.load(open(os.path.join(WORK, 's5e_from_os4', 'manifest.json'), encoding='utf-8'))['records']
|
||
off = 0
|
||
for r in recs:
|
||
rels['pay'].append((off, r['length']))
|
||
off += r['length']
|
||
off = 0
|
||
for r in recs:
|
||
rels['rb'].append((off, r['length']))
|
||
off += r['length']
|
||
|
||
src = open(os.path.join(WF, 'main.lua'), encoding='utf-8').read()
|
||
for ph in ('__UIIMG__', '__PAY__', '__RB__'):
|
||
if ph not in src:
|
||
raise SystemExit('watchface_s5e/main.lua 缺少占位符 ' + ph)
|
||
|
||
# ---------------------------------------------------------------- pass 1
|
||
log('第 1 遍:占位符版编一次,量出资源偏移')
|
||
prepare(src)
|
||
face1 = run_build()
|
||
log(' face %d bytes' % len(face1))
|
||
ui, pay_base, rb_base = locate(face1, rels)
|
||
for o, l, n in ui:
|
||
log(' %-12s off=0x%06x len=%d' % (n, o, l))
|
||
log(' payload base=0x%06x rollback base=0x%06x' % (pay_base, rb_base))
|
||
|
||
ui_txt = '\n'.join(' { %d, %d, "%s" },' % (o, l, n) for o, l, n in ui)
|
||
pay_txt = ' pay = {\n' + '\n'.join(
|
||
' { "%s", 0x%08x, %d, %d },' % (r['dev'], r['offset'], r['length'], pay_base + MAGIC_LEN + rels['pay'][i][0])
|
||
for i, r in enumerate(recs)) + '\n },'
|
||
rb_txt = ' rb = {\n' + '\n'.join(
|
||
' { "%s", 0x%08x, %d, %d },' % (r['dev'], r['offset'], r['length'], rb_base + MAGIC_LEN + rels['rb'][i][0])
|
||
for i, r in enumerate(recs)) + '\n },'
|
||
resolved = src.replace('__UIIMG__', ui_txt).replace('__PAY__', pay_txt).replace('__RB__', rb_txt)
|
||
assert '__UIIMG__' not in resolved and '__PAY__' not in resolved and '__RB__' not in resolved
|
||
overlay_lua = os.path.join(OVER, 'watchface', 'fprj', 'app', 'lua')
|
||
os.makedirs(overlay_lua, exist_ok=True)
|
||
open(os.path.join(overlay_lua, 'main.lua'), 'w', encoding='utf-8', newline='\n').write(resolved)
|
||
log('第 2 遍:回填后的 Lua(%d bytes)重新编译' % len(resolved))
|
||
|
||
# ---------------------------------------------------------------- pass 2
|
||
prepare(resolved)
|
||
face2 = run_build()
|
||
ui2, pay_base2, rb_base2 = locate(face2, rels)
|
||
drift = 0
|
||
if (pay_base2, rb_base2) != (pay_base, rb_base) or ui2 != ui:
|
||
drift = 1
|
||
log('偏移校验:%s' % ('0 漂移' if not drift else '!! 有漂移'))
|
||
if drift:
|
||
raise SystemExit('resource offsets drifted between pass 1 and pass 2')
|
||
|
||
# ---------------------------------------------------------------- finalize
|
||
raw = compile_raw()
|
||
d = bytearray(raw)
|
||
assert d[0:4] == b'\x5a\xa5\x34\x12', 'bad face magic'
|
||
d[40:50] = WF_ID.encode() + b'\x00' * (10 - len(WF_ID))
|
||
os.makedirs(OUTFACE, exist_ok=True)
|
||
faithful = os.path.join(OUTFACE, NAME + '.face')
|
||
open(faithful, 'wb').write(bytes(d))
|
||
toolchain = os.path.join(OUTFACE, NAME + '_toolchain.face')
|
||
shutil.copyfile(os.path.join(BUILD, 'bin', NAME + '.face'), toolchain)
|
||
|
||
# 交付产物独立自查:直接从最终 .face 里抠出来比对
|
||
fin = open(faithful, 'rb').read()
|
||
ui3, pb3, rb3 = locate(fin, rels)
|
||
assert ui3 == ui and pb3 == pay_base and rb3 == rb_base
|
||
for i, r in enumerate(recs):
|
||
res = pb3 + MAGIC_LEN + rels['pay'][i][0]
|
||
assert fin[res:res + r['length']] == open(os.path.join(WF, 'payload', 'payload.bin'), 'rb').read()[MAGIC_LEN + rels['pay'][i][0]:MAGIC_LEN + rels['pay'][i][0] + r['length']]
|
||
log('')
|
||
log('%-34s %10s b4 b5 id sha256[:16]' % ('file', 'bytes'))
|
||
for p in (faithful, toolchain):
|
||
b = open(p, 'rb').read()
|
||
log('%-34s %10d %2d %2d %-10r %s' % (os.path.basename(p), len(b), b[4], b[5], b[40:50],
|
||
hashlib.sha256(b).hexdigest()[:16]))
|
||
_p32, = struct.unpack('<I', fin[32:36])
|
||
_pal, _pw, _ph, _psz = struct.unpack('<IHHI', fin[_p32:_p32 + 12])
|
||
log('preview @0x%x: %dx%d, palette %d B, data %d B' % (_p32, _pw, _ph, _pal, _psz))
|
||
log('交付物:' + faithful)
|
||
open(os.path.join(WORK, 's5e_build_log.txt'), 'w', encoding='utf-8').write('\n'.join(LOG))
|