Files
o62lte-customising-plan/s5e-icons/tools/s5e_build.py
T
2026-10-02 21:34:56 +08:00

267 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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))