Initial Commit
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
"""Build / apply a system-app launcher-icon replacement for a Vela `vela_app.bin`.
|
||||
|
||||
Why "same length" is the whole ball game
|
||||
----------------------------------------
|
||||
ROMFS packs superblock -> inode -> name -> file data strictly back to back, and
|
||||
every `next` / `spec` pointer is an ABSOLUTE offset. If a replacement file
|
||||
changes length by even one byte, every later offset shifts and the image is
|
||||
corrupt. So a replacement must encode to EXACTLY the original byte length:
|
||||
|
||||
* source PNG == original dimensions -> encode, lengths match, done.
|
||||
* source PNG != original dimensions -> two options:
|
||||
(default) resize to the original dimensions (icon renders identically)
|
||||
--pad keep the source dimensions, zero-pad the container to the
|
||||
original length (renders smaller unless the launcher scales)
|
||||
|
||||
Usage:
|
||||
iconpack.py list <vela_app.bin>
|
||||
iconpack.py make <vela_app.bin> <pngdir> <outdir> [--pad] [--patched]
|
||||
iconpack.py apply <vela_app.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,150 @@
|
||||
"""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
|
||||
@@ -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,193 @@
|
||||
"""按页面规格渲染 466x466 预览图(真 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
|
||||
ROOT = PROJ
|
||||
FONT_FILES = {'semibold': 'MiSans-Semibold.ttf', 'medium': 'MiSans-Medium.ttf',
|
||||
'regular': 'MiSans-Regular.ttf'}
|
||||
W = H = 466
|
||||
_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
|
||||
outs = []
|
||||
for name, items in PAGES.items():
|
||||
p = os.path.join(ROOT, '_out', f'ui_{name}.png')
|
||||
render(items, p)
|
||||
outs.append(p)
|
||||
print('wrote', p)
|
||||
# 拼一张总览图
|
||||
cols = 3
|
||||
rows = (len(outs) + cols - 1) // cols
|
||||
sheet = Image.new('RGB', (cols * (W + 12) + 12, rows * (H + 12) + 12), (28, 28, 32))
|
||||
for i, p in enumerate(outs):
|
||||
im = Image.open(p)
|
||||
sheet.paste(im, (12 + (i % cols) * (W + 12), 12 + (i // cols) * (H + 12)))
|
||||
sp = os.path.join(ROOT, '_out', '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,266 @@
|
||||
# -*- 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))
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Round-screen clipping check for the 480 spec (measures real rendered pixels)."""
|
||||
import os, sys, math
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import render_ui as R
|
||||
from PIL import Image, ImageDraw
|
||||
W = H = 480
|
||||
CX = CY = 240.0
|
||||
RAD = 240.0
|
||||
from s5e_pages import PAGES
|
||||
|
||||
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)
|
||||
(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)
|
||||
return img
|
||||
|
||||
print('%-10s %8s %11s worst point' % ('page', 'max r', 'outside px'))
|
||||
print('-' * 60)
|
||||
bad = 0
|
||||
for name, items in PAGES.items():
|
||||
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
|
||||
print('%-10s %8.1f %11d %s%s' % (name, worst, outside, wpt,
|
||||
'' if worst <= RAD else ' <-- CLIPPED'))
|
||||
if outside: bad += 1
|
||||
print('-' * 60)
|
||||
print('centre (240,240) radius 240 canvas 480x480 pages clipped:', bad)
|
||||
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Xiaomi Watch S5 eSIM (480x480) page spec.
|
||||
|
||||
Same design language as the 466x466 S5->S4 face, uniformly scaled by 480/466 and
|
||||
re-centred; every coordinate here is what watchface_s5e/main.lua uses, and
|
||||
_tools/s5e_render_ui.py draws exactly this spec so the previews are honest.
|
||||
"""
|
||||
import os
|
||||
|
||||
from paths import PROJ, DOCS
|
||||
ROOT = PROJ
|
||||
ASSETS = os.path.join(ROOT, 'assets')
|
||||
W = H = 480
|
||||
BG = (0, 0, 0)
|
||||
GRAY = '999999'
|
||||
WHITE = 'ffffff'
|
||||
PLATE = '002927'
|
||||
CIRCLE = '4f4f4f'
|
||||
OK = '43cf7c'
|
||||
ERR = 'd43030'
|
||||
S600, S500 = 'semibold', 'medium'
|
||||
|
||||
# list item geometry
|
||||
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
|
||||
ICON_PNG = {'ic_help': 'MdiLightbulbOutline.png', 'ic_replace': 'MdiBandage.png',
|
||||
'ic_about': 'MdiInformationOutline.png', 'ic_check': 'MdiClipboardTextSearchOutline.png',
|
||||
'ic_rollback': 'MdiReplyAllOutline.png', 'ic_qq': 'qq.png'}
|
||||
|
||||
|
||||
def item(y, iconkey, title):
|
||||
return [
|
||||
dict(kind='rect', x=ITEM_X, y=y, w=ITEM_W, h=ITEM_H, bg=PLATE, radius=ITEM_R),
|
||||
dict(kind='circle', x=ITEM_X + ICONC_DX, y=y + ICONC_DY, w=ICONC_SZ, h=ICONC_SZ, bg=CIRCLE),
|
||||
dict(kind='image', x=ITEM_X + ICON_DX, y=y + ICON_DY, w=ICON_SZ, h=ICON_SZ,
|
||||
src=os.path.join(ASSETS, ICON_PNG[iconkey])),
|
||||
dict(kind='label', x=ITEM_X + TITLE_DX, y=y + TITLE_DY, w=TITLE_W, h=TITLE_H,
|
||||
text=title, size=29, weight=S500, color=WHITE, align='left', vtop=True),
|
||||
]
|
||||
|
||||
|
||||
def clock(t='10:24'):
|
||||
return dict(kind='label', x=201, y=4, w=78, h=46, text=t, size=29,
|
||||
weight=S600, color=GRAY, align='left', vtop=True)
|
||||
|
||||
|
||||
def title(text, x, w=260):
|
||||
return dict(kind='label', x=x, y=36, w=w, h=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):
|
||||
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),
|
||||
]
|
||||
|
||||
|
||||
PAGE_HOME = [clock(), title('OS4 Icons', 164, 152),
|
||||
*item(88, 'ic_help', '快速帮助'),
|
||||
*item(186, 'ic_replace', '开始替换'),
|
||||
*item(285, 'ic_about', '关于')]
|
||||
|
||||
_HELP_HEAD = [clock(), back_arrow(167), title('快速帮助', 188, 124)]
|
||||
_HELP_D1 = dict(kind='label', x=67, y=182, w=348, h=76,
|
||||
text='检查 app / health 两个分区\n与 47 条负载的完整性。', size=25,
|
||||
weight=S500, color=GRAY, align='left', vtop=True)
|
||||
_HELP_I2 = item(264, 'ic_rollback', '回滚修改')
|
||||
_HELP_D2 = dict(kind='label', x=67, y=355, w=348, h=36,
|
||||
text='恢复 47 个图标的原始样式。', size=25,
|
||||
weight=S500, color=GRAY, align='left', vtop=True)
|
||||
PAGE_HELP = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [_HELP_D1] + _HELP_I2 + [_HELP_D2]
|
||||
|
||||
HELP_DESC_OK = dict(kind='label', x=67, y=182, w=348, h=76,
|
||||
text='预检查通过 | 47 条 | 3.72MB\n两个分区都可读。',
|
||||
size=25, weight=S500, color=OK, align='left', vtop=True)
|
||||
HELP_DESC_ERR = dict(kind='label', x=67, y=182, w=348, h=76,
|
||||
text='预检查失败:设备节点不可读\n或分区头不是 ROMFS。',
|
||||
size=25, weight=S500, color=ERR, align='left', vtop=True)
|
||||
PAGE_HELP_OK = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_OK] + _HELP_I2 + [_HELP_D2]
|
||||
PAGE_HELP_ERR = _HELP_HEAD + item(88, 'ic_check', '环境预检查') + [HELP_DESC_ERR] + _HELP_I2 + [_HELP_D2]
|
||||
|
||||
WARN_LABELS = [
|
||||
dict(kind='label', x=60, y=93, w=360, h=36,
|
||||
text='替换图标包之前,请先确认自己', size=25, weight=S500, color=GRAY,
|
||||
align='left', vtop=True),
|
||||
dict(kind='label', x=60, y=126, w=360, h=36,
|
||||
text='的系统版本号为 ', size=25, weight=S500, color=GRAY, align='left', vtop=True),
|
||||
dict(kind='label', x=244, y=126, w=120, h=36, text='3.112.035',
|
||||
size=25, weight=S500, color=WHITE, align='left', vtop=True),
|
||||
dict(kind='label', x=356, y=126, w=40, h=36, text='!',
|
||||
size=25, weight=S500, color=GRAY, align='left', vtop=True),
|
||||
dict(kind='label', x=60, y=196, w=360, h=36, text='请确认自己的手表型号为',
|
||||
size=25, weight=S500, color=GRAY, align='left', vtop=True),
|
||||
dict(kind='label', x=60, y=229, w=372, h=36, text='Xiaomi Watch S5 eSIM 46mm',
|
||||
size=25, weight=S500, color=WHITE, align='left', vtop=True),
|
||||
dict(kind='label', x=427, y=229, w=40, h=36, text='!',
|
||||
size=25, weight=S500, color=GRAY, align='left', vtop=True),
|
||||
dict(kind='label', x=60, y=331, w=360, 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, '确认替换')
|
||||
|
||||
LOG_MID = ('> 开始替换\n'
|
||||
'> app 33 条 / health 14 条\n'
|
||||
'> 写入 20/47\n'
|
||||
'> 替换完毕,请重启手表。\n'
|
||||
'> 若发现问题,请回滚修改。')
|
||||
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_ABOUT = [
|
||||
clock(), back_arrow(198), title('关于', 220, 62),
|
||||
dict(kind='rect', x=54, y=81, w=373, h=210, bg=PLATE, radius=31),
|
||||
dict(kind='image', x=200, y=111, w=80, h=80, src=os.path.join(DOCS, 'logo_preview_480.png')),
|
||||
dict(kind='label', x=170, y=198, w=141, h=40, text='OS4 Icons',
|
||||
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,
|
||||
'confirm': PAGE_CONFIRM, 'running': PAGE_RUNNING, 'about': PAGE_ABOUT}
|
||||
@@ -0,0 +1,808 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""S5 41mm (q63, OS4) launcher icons -> Xiaomi Watch S5 eSIM (p62lte, 480x480).
|
||||
|
||||
Target slots live in TWO partitions:
|
||||
vela_app.bin (/dev/app) 34 launcher.bin
|
||||
vela_health.bin (/dev/health) 15 launcher.bin
|
||||
All 49 target slots are 144x144 ARGB8888 (82956 bytes) -- so the replacement is
|
||||
ALWAYS exactly one slot long, which is what keeps the ROMFS absolute offsets valid.
|
||||
|
||||
Sizing convention (measured, not guessed):
|
||||
the S5 eSIM stock art is inset in the 144 canvas (136x136 at (4,4) for almost
|
||||
every slot, 138 at (3,3) for launcher_*, 144 full-bleed for a few), while the
|
||||
OS4 source art is mostly full-bleed 144x144. So each source is cropped to its
|
||||
painted bbox, scaled to the TARGET slot's own painted size and pasted at the
|
||||
TARGET slot's origin. Sources that already match are copied without resampling
|
||||
(bit-exact).
|
||||
|
||||
Outputs
|
||||
watchface_s5e/payload/payload.bin MAGIC + concat(replace blobs)
|
||||
watchface_s5e/payload/payload.lua per-record table for main.lua
|
||||
watchface_s5e/payload_rollback/... same for the stock bytes
|
||||
_out/s5e_from_os4/vela_app_os4.bin whole-image variants (route C)
|
||||
_out/s5e_from_os4/vela_health_os4.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 decode_lvgl_bin
|
||||
import iconpack as ip
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from paths import PROJ, INPUTS, WORK
|
||||
NEW = os.path.join(INPUTS, 'target') # 目标固件分区(p62lte)
|
||||
OLD = os.path.join(INPUTS, 'source') # 图标来源分区(q63 = S5 41mm)
|
||||
WF = PROJ
|
||||
OUT = os.path.join(WORK, 's5e_from_os4') # manifest + 整分区镜像(产物)
|
||||
|
||||
PART = [('app', 'vela_app.bin', '/dev/app'),
|
||||
('health', 'vela_health.bin', '/dev/health')]
|
||||
MAGIC_PAY = b'\x00S5E-PAYv1\x00\xa5\x5a\xc3\x3c' # 15 B, must stay unique in the .face
|
||||
MAGIC_RB = b'\x00S5E-RBKv1\x00\x5a\xa5\x3c\xc3'
|
||||
|
||||
# 自制图覆盖:优先级高于固件来源(和 S4 项目同一套做法)。
|
||||
# activities : 112x112 的红/绿/青同心环 = 用户要的「跑道」图标
|
||||
# voice_aivs : 144x144 的「小爱同学」用户 PNG
|
||||
OVERRIDE = {k: os.path.join(PROJ, 'assets', 'override', k + '.png')
|
||||
for k in ('activities', 'voice_aivs')}
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
os.makedirs(os.path.join(WF, 'payload'), exist_ok=True)
|
||||
os.makedirs(os.path.join(WF, 'payload_rollback'), exist_ok=True)
|
||||
|
||||
|
||||
def ink_bbox(img, thr=16):
|
||||
a = img.convert('RGBA').getchannel('A')
|
||||
w, h = a.size
|
||||
px = a.load()
|
||||
x0, y0, x1, y1 = w, h, -1, -1
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if px[x, y] > thr:
|
||||
if x < x0: x0 = x
|
||||
if y < y0: y0 = y
|
||||
if x > x1: x1 = x
|
||||
if y > y1: y1 = y
|
||||
return None if x1 < 0 else (x0, y0, x1, y1)
|
||||
|
||||
|
||||
def normalize(src_img, slot_bb, canvas):
|
||||
"""crop to painted bbox -> scale to the slot's painted size -> paste at slot origin"""
|
||||
cw, ch = canvas
|
||||
if slot_bb is None:
|
||||
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
sx0, sy0, sx1, sy1 = slot_bb
|
||||
tw, th = sx1 - sx0 + 1, sy1 - sy0 + 1
|
||||
nb = ink_bbox(src_img)
|
||||
if nb is None:
|
||||
return Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
if (nb[2] - nb[0] + 1, nb[3] - nb[1] + 1) == (tw, th) and (nb[0], nb[1]) == (sx0, sy0):
|
||||
return src_img.convert('RGBA') # already identical geometry
|
||||
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
|
||||
if art.size != (tw, th):
|
||||
up = tw > art.size[0] or th > art.size[1]
|
||||
art = art.resize((tw, th), Image.LANCZOS)
|
||||
if up:
|
||||
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
|
||||
canvas_img = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
canvas_img.paste(art, (sx0, sy0))
|
||||
return canvas_img
|
||||
|
||||
|
||||
def normalize_fit(src_img, target_bb, canvas):
|
||||
"""等比缩放源实心区域到能放进 target_bb 的最大尺寸并居中(不拉伸)。"""
|
||||
cw, ch = canvas
|
||||
cv = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
if target_bb is None:
|
||||
return cv
|
||||
nb = ink_bbox(src_img)
|
||||
if nb is None:
|
||||
return cv
|
||||
tw, th = target_bb[2] - target_bb[0] + 1, target_bb[3] - target_bb[1] + 1
|
||||
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
|
||||
s = min(tw / art.size[0], th / art.size[1])
|
||||
nw, nh = max(1, round(art.size[0] * s)), max(1, round(art.size[1] * s))
|
||||
if (nw, nh) != art.size:
|
||||
up = s > 1.0
|
||||
art = art.resize((nw, nh), Image.LANCZOS)
|
||||
if up:
|
||||
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
|
||||
cv.paste(art, (target_bb[0] + (tw - nw) // 2, target_bb[1] + (th - nh) // 2))
|
||||
return cv
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ load slots
|
||||
slots = [] # ordered records
|
||||
ROM_N = {}
|
||||
for tag, fn, dev in PART:
|
||||
r = Romfs(os.path.join(NEW, fn))
|
||||
ROM_N[tag] = r
|
||||
tg = ip.targets(r)
|
||||
for name in sorted(tg):
|
||||
ino = tg[name]
|
||||
blob = r.read(ino)
|
||||
h, img = decode_lvgl_bin(blob)
|
||||
slots.append(dict(tag=tag, dev=dev, image=fn, name=name, ino=ino, blob=blob,
|
||||
h=h, img=img, bb=ink_bbox(img) if img is not None else None))
|
||||
|
||||
src = {}
|
||||
ROM_O = {}
|
||||
for fn in ['vela_app.bin', 'vela_health.bin']:
|
||||
r = Romfs(os.path.join(OLD, fn))
|
||||
ROM_O['app' if fn == 'vela_app.bin' else 'health'] = r
|
||||
for name, ino in ip.targets(r).items():
|
||||
if name in src: continue
|
||||
h, img = decode_lvgl_bin(r.read(ino))
|
||||
src[name] = (h, img, r.read(ino))
|
||||
|
||||
print('target slots %d source icons %d' % (len(slots), len(src)))
|
||||
rows, newblobs, stock_of, skipped = [], {}, {}, []
|
||||
for s in slots:
|
||||
h = s['h']
|
||||
if h is None:
|
||||
skipped.append((s['name'], 'target not decodable')); continue
|
||||
if s['name'] not in src:
|
||||
skipped.append((s['name'], 'no OS4 source')); continue
|
||||
sh, simg, sblob = src[s['name']]
|
||||
if simg is None:
|
||||
skipped.append((s['name'], 'source not decodable')); continue
|
||||
src_from = 'q63 固件 %s' % s['image']
|
||||
ov = OVERRIDE.get(s['name'])
|
||||
if ov and os.path.exists(ov):
|
||||
# 自制图覆盖:裁到实心 bbox -> 缩放到目标槽位的画法(136x136)-> 居中粘回 (4,4)
|
||||
simg = Image.open(ov).convert('RGBA')
|
||||
sblob = b''
|
||||
src_from = '自制图 %s (%dx%d)' % (os.path.basename(ov), simg.size[0], simg.size[1])
|
||||
print(' [override] %-14s <- %s %dx%d' % (s['name'], os.path.basename(ov),
|
||||
simg.size[0], simg.size[1]))
|
||||
out_img = normalize(simg, s['bb'], (h['w'], h['h']))
|
||||
blob = ip.encode(out_img, h['cf'], h['w'], h['h'], h['flags'])
|
||||
if len(blob) != s['ino'].size:
|
||||
skipped.append((s['name'], 'length %d != slot %d' % (len(blob), s['ino'].size))); continue
|
||||
exact = (blob == sblob)
|
||||
newblobs[s['name']] = blob
|
||||
stock_of[s['name']] = s['blob']
|
||||
rows.append(dict(name=s['name'], tag=s['tag'], dev=s['tag'], node=s['dev'], image=s['image'],
|
||||
offset=s['ino'].data_off, length=s['ino'].size,
|
||||
dim='%dx%d' % (h['w'], h['h']), cf=ip.FMT.get(h['cf'], hex(h['cf'])),
|
||||
stock_ink=s['bb'], source_from=src_from,
|
||||
identical_to_stock=exact,
|
||||
sha256=hashlib.sha256(blob).hexdigest(),
|
||||
rb_sha256=hashlib.sha256(s['blob']).hexdigest()))
|
||||
|
||||
print('%-24s %-7s %-12s %-14s %s' % ('name', 'part', 'offset', 'slot', 'stock ink -> new'))
|
||||
for s in slots:
|
||||
r = next((x for x in rows if x['name'] == s['name']), None)
|
||||
if r:
|
||||
print('%-24s %-7s 0x%08x %-8d %-14s' % (r['name'], r['tag'], r['offset'], r['length'],
|
||||
str(r['stock_ink']) + (' (identical)' if r['identical_to_stock'] else '')))
|
||||
for n, why in skipped:
|
||||
print('%-24s SKIP %s' % (n, why))
|
||||
print('\nreplace %d skip %d' % (len(rows), len(skipped)))
|
||||
|
||||
|
||||
# ------------------------------------------------- 系统 UI 素材:从 S5 41mm(q63) 搬过来
|
||||
# 只搬「独立字形图标」(锁屏 / 通知中心 / 设置 / 米家)。控制中心的开关磁贴**故意没搬**:
|
||||
# q63 的控制中心是白色主题(白盘 + 深色字形,运行时着色),S5 eSIM 是蓝色主题(预着色成品),
|
||||
# 只搬那 4 张会变成「白盘混在蓝盘里」;要搬就得整套 ~40 张一起搬。
|
||||
SYSICONS = [
|
||||
('app', 'system/icon/lock_icon.bin', '锁屏上的锁'),
|
||||
('app', 'common/icon/system_lock.bin', '状态栏系统锁'),
|
||||
('app', 'common/icon/lock.bin', '通用锁'),
|
||||
('app', 'system/icon/unlock.bin', '解锁提示页'),
|
||||
('app', 'common/icon/quiet_mode.bin', '免打扰/静音小图标'),
|
||||
('app', 'common/icon/unread_msg.bin', '未读消息红点'),
|
||||
('app', 'notifications/default.bin', '通知默认大图标'),
|
||||
('app', 'notifications/default_small.bin', '通知默认小图标'),
|
||||
('app', 'notifications/no_message.bin', '通知中心空状态'),
|
||||
('app', 'settings/icon/notify.bin', '设置-通知'),
|
||||
('app', 'settings/disturb/confirm.bin', '免打扰确认按钮'),
|
||||
('app', 'esimsms/icon/esim_sms_reminder.bin', '短信提醒'),
|
||||
('app', 'esimsms/icon/sms_no_read.bin', '未读短信'),
|
||||
('app', 'esimsms/icon/sms_reminder_watch.bin', '手表短信提醒'),
|
||||
('app', 'phone/icon/send_sms_new.bin', '新建短信'),
|
||||
('app', 'mijia/enable_donot_disturb.bin', '米家 免打扰开'),
|
||||
('app', 'mijia/disable_donot_disturb.bin', '米家 免打扰关'),
|
||||
('health', 'activities/icon/notification_stand.bin', '久坐提醒'),
|
||||
('health', 'activities/icon/notification_vigor.bin', '活力提醒'),
|
||||
# 压力 App 的小表情(4 档情绪 × 32/48/96,96 那三张两边本来就一样,会被自动跳过)
|
||||
('health', 'pressure/icon/presure_relax.bin', '压力表情-放松 48'),
|
||||
('health', 'pressure/icon/presure_mild.bin', '压力表情-轻度 48'),
|
||||
('health', 'pressure/icon/presure_mid.bin', '压力表情-中度 48'),
|
||||
('health', 'pressure/icon/presure_severe.bin', '压力表情-重度 48'),
|
||||
('health', 'pressure/icon/presure_relax32.bin', '压力表情-放松 32'),
|
||||
('health', 'pressure/icon/presure_mild32.bin', '压力表情-轻度 32'),
|
||||
('health', 'pressure/icon/presure_mid32.bin', '压力表情-中度 32'),
|
||||
('health', 'pressure/icon/presure_severe32.bin', '压力表情-重度 32'),
|
||||
('health', 'pressure/icon/presure_relax96.bin', '压力表情-放松 96'),
|
||||
('health', 'pressure/icon/presure_mild96.bin', '压力表情-轻度 96'),
|
||||
('health', 'pressure/icon/presure_mid96.bin', '压力表情-中度 96'),
|
||||
('health', 'pressure/icon/presure_severe96.bin', '压力表情-重度 96'),
|
||||
('health', 'pressure/icon/presure_icon.bin', '压力 App 小图标'),
|
||||
('health', 'pressure/icon/press_grade1.bin', '压力等级徽标 1'),
|
||||
('health', 'pressure/icon/press_grade2.bin', '压力等级徽标 2'),
|
||||
('health', 'pressure/icon/press_grade3.bin', '压力等级徽标 3'),
|
||||
('health', 'pressure/icon/press_grade4.bin', '压力等级徽标 4'),
|
||||
('health', 'pressure/icon/pressure_eomji_bg.bin', '表情底版'),
|
||||
('health', 'pressure/icon/pressure_eomji_widbg.bin', '表情底版(小)'),
|
||||
# 没有搬(体积大 / 不是表情):pressure_bg、success_bg、success_arc、
|
||||
# press_widget_bg*、press_chart_bg、pressure_widget_pre_img*、reminder、measure/*
|
||||
]
|
||||
|
||||
|
||||
# 整目录一起搬(q63 有同名文件的才搬):控制中心的开关磁贴
|
||||
SYSICONS_DIRS = ['control_center/icon/']
|
||||
|
||||
|
||||
def find_inode(romfs, path):
|
||||
for p, i in romfs.entries():
|
||||
if p == path and i.type != 1:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
print('\n--- 系统 UI 素材(锁屏 / 勿扰 / 通知 / 控制中心):q63 -> S5 eSIM ---')
|
||||
_jobs = [(d, p, n) for d, p, n in SYSICONS]
|
||||
_seen = {p for _, p, _ in _jobs}
|
||||
for _dev in ROM_N:
|
||||
for _p, _i in ROM_N[_dev].entries():
|
||||
if _i.type == 1 or _p in _seen:
|
||||
continue
|
||||
if any(_p.startswith(_d) for _d in SYSICONS_DIRS):
|
||||
_jobs.append((_dev, _p, '控制中心磁贴'))
|
||||
_seen.add(_p)
|
||||
for dev, path, note in _jobs:
|
||||
ti = find_inode(ROM_N[dev], path)
|
||||
oi = find_inode(ROM_O[dev], path)
|
||||
if ti is None:
|
||||
skipped.append((path, '目标固件没有这个文件')); continue
|
||||
if oi is None:
|
||||
skipped.append((path, 'q63 没有这个文件')); continue
|
||||
tgt_blob = ROM_N[dev].read(ti)
|
||||
src_blob = ROM_O[dev].read(oi)
|
||||
if src_blob == tgt_blob:
|
||||
print(' %-52s 两边字节完全相同,跳过' % path)
|
||||
continue
|
||||
th = img_header(tgt_blob)
|
||||
sh = img_header(src_blob)
|
||||
if len(src_blob) == len(tgt_blob):
|
||||
new, how = src_blob, 'copy(等长)'
|
||||
elif th and sh and th['cf'] == sh['cf']:
|
||||
timg = decode_lvgl_bin(tgt_blob)[1]
|
||||
simg = decode_lvgl_bin(src_blob)[1]
|
||||
if timg is None or simg is None:
|
||||
skipped.append((path, '无法解码')); continue
|
||||
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
|
||||
out = normalize_fit(simg, ink_bbox(timg), (th['w'], th['h']))
|
||||
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
|
||||
how = 'rescale %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
|
||||
else:
|
||||
skipped.append((path, '长度/格式不支持')); continue
|
||||
if len(new) != len(tgt_blob):
|
||||
skipped.append((path, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
|
||||
name = 'sys:' + path
|
||||
newblobs[name] = new
|
||||
stock_of[name] = tgt_blob
|
||||
rows.append(dict(name=name, kind='sysicon', path=path, note=note, how=how,
|
||||
tag=dev, dev=dev, node='/dev/' + dev, image=path,
|
||||
offset=ti.data_off, length=ti.size,
|
||||
dim=('%dx%d' % (th['w'], th['h'])) if th else '?',
|
||||
cf=ip.FMT.get(th['cf'], hex(th['cf'])) if th else '?',
|
||||
stock_ink=ink_bbox(decode_lvgl_bin(tgt_blob)[1]) if th and decode_lvgl_bin(tgt_blob)[1] else None,
|
||||
source_from='q63 固件 / ' + path, identical_to_stock=False,
|
||||
sha256=hashlib.sha256(new).hexdigest(),
|
||||
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
|
||||
print(' %-52s %-22s %s' % (path, how, note))
|
||||
|
||||
|
||||
# ------------------------------------------------- 对讲机素材(用户提供的整套 PNG)
|
||||
# walkie_talkie/ 下的槽位几乎**全是 I8+RLE 压缩**的:不能像图标那样直接编码(长度会变),
|
||||
# 必须走 ip.encode_into_slot():量化 -> RLE -> 用 ctrl=0 空指令补齐到原 csize,
|
||||
# 这样字节数与原槽位完全相等,ROMFS 后面所有偏移一个字节都不动。
|
||||
WALKIE_SRC = os.path.join(PROJ, 'assets', 'walkie_talkie')
|
||||
# 对讲机素材:**默认不打包**(用户 2026-10-02 决定不改)。ICON_WALKIE=1 可打开。
|
||||
DO_WALKIE = os.environ.get('ICON_WALKIE') == '1'
|
||||
print('\n--- 对讲机素材 walkie_talkie(用户 PNG)-> S5 eSIM ---')
|
||||
walkie_n = 0
|
||||
if not DO_WALKIE:
|
||||
print(' 按设置跳过这一组(ICON_WALKIE=1 可打开)')
|
||||
elif not os.path.isdir(WALKIE_SRC):
|
||||
print(' 没有 %s,跳过这一组' % WALKIE_SRC)
|
||||
else:
|
||||
rels = []
|
||||
for root, _, files in os.walk(WALKIE_SRC):
|
||||
for f in files:
|
||||
if f.lower().endswith('.png'):
|
||||
rels.append(os.path.relpath(os.path.join(root, f), WALKIE_SRC).replace('\\', '/'))
|
||||
for rel in sorted(rels):
|
||||
path = 'walkie_talkie/' + rel[:-4] + '.bin'
|
||||
dev = 'app'
|
||||
ti = find_inode(ROM_N[dev], path)
|
||||
if ti is None:
|
||||
skipped.append((path, '目标固件没有这个槽位')); continue
|
||||
tgt_blob = ROM_N[dev].read(ti)
|
||||
img = Image.open(os.path.join(WALKIE_SRC, rel))
|
||||
new, how = ip.encode_into_slot(img, tgt_blob, ti.size)
|
||||
if new is None:
|
||||
skipped.append((path, how)); continue
|
||||
if new == tgt_blob:
|
||||
print(' %-52s 与现有一致,跳过' % path); continue
|
||||
th = img_header(tgt_blob)
|
||||
name = 'walk:' + path
|
||||
newblobs[name] = new
|
||||
stock_of[name] = tgt_blob
|
||||
rows.append(dict(name=name, kind='walkie', path=path, note='对讲机素材', how=how,
|
||||
tag=dev, dev=dev, node='/dev/' + dev, image=path,
|
||||
offset=ti.data_off, length=ti.size,
|
||||
dim=('%dx%d' % (th['w'], th['h'])) if th else '?',
|
||||
cf=ip.FMT.get(th['cf'], hex(th['cf'])) if th else '?',
|
||||
stock_ink=None, source_from='用户 PNG assets/walkie_talkie/' + rel,
|
||||
identical_to_stock=False,
|
||||
sha256=hashlib.sha256(new).hexdigest(),
|
||||
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
|
||||
walkie_n += 1
|
||||
if walkie_n <= 6 or walkie_n % 40 == 0:
|
||||
print(' %-52s %-18s %d B' % (path, how, ti.size))
|
||||
print(' 对讲机素材写入 %d 条' % walkie_n)
|
||||
|
||||
|
||||
# ------------------------------------------------- 控制中心顶部状态区(q63 -> S5 eSIM)
|
||||
# q63 的顶栏素材在 control_center/bar/:wifi 4 档 / 蜂窝信号 5 档 / 3G·4G 文字 /
|
||||
# 蓝牙连接·断开 / 飞行模式 / 定位。本机同名槽位在 control_center/icon/(48x48 I8)。
|
||||
# 尺寸不同的(q63 那几张 36x36)按**实心范围**归一化(normalize(simg, ink_bbox(timg), ...))。
|
||||
CC_TOP = [
|
||||
('control_center/icon/network_4g.bin', 'control_center/bar/network_4g.bin', '4G 文字'),
|
||||
('control_center/icon/network_3g.bin', 'control_center/bar/network_3g.bin', '3G 文字'),
|
||||
('control_center/icon/signal_strength.bin', 'control_center/bar/signal_strength0.bin', '蜂窝信号 0'),
|
||||
('control_center/icon/signal_strength1.bin', 'control_center/bar/signal_strength1.bin', '蜂窝信号 1'),
|
||||
('control_center/icon/signal_strength2.bin', 'control_center/bar/signal_strength2.bin', '蜂窝信号 2'),
|
||||
('control_center/icon/signal_strength3.bin', 'control_center/bar/signal_strength3.bin', '蜂窝信号 3'),
|
||||
('control_center/icon/signal_strength4.bin', 'control_center/bar/signal_strength4.bin', '蜂窝信号 4'),
|
||||
('control_center/icon/air_mode_conn.bin', 'control_center/bar/air.bin', '飞行模式'),
|
||||
('control_center/icon/bt_connect.bin', 'control_center/bar/connect.bin', '蓝牙已连接手机'),
|
||||
('control_center/icon/bt_connect_g.bin', 'control_center/bar/connect.bin', '蓝牙已连接手机(g)'),
|
||||
('control_center/icon/bt_disconnect.bin', 'control_center/bar/disconnect.bin', '蓝牙未连接'),
|
||||
('control_center/icon/location.bin', 'control_center/bar/local.bin', '定位'),
|
||||
('control_center/icon/wifi_level_0.bin', 'control_center/bar/wifi_level_0.bin', 'WiFi 0'),
|
||||
('control_center/icon/wifi_level_1.bin', 'control_center/bar/wifi_level_1.bin', 'WiFi 1'),
|
||||
('control_center/icon/wifi_level_2.bin', 'control_center/bar/wifi_level_2.bin', 'WiFi 2'),
|
||||
('control_center/icon/wifi_level_3.bin', 'control_center/bar/wifi_level_3.bin', 'WiFi 3'),
|
||||
]
|
||||
# 手机电量(本机 phone/icon/battery_level_*,64x22):q63 同名但 64x32
|
||||
for _n in range(0, 11):
|
||||
CC_TOP.append(('phone/icon/battery_level_%d.bin' % _n,
|
||||
'phone/icon/battery_level_%d.bin' % _n, '手机电量 %d' % _n))
|
||||
|
||||
print('\n--- 控制中心顶部状态区(q63 control_center/bar -> S5 eSIM)---')
|
||||
cc_top_n = 0
|
||||
for tpath, spath, note in CC_TOP:
|
||||
ti = find_inode(ROM_N['app'], tpath)
|
||||
if ti is None:
|
||||
ti = find_inode(ROM_N['health'], tpath)
|
||||
dev = 'health'
|
||||
else:
|
||||
dev = 'app'
|
||||
if ti is None:
|
||||
skipped.append((tpath, '本机没有这个槽位')); continue
|
||||
oi = find_inode(ROM_O[dev], spath)
|
||||
if oi is None:
|
||||
skipped.append((spath, 'q63 没有这个素材')); continue
|
||||
tgt_blob = ROM_N[dev].read(ti)
|
||||
src_blob = ROM_O[dev].read(oi)
|
||||
th = img_header(tgt_blob)
|
||||
sh = img_header(src_blob)
|
||||
timg = decode_lvgl_bin(tgt_blob)[1]
|
||||
simg = decode_lvgl_bin(src_blob)[1]
|
||||
if timg is None or simg is None:
|
||||
skipped.append((tpath, '解码失败')); continue
|
||||
out = normalize(simg, ink_bbox(timg), (th['w'], th['h']))
|
||||
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
|
||||
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
|
||||
if len(new) != len(tgt_blob):
|
||||
failed.append((tpath, '编码 %d != 槽位 %d' % (len(new), len(tgt_blob)))); continue
|
||||
if new == tgt_blob:
|
||||
skipped.append((tpath, '两边已经一致')); continue
|
||||
# 与 SYSICONS 组同名 -> 同一槽位只保留一条记录(这里覆盖那边的旧风格版本)
|
||||
name = 'sys:' + tpath
|
||||
rows[:] = [r for r in rows if r['name'] != name]
|
||||
newblobs[name] = new
|
||||
stock_of[name] = tgt_blob
|
||||
rows.append(dict(name=name, kind='sysicon', path=tpath, note=note, how='归一化(%s)' % note,
|
||||
tag=dev, dev=dev, node='/dev/' + dev, image=tpath,
|
||||
offset=ti.data_off, length=ti.size,
|
||||
dim='%dx%d' % (th['w'], th['h']),
|
||||
cf=ip.FMT.get(th['cf'], hex(th['cf'])),
|
||||
stock_ink=ink_bbox(timg), source_from='q63 ' + spath,
|
||||
identical_to_stock=False,
|
||||
sha256=hashlib.sha256(new).hexdigest(),
|
||||
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
|
||||
cc_top_n += 1
|
||||
print(' %-46s <- %-40s %s' % (tpath, spath, note))
|
||||
print(' 顶栏状态区写入 %d 条' % cc_top_n)
|
||||
|
||||
|
||||
# ------------------------------------------------- 米家 / 融合中心(控制中心里的米家设备图标)
|
||||
# q63 的 fusion_center/** 与 mijia/** 里**同名路径**本机也有的就移植;本机还有一份
|
||||
# fusion_center/gauss/**(同名的一并写)。画布尺寸两边常常不同(q63 116/64 vs 本机 96/128),
|
||||
# 按**实心范围**对齐并转成本机槽位的格式。
|
||||
PLATE_KEEP = ('headset',) # 只有这种「裸图案」槽位保留底版 + 图案归位
|
||||
# 控制中心的米家/融合中心那组:**默认不打包**(用户 2026-10-02 决定不改)。ICON_MIJIA=1 可打开。
|
||||
DO_MIJIA = os.environ.get('ICON_MIJIA') == '1'
|
||||
# q63 的「柔光玻璃」底盘(干净的深色玻璃圆盘 + 发丝高光环)
|
||||
_glass = None
|
||||
_g = find_inode(ROM_O['app'], 'fusion_center/fg.bin')
|
||||
if _g is not None:
|
||||
_glass = decode_lvgl_bin(ROM_O['app'].read(_g))[1]
|
||||
if _glass is not None:
|
||||
_glass = _glass.convert('RGBA')
|
||||
print('柔光玻璃盘: q63 fusion_center/fg.bin %dx%d' % _glass.size)
|
||||
MIJIA_PREFIX = ('fusion_center/', 'mijia/')
|
||||
def plate_glyph_bbox(img, tol=26, min_plate=0.30):
|
||||
"""本机图标是「底版 + 图案」时,返回底版上图案的 bbox(底版色 = 出现最多的颜色)。"""
|
||||
rgba = img.convert('RGBA')
|
||||
w, h = rgba.size
|
||||
px = rgba.load()
|
||||
cnt = {}
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
p = px[x, y]
|
||||
if p[3] > 16:
|
||||
cnt[p] = cnt.get(p, 0) + 1
|
||||
if not cnt:
|
||||
return None
|
||||
plate, n = max(cnt.items(), key=lambda kv: kv[1])
|
||||
if n >= min_plate * w * h:
|
||||
xs, ys = [], []
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
p = px[x, y]
|
||||
if p[3] > 16 and (abs(p[0] - plate[0]) + abs(p[1] - plate[1]) + abs(p[2] - plate[2])) > tol:
|
||||
xs.append(x); ys.append(y)
|
||||
if xs:
|
||||
return (min(xs), min(ys), max(xs), max(ys))
|
||||
# 兜底:底版是渐变(没有主导纯色)时,用**亮度**找亮图案
|
||||
lum = {}
|
||||
mx = 0
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
p = px[x, y]
|
||||
if p[3] > 16:
|
||||
v = 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]
|
||||
lum[(x, y)] = v
|
||||
mx = max(mx, v)
|
||||
if not lum or mx <= 0:
|
||||
return None
|
||||
thr = 0.62 * mx
|
||||
xs = [x for (x, y), v in lum.items() if v >= thr]
|
||||
ys = [y for (x, y), v in lum.items() if v >= thr]
|
||||
if not xs:
|
||||
return None
|
||||
bb = (min(xs), min(ys), max(xs), max(ys))
|
||||
if (bb[2] - bb[0] + 1) * (bb[3] - bb[1] + 1) > 0.92 * w * h:
|
||||
return None
|
||||
return bb
|
||||
|
||||
|
||||
def ink_ratio(img):
|
||||
bb = ink_bbox(img)
|
||||
if bb is None:
|
||||
return 0.0
|
||||
w, h = img.size
|
||||
return max((bb[2] - bb[0] + 1) / w, (bb[3] - bb[1] + 1) / h)
|
||||
|
||||
|
||||
def normalize_fit(src_img, target_bb, canvas):
|
||||
"""等比缩放源实心区域到能放进 target_bb 的最大尺寸并居中(不拉伸)。"""
|
||||
cw, ch = canvas
|
||||
cv = Image.new('RGBA', (cw, ch), (0, 0, 0, 0))
|
||||
if target_bb is None:
|
||||
return cv
|
||||
nb = ink_bbox(src_img)
|
||||
if nb is None:
|
||||
return cv
|
||||
tw, th = target_bb[2] - target_bb[0] + 1, target_bb[3] - target_bb[1] + 1
|
||||
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
|
||||
s = min(tw / art.size[0], th / art.size[1])
|
||||
nw, nh = max(1, round(art.size[0] * s)), max(1, round(art.size[1] * s))
|
||||
if (nw, nh) != art.size:
|
||||
up = s > 1.0
|
||||
art = art.resize((nw, nh), Image.LANCZOS)
|
||||
if up:
|
||||
art = art.filter(ImageFilter.UnsharpMask(radius=2, percent=70, threshold=3))
|
||||
cv.paste(art, (target_bb[0] + (tw - nw) // 2, target_bb[1] + (th - nh) // 2))
|
||||
return cv
|
||||
|
||||
|
||||
|
||||
def overlay_glyph_in_place(tgt_blob, th, src_img, target_bb):
|
||||
"""在**索引层**把 q63 的裸图案画进本机 I8 图标:底版像素一个比特都不动。
|
||||
|
||||
底版是抖动/渐变,重新量化(用谁的调色板都一样)会出噪点或白环;只有直接改索引数组
|
||||
底版才保持原样。图案像素按最近色映射到**本机自己的调色板**(本机图标里本来就有白色)。
|
||||
"""
|
||||
w, h, cf = th['w'], th['h'], th['cf']
|
||||
if cf != ip.CF_I8 or (th['flags'] & 0x08):
|
||||
return None
|
||||
if len(tgt_blob) != 12 + 1024 + w * h:
|
||||
return None
|
||||
pal_b = tgt_blob[12:12 + 1024]
|
||||
pal = [(pal_b[i * 4 + 2], pal_b[i * 4 + 1], pal_b[i * 4 + 0], pal_b[i * 4 + 3]) for i in range(256)]
|
||||
idx = bytearray(tgt_blob[12 + 1024:12 + 1024 + w * h])
|
||||
nb = ink_bbox(src_img)
|
||||
if nb is None or target_bb is None:
|
||||
return None
|
||||
tw, thh = target_bb[2] - target_bb[0] + 1, target_bb[3] - target_bb[1] + 1
|
||||
art = src_img.convert('RGBA').crop((nb[0], nb[1], nb[2] + 1, nb[3] + 1))
|
||||
s = min(tw / art.size[0], thh / art.size[1])
|
||||
art = art.resize((max(1, round(art.size[0] * s)), max(1, round(art.size[1] * s))), Image.LANCZOS)
|
||||
ox = target_bb[0] + (tw - art.width) // 2
|
||||
oy = target_bb[1] + (thh - art.height) // 2
|
||||
cache = {}
|
||||
for y in range(art.height):
|
||||
for x in range(art.width):
|
||||
r, g, b, a = art.getpixel((x, y))
|
||||
if a < 24:
|
||||
continue
|
||||
key = (r, g, b, a)
|
||||
ci = cache.get(key)
|
||||
if ci is None:
|
||||
best, bd = 0, 1 << 30
|
||||
for k in range(256):
|
||||
pr, pg, pb, pa = pal[k]
|
||||
d = (pr - r) ** 2 + (pg - g) ** 2 + (pb - b) ** 2 + 2 * (pa - a) ** 2
|
||||
if d < bd:
|
||||
bd, best = d, k
|
||||
cache[key] = ci = best
|
||||
ix, iy = ox + x, oy + y
|
||||
if 0 <= ix < w and 0 <= iy < h:
|
||||
idx[iy * w + ix] = ci
|
||||
return tgt_blob[:12 + 1024] + bytes(idx)
|
||||
|
||||
|
||||
def plate_share(img, gbb):
|
||||
"""底版像素占实心像素的比例 —— 确认这张图**真的有底版**(不是纯图案)。"""
|
||||
if gbb is None:
|
||||
return 0.0
|
||||
a = img.convert('RGBA').getchannel('A')
|
||||
w, h = a.size
|
||||
px = a.load()
|
||||
ink = tot = 0
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if px[x, y] > 16:
|
||||
tot += 1
|
||||
if not (gbb[0] <= x <= gbb[2] and gbb[1] <= y <= gbb[3]):
|
||||
ink += 1
|
||||
return (ink / tot) if tot else 0.0
|
||||
|
||||
|
||||
print('\n--- 米家 / 融合中心素材(q63 -> S5 eSIM)%s ---' % ('' if DO_MIJIA else ':已按设置跳过'))
|
||||
mijia_n = 0
|
||||
_mijia_srcs = []
|
||||
if DO_MIJIA:
|
||||
for _dev0 in ('app', 'health'):
|
||||
for _p, _i in ROM_O[_dev0].entries():
|
||||
if _i.type == 2 and _p.startswith(MIJIA_PREFIX) and not _p.endswith('launcher.bin'):
|
||||
_mijia_srcs.append((_p, _dev0))
|
||||
for _q, _srcdev in sorted(set(_mijia_srcs)):
|
||||
cands = [_q]
|
||||
if _q.startswith('fusion_center/icon/'):
|
||||
cands.append(_q.replace('fusion_center/icon/', 'fusion_center/gauss/'))
|
||||
for tpath in cands:
|
||||
dev = 'app'
|
||||
ti = find_inode(ROM_N[dev], tpath)
|
||||
if ti is None:
|
||||
ti = find_inode(ROM_N['health'], tpath)
|
||||
dev = 'health'
|
||||
if ti is None:
|
||||
if tpath == _q:
|
||||
skipped.append((tpath, '本机没有这个槽位'))
|
||||
continue
|
||||
oi = find_inode(ROM_O[_srcdev], _q)
|
||||
if oi is None:
|
||||
skipped.append((_q, 'q63 没有这个素材')); continue
|
||||
tgt_blob = ROM_N[dev].read(ti)
|
||||
src_blob = ROM_O[_srcdev].read(oi)
|
||||
th, sh = img_header(tgt_blob), img_header(src_blob)
|
||||
if th is None or sh is None:
|
||||
skipped.append((tpath, '头部异常')); continue
|
||||
simg = decode_lvgl_bin(src_blob)[1]
|
||||
if simg is None:
|
||||
skipped.append((tpath, 'q63 素材解码失败(cf=0x%02x)' % sh['cf'])); continue
|
||||
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
|
||||
new, how = ip.encode_into_slot(simg, tgt_blob, ti.size)
|
||||
else:
|
||||
timg = decode_lvgl_bin(tgt_blob)[1]
|
||||
if timg is None:
|
||||
skipped.append((tpath, '本机图解码失败(cf=0x%02x)' % th['cf'])); continue
|
||||
gbb = plate_glyph_bbox(timg)
|
||||
pal = None
|
||||
if (gbb is not None and ink_ratio(simg) < 0.90
|
||||
and plate_share(timg, gbb) >= 0.45
|
||||
and tpath.rsplit('/', 1)[-1][:-4] in PLATE_KEEP):
|
||||
# 底版原样保留(索引层),只把 q63 的裸图案画进去
|
||||
new = overlay_glyph_in_place(tgt_blob, th, simg, gbb)
|
||||
if new is None:
|
||||
skipped.append((tpath, '索引层合成不可用')); continue
|
||||
out = None
|
||||
how = '底版原样+图案入索引 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
|
||||
else:
|
||||
out = normalize_fit(simg, ink_bbox(timg), (th['w'], th['h']))
|
||||
how = '归一化 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
|
||||
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
|
||||
if out is not None:
|
||||
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
|
||||
if new is None or len(new) != ti.size:
|
||||
skipped.append((tpath, '编码失败/长度不符')); continue
|
||||
if new == tgt_blob:
|
||||
skipped.append((tpath, '两边已经一致')); continue
|
||||
name = 'sys:' + tpath
|
||||
rows[:] = [r for r in rows if r['name'] != name]
|
||||
newblobs[name] = new
|
||||
stock_of[name] = tgt_blob
|
||||
rows.append(dict(name=name, kind='sysicon', path=tpath, note='米家/融合中心', how=how,
|
||||
tag=dev, dev=dev, node='/dev/' + dev, image=tpath,
|
||||
offset=ti.data_off, length=ti.size,
|
||||
dim='%dx%d' % (th['w'], th['h']),
|
||||
cf=ip.FMT.get(th['cf'], hex(th['cf'])),
|
||||
stock_ink=ink_bbox(timg) if 'timg' in dir() and timg else None,
|
||||
source_from='q63 ' + _q, identical_to_stock=False,
|
||||
sha256=hashlib.sha256(new).hexdigest(),
|
||||
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
|
||||
mijia_n += 1
|
||||
if mijia_n <= 8 or mijia_n % 20 == 0:
|
||||
print(' %-52s <- %-34s %s' % (tpath, _q, how))
|
||||
print(' 米家/融合中心写入 %d 条' % mijia_n)
|
||||
|
||||
|
||||
# ------------------------------------------------- 焦点通知素材(q63 notifications/** -> 本机)
|
||||
PORT_PREFIXES = ('notifications/', 'phone/', 'recorder/', 'timer/', 'todolist/')
|
||||
# 用户指定:这两张保持原厂不换
|
||||
NOTIF_SKIP = ('notifications/dotted_line.bin', 'notifications/track.bin',
|
||||
# 用户指定:录音机这 3 张不换
|
||||
'recorder/mark_opt.bin', 'recorder/menu.bin', 'recorder/confirm.bin',
|
||||
# 用户指定:拨号列表这 3 张不换
|
||||
'phone/icon/list_icon_keypad.bin', 'phone/icon/list_icon_call_log.bin',
|
||||
'phone/icon/list_icon_contact.bin',
|
||||
# 用户指定:组件预览底图这 2 张不换
|
||||
'phone/widget/phone_widget.bin', 'phone/widget/pre_phone_widget.bin')
|
||||
# 应用图标归 launcher 组管,这里跳过避免重复写同一槽位
|
||||
PORT_SKIP_TAIL = ('launcher.bin', 'launcher')
|
||||
print('\n--- 通知/电话/录音机素材(q63 同名移植 -> S5 eSIM)---')
|
||||
notif_n = 0
|
||||
for _q, _srcdev in sorted((p, 'app') for p, i in ROM_O['app'].entries()
|
||||
if i.type == 2 and p.startswith(PORT_PREFIXES)):
|
||||
if _q in NOTIF_SKIP:
|
||||
skipped.append((_q, '按约定保持原厂')); continue
|
||||
if _q.rsplit('/', 1)[-1].endswith('launcher.bin'):
|
||||
continue # 应用图标归 launcher 组管
|
||||
dev = 'app'
|
||||
ti = find_inode(ROM_N[dev], _q)
|
||||
if ti is None:
|
||||
ti = find_inode(ROM_N['health'], _q)
|
||||
dev = 'health'
|
||||
if ti is None:
|
||||
skipped.append((_q, '本机没有这个槽位')); continue
|
||||
oi = find_inode(ROM_O[_srcdev], _q)
|
||||
if oi is None:
|
||||
skipped.append((_q, 'q63 没有这个素材')); continue
|
||||
tgt_blob = ROM_N[dev].read(ti)
|
||||
src_blob = ROM_O[_srcdev].read(oi)
|
||||
th, sh = img_header(tgt_blob), img_header(src_blob)
|
||||
if th is None or sh is None:
|
||||
skipped.append((_q, '头部异常')); continue
|
||||
simg = decode_lvgl_bin(src_blob)[1]
|
||||
if simg is None:
|
||||
skipped.append((_q, 'q63 素材解码失败(cf=0x%02x)' % sh['cf'])); continue
|
||||
if (th['flags'] & 0x08) or th['cf'] not in (ip.CF_I8, ip.CF_ARGB8888):
|
||||
new, how = ip.encode_into_slot(simg, tgt_blob, ti.size)
|
||||
if new is None:
|
||||
skipped.append((_q, how)); continue
|
||||
else:
|
||||
timg = decode_lvgl_bin(tgt_blob)[1]
|
||||
if timg is None:
|
||||
skipped.append((_q, '本机图解码失败(cf=0x%02x)' % th['cf'])); continue
|
||||
out = normalize_fit(simg, ink_bbox(timg), (th['w'], th['h']))
|
||||
pal = src_blob[12:12 + 1024] if sh['cf'] == ip.CF_I8 else None
|
||||
new = ip.encode(out, th['cf'], th['w'], th['h'], th['flags'], palette=pal)
|
||||
how = '归一化 %dx%d->%dx%d' % (sh['w'], sh['h'], th['w'], th['h'])
|
||||
if len(new) != ti.size:
|
||||
skipped.append((_q, '编码 %d != 槽位 %d' % (len(new), ti.size))); continue
|
||||
if new == tgt_blob:
|
||||
skipped.append((_q, '两边已经一致')); continue
|
||||
name = 'sys:' + _q
|
||||
rows[:] = [r for r in rows if r['name'] != name]
|
||||
newblobs[name] = new
|
||||
stock_of[name] = tgt_blob
|
||||
rows.append(dict(name=name, kind='sysicon', path=_q, note='焦点通知', how=how,
|
||||
tag=dev, dev=dev, node='/dev/' + dev, image=_q,
|
||||
offset=ti.data_off, length=ti.size,
|
||||
dim='%dx%d' % (th['w'], th['h']),
|
||||
cf=ip.FMT.get(th['cf'], hex(th['cf'])),
|
||||
stock_ink=ink_bbox(timg) if 'timg' in dir() and timg else None,
|
||||
source_from='q63 ' + _q, identical_to_stock=False,
|
||||
sha256=hashlib.sha256(new).hexdigest(),
|
||||
rb_sha256=hashlib.sha256(tgt_blob).hexdigest()))
|
||||
notif_n += 1
|
||||
print(' %-46s %s' % (_q, how))
|
||||
print(' 通知/电话/录音机写入 %d 条' % notif_n)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ payload files
|
||||
pay = MAGIC_PAY + b''.join(newblobs[r['name']] for r in rows)
|
||||
rb = MAGIC_RB + b''.join(stock_of[r['name']] for r in rows)
|
||||
|
||||
open(os.path.join(WF, 'payload', 'payload.bin'), 'wb').write(pay)
|
||||
open(os.path.join(WF, 'payload_rollback', 'rollback.bin'), 'wb').write(rb)
|
||||
|
||||
def lua_table(recs, var):
|
||||
lines = ['-- %s: {设备, 分区内偏移, 长度, payload 文件内相对偏移} —— 由 _tools/s5e_payload.py 生成' % var,
|
||||
'local %s = {' % var]
|
||||
rel = 0
|
||||
for r in recs:
|
||||
lines.append(' { "%s", 0x%08x, %d, %d },' % (r['dev'], r['offset'], r['length'], rel))
|
||||
rel += len(stock_of[r['name']])
|
||||
lines.append('}')
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
open(os.path.join(WF, 'payload', 'payload.lua'), 'w', encoding='utf-8', newline='\n').write(
|
||||
lua_table(rows, 'PAY'))
|
||||
open(os.path.join(WF, 'payload_rollback', 'rollback.lua'), 'w', encoding='utf-8', newline='\n').write(
|
||||
lua_table(rows, 'RB'))
|
||||
|
||||
json.dump(dict(target='Xiaomi Watch S5 eSIM 46mm (p62lte) v3.112.035',
|
||||
source='Xiaomi Watch S5 41mm (q63) v4.101.020 launcher icons',
|
||||
device_nodes={'app': '/dev/app', 'health': '/dev/health'},
|
||||
payload_file='payload.bin', payload_magic=MAGIC_PAY.hex(),
|
||||
rollback_file='rollback.bin', rollback_magic=MAGIC_RB.hex(),
|
||||
count=len(rows), records=rows,
|
||||
skipped=[dict(name=n, reason=w) for n, w in skipped]),
|
||||
open(os.path.join(OUT, 'manifest.json'), 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
|
||||
|
||||
# ------------------------------------------------------------------ patched whole images
|
||||
for tag, fn, dev in PART:
|
||||
srcp = os.path.join(NEW, fn)
|
||||
dstp = os.path.join(OUT, fn.replace('.bin', '_os4.bin'))
|
||||
shutil.copyfile(srcp, dstp)
|
||||
with open(dstp, 'r+b') as f:
|
||||
for r in rows:
|
||||
if r['tag'] == tag:
|
||||
f.seek(r['offset'])
|
||||
f.write(newblobs[r['name']])
|
||||
a = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(srcp).entries()]
|
||||
b = [(p, i.off, i.next, i.spec, i.size, i.cksum) for p, i in Romfs(dstp).entries()]
|
||||
r1, r2 = Romfs(srcp), Romfs(dstp)
|
||||
i1 = {p: i for p, i in r1.entries()}
|
||||
i2 = {p: i for p, i in r2.entries()}
|
||||
changed = sorted(p for p in i1 if r1.read(i1[p]) != r2.read(i2[p]))
|
||||
|
||||
# 期望变化的文件 = 所有 new != stock 的记录(launcher 图标 + 系统 UI 素材)
|
||||
mine = [r for r in rows if r['tag'] == tag and newblobs[r['name']] != stock_of[r['name']]]
|
||||
sys_paths = {r['path'] for r in mine if r.get('kind') in ('sysicon', 'walkie')}
|
||||
unexpected = [p for p in changed
|
||||
if not (p.rsplit('/', 1)[-1].endswith('launcher.bin') or p in sys_paths)]
|
||||
print('\n%s -> %s' % (fn, os.path.basename(dstp)))
|
||||
print(' entries %d, inode table identical: %s, superblock identical: %s' % (
|
||||
len(a), a == b, (r1.sb_size, r1.sb_cksum) == (r2.sb_size, r2.sb_cksum)))
|
||||
print(' changed files: %d (expected %d) sha256 %s' % (
|
||||
len(changed), len(mine), hashlib.sha256(open(dstp, 'rb').read()).hexdigest()[:16]))
|
||||
assert not unexpected, 'unexpected changed file! %s' % unexpected[:3]
|
||||
assert len(changed) == len(mine), 'changed %d != expected %d' % (len(changed), len(mine))
|
||||
# 端到端:把负载按 (偏移,长度) 放回去,必须与改后镜像逐字节相同
|
||||
d = bytearray(open(srcp, 'rb').read())
|
||||
for r in rows:
|
||||
if r['tag'] == tag:
|
||||
d[r['offset']:r['offset'] + r['length']] = newblobs[r['name']]
|
||||
assert bytes(d) == open(dstp, 'rb').read(), 'payload replay != patched image'
|
||||
print(' payload replay == patched image: True')
|
||||
# 回滚也必须能逐字节还原原厂镜像
|
||||
e = bytearray(open(dstp, 'rb').read())
|
||||
for r in rows:
|
||||
if r['tag'] == tag:
|
||||
e[r['offset']:r['offset'] + r['length']] = stock_of[r['name']]
|
||||
orig = open(srcp, 'rb').read()
|
||||
assert bytes(e) == orig, 'rollback replay != stock image'
|
||||
print(' rollback replay == stock image: True (sha256 %s)'
|
||||
% hashlib.sha256(orig).hexdigest()[:16])
|
||||
|
||||
print('\n-> %s' % OUT)
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Render the 480x480 page spec with the real MiSans fonts (previews + overview)."""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import render_ui as R
|
||||
from PIL import Image
|
||||
from paths import DOCS
|
||||
R.W = R.H = 480
|
||||
from s5e_pages import PAGES
|
||||
|
||||
outs = []
|
||||
for name, items in PAGES.items():
|
||||
p = os.path.join(DOCS, 's5e_ui_%s.png' % name)
|
||||
R.render(items, p)
|
||||
outs.append((name, p))
|
||||
print('wrote', p)
|
||||
if R.OVERFLOW:
|
||||
print('!! label overflow (text, lh, lines, total, box, natural):')
|
||||
for o in R.OVERFLOW:
|
||||
print(' ', o)
|
||||
else:
|
||||
print('no label overflow')
|
||||
cols = 3
|
||||
rows = (len(outs) + cols - 1) // cols
|
||||
sheet = Image.new('RGB', (cols * (480 + 12) + 12, rows * (480 + 12) + 12), (28, 28, 32))
|
||||
for i, (name, p) in enumerate(outs):
|
||||
sheet.paste(Image.open(p), (12 + (i % cols) * 492, 12 + (i // cols) * 492))
|
||||
sp = os.path.join(DOCS, 's5e_ui_all.png')
|
||||
sheet.save(sp)
|
||||
print('wrote', sp, sheet.size)
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""UI image assets for the 480 face + the 326 preview / 80 logo for the builder."""
|
||||
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')
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
SPEC = [('back', 'backarrow.png', 18, 29),
|
||||
('ic_help', 'MdiLightbulbOutline.png', 31, 31),
|
||||
('ic_replace', 'MdiBandage.png', 31, 31),
|
||||
('ic_about', 'MdiInformationOutline.png', 31, 31),
|
||||
('ic_check', 'MdiClipboardTextSearchOutline.png', 31, 31),
|
||||
('ic_rollback', 'MdiReplyAllOutline.png', 31, 31),
|
||||
('ic_qq', 'qq.png', 31, 31)]
|
||||
for name, png, w, h in SPEC:
|
||||
p = os.path.join(ASSETS, png)
|
||||
if not os.path.exists(p):
|
||||
print(' missing', png); continue
|
||||
img = Image.open(p).convert('RGBA')
|
||||
if img.size != (w, h):
|
||||
img = img.resize((w, h), Image.LANCZOS)
|
||||
blob = ip.encode(img, ip.CF_ARGB8888, w, h, 0)
|
||||
open(os.path.join(OUT, name + '.bin'), 'wb').write(blob)
|
||||
print(' %-12s <- %-34s %dx%d %d bytes' % (name, png, w, h, len(blob)))
|
||||
|
||||
L = 80
|
||||
img = Image.open(os.path.join(ASSETS, 'cover.png')).convert('RGBA').resize((L, L), Image.LANCZOS)
|
||||
mask = Image.new('L', (L, L), 0)
|
||||
ImageDraw.Draw(mask).ellipse([0, 0, L - 1, L - 1], fill=255)
|
||||
img.putalpha(mask)
|
||||
open(os.path.join(OUT, 'logo.bin'), 'wb').write(ip.encode(img, ip.CF_ARGB8888, L, L, 0))
|
||||
img.save(os.path.join(DOCS, 'logo_preview_480.png'))
|
||||
print(' %-12s <- cover.png(circular) %dx%d' % ('logo', L, L))
|
||||
print('->', OUT)
|
||||
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Independent end-to-end check of the delivered .face.
|
||||
|
||||
Nothing here trusts the build script's bookkeeping: the Lua table is parsed out of
|
||||
the face and every offset is validated against the *firmware images* (stock and
|
||||
patched) and the ui_images directory.
|
||||
"""
|
||||
import os, re, sys, json, hashlib, struct
|
||||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from romfs import Romfs
|
||||
|
||||
from paths import PROJ, INPUTS, WORK, DIST
|
||||
WF = PROJ
|
||||
NEW = os.path.join(INPUTS, 'target')
|
||||
PAT = os.path.join(WORK, 's5e_from_os4')
|
||||
FACE = os.path.join(DIST, 'S5eIcons.face')
|
||||
IMAGES = {'app': ('vela_app.bin', 'vela_app_os4.bin'), 'health': ('vela_health.bin', 'vela_health_os4.bin')}
|
||||
MANIFEST = os.path.join(PAT, 'manifest.json')
|
||||
|
||||
face = open(FACE, 'rb').read()
|
||||
# 直接从 .face 里把 Lua 抠出来校验(不依赖构建目录)
|
||||
_end = face.find(b'return ui\n')
|
||||
if _end < 0:
|
||||
raise SystemExit('face 里找不到 Lua 结尾')
|
||||
_start = face.rfind(b'--[[', 0, _end)
|
||||
lua = face[_start:_end + len(b'return ui\n')]
|
||||
if not lua.startswith(b'--[[--') or b'local UIIMG = {' not in lua:
|
||||
raise SystemExit('Lua 提取失败:%r ... %r' % (lua[:24], lua[-24:]))
|
||||
fails = []
|
||||
|
||||
|
||||
def ck(cond, msg):
|
||||
print((' OK ' if cond else ' FAIL ') + msg)
|
||||
if not cond:
|
||||
fails.append(msg)
|
||||
|
||||
|
||||
print('== 头部 ==')
|
||||
ck(face[0:4] == b'\x5a\xa5\x34\x12', 'magic 5a a5 34 12')
|
||||
ck(face[40:50] == b'462150101\x00', 'id = %r' % face[40:50])
|
||||
ck(face[5] == 0, 'b5 = %d(固件自带表盘 b5 也是 0)' % face[5])
|
||||
a16, = struct.unpack('<I', face[16:20])
|
||||
a32, = struct.unpack('<I', face[32:36])
|
||||
print(' info @16=%d @32=0x%x 文件 %d B' % (a16, a32, len(face)))
|
||||
|
||||
print('== 预览块 ==')
|
||||
pal, pw, ph, psz = struct.unpack('<IHHI', face[a32:a32 + 12])
|
||||
ck(pw == 326 and ph == 326, 'preview %dx%d(Compiler.exe 对 462 设备强制 326x326)' % (pw, ph))
|
||||
ck(a32 + 12 + psz <= len(face) + 1,
|
||||
'preview 数据 %d B @0x%x(文件尾 0x%x)' % (psz, a32 + 12, len(face)))
|
||||
|
||||
print('== Lua 源码 ==')
|
||||
ck(face.count(lua) == 1, 'Lua 源码原样出现 %d 次(%d B)' % (face.count(lua), len(lua)))
|
||||
off_lua = face.find(lua)
|
||||
ck(off_lua > 0, 'Lua 偏移 0x%x' % off_lua)
|
||||
txt = lua.decode('utf-8')
|
||||
|
||||
print('== UI 图片(从 .face 里的 Lua 表读出来校验)==')
|
||||
ui = re.findall(r'\{ (\d+), (\d+), "([a-z_]+)" \}', txt)
|
||||
ck(len(ui) == 8, 'UIIMG 条目 %d 条' % len(ui))
|
||||
files = {f[:-4]: open(os.path.join(WF, 'ui_images', f), 'rb').read()
|
||||
for f in os.listdir(os.path.join(WF, 'ui_images')) if f.endswith('.bin')}
|
||||
for o, l, n in ui:
|
||||
o, l = int(o), int(l)
|
||||
ck(n in files and face[o:o + l] == files[n], '%(n)-12s @0x%(o)06x len=%(l)d 与 ui_images/%(n)s.bin 一致' % dict(n=n, o=o, l=l))
|
||||
|
||||
print('== 负载表 ==')
|
||||
pay = re.findall(r'\{ "(app|health)", 0x([0-9a-f]+), (\d+), (\d+) \}', txt)
|
||||
blocks = re.findall(r'^ (pay|rb) = \{', txt, re.M)
|
||||
ck(blocks == ['pay', 'rb'], 'pay / rb 两个表都在')
|
||||
half = len(pay) // 2
|
||||
exp = json.load(open(MANIFEST, encoding='utf-8'))['records']
|
||||
ck(half == len(exp) and len(pay) == 2 * len(exp),
|
||||
'pay %d 条 + rb %d 条(manifest 里 %d 条)' % (half, len(pay) - half, len(exp)))
|
||||
pay_recs = [dict(dev=d, off=int(o, 16), ln=int(l), res=int(r)) for d, o, l, r in pay[:half]]
|
||||
rb_recs = [dict(dev=d, off=int(o, 16), ln=int(l), res=int(r)) for d, o, l, r in pay[half:]]
|
||||
|
||||
stock, patched, sizes = {}, {}, {}
|
||||
for dev, (sf, pf) in IMAGES.items():
|
||||
rs, rp = Romfs(os.path.join(NEW, sf)), Romfs(os.path.join(PAT, pf))
|
||||
stock[dev] = rs.data
|
||||
patched[dev] = rp.data
|
||||
sizes[dev] = rs.sb_size
|
||||
|
||||
bad = 0
|
||||
for i, (p, r) in enumerate(zip(pay_recs, rb_recs)):
|
||||
assert p['dev'] == r['dev'] and p['off'] == r['off'] and p['ln'] == r['ln'], 'record %d 表错位' % i
|
||||
d, o, l = p['dev'], p['off'], p['ln']
|
||||
ok = (face[p['res']:p['res'] + l] == patched[d][o:o + l] and
|
||||
face[r['res']:r['res'] + l] == stock[d][o:o + l] and
|
||||
o % 4 == 0 and o + l <= sizes[d] and # RLE 素材的长度可以是任意值,只要偏移对齐
|
||||
len(face[p['res']:p['res'] + l]) == l and len(face[r['res']:r['res'] + l]) == l)
|
||||
if not ok:
|
||||
bad += 1
|
||||
print(' FAIL %-22s %s off=0x%x len=%d' % (d, p['dev'], o, l))
|
||||
ck(bad == 0, '%d 条:负载字节 == 改后镜像,回滚字节 == 原厂镜像,且都 4 对齐、不越界' % half)
|
||||
devs = {}
|
||||
for p in pay_recs:
|
||||
devs[p['dev']] = devs.get(p['dev'], 0) + 1
|
||||
exp_devs = {}
|
||||
for r in exp:
|
||||
exp_devs[r['dev']] = exp_devs.get(r['dev'], 0) + 1
|
||||
ck(devs == exp_devs, '分区分布 %s(manifest %s)' % (devs, exp_devs))
|
||||
|
||||
print('== 负载文件在 .face 里连续存放 ==')
|
||||
pbin = open(os.path.join(WF, 'payload', 'payload.bin'), 'rb').read()
|
||||
rbin = open(os.path.join(WF, 'payload_rollback', 'rollback.bin'), 'rb').read()
|
||||
ck(face.count(pbin) == 1 and face.count(rbin) == 1,
|
||||
'payload %d B / rollback %d B 各出现 1 次' % (len(pbin), len(rbin)))
|
||||
rel = sorted(x['res'] - (face.find(pbin) + 15) for x in pay_recs)
|
||||
ck(rel[0] == 0, '第一条负载相对偏移为 0')
|
||||
ck(face[pay_recs[0]['res'] - 15:pay_recs[0]['res']] == pbin[:15], 'magic 紧贴第一条负载')
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print('!! %d 项未通过' % len(fails))
|
||||
sys.exit(1)
|
||||
print('全部通过 —— %s (%d B, sha256 %s)' % (os.path.basename(FACE), len(face),
|
||||
hashlib.sha256(face).hexdigest()[:16]))
|
||||
Reference in New Issue
Block a user