Files
o62lte-customising-plan/s5e-pressure-anim/main.lua
T
2026-10-03 00:01:12 +08:00

818 lines
30 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--[[----------------------------------------------------------------------------
S5ePAnim —— S5 41mm(q63) 压力检测动画 -> Xiaomi Watch S5 eSIM 46mm(p62lte)
目标固件:miwear.watch.p62lte_v3.112.035_full(480x480,Lua 5.4 宿主)
写入目标:ROMFS 分区 /dev/health(卷名 health,sb_size 206,924,736 B)
pressure/measure/Measuring1..119.bin
119 个槽位全是 464x464 I8 + RLE(usize = 1024 + 464*464 + 1 = 216,321)。
负载**逐条等长**:每条写进去的字节数恰好等于原槽位长度(4485 ~ 196538 B),
所以 ROMFS 的 inode 表 / 目录项 / 后面所有文件的绝对偏移一个字节都不动。
源:Xiaomi Watch S5 41mm(q63, v4.101.020)pressure/measure 的 100 帧 480x480。
映射:第 k 个槽位(k=1..119) <- q63 第 round((k-1)/(119-1)*(100-1))+1 帧。
界面按 480x480 设计(tools/s5e_pages.py,本机真 MiSans 渲染 + 真实像素判圆屏切边:
0 越界、0 折行)。坐标与设计稿一一对应,预览图不是另画一套。
两份盘共用同一个 watchface id 462150102(462 = S5 家族):
S5ePAnim.face 只装"替换后"的 119 条 + 原厂前 64 B 指纹
S5ePAnimRestore.face 只装"原厂"的 119 条 + 替换后前 64 B 指纹
装其中一份就顶掉另一份,设备上始终只占一份空间。
★ 分区键名 vs 设备节点(S4e 那版踩过的坑,别改回去)
DEVS 的**键**是 'health',值是设备节点 '/dev/health';运行期才取绝对路径。
负载表里存的是**分区内偏移**(整数),没有节点名字段。
真机已验证的底层能力(沿用 S5->S4 那个表盘):
* 宿主不调用 ui.init -> 加载期建界面
* SCRIPT_PATH 为空 -> 用 debug.getinfo 反推目录
* .face 不解包,资源仍在 resource.bin 里 -> 按偏移读出来用
* os.execute / dd 可用;os.execute 的返回值不可靠 -> 一律用内容比对判成败
真机**未**验证的:S5 eSIM(p62lte) 的 /dev/health 可写(同源平台 S4 eSIM 上的 dd 流程
实测通过)。所以写入前一律先只读预检查、再逐条版本校验,版本对不上就跳过分区剩余记录。
------------------------------------------------------------------------------]]
local lvgl = require("lvgl")
local W, H = 480, 480
local BLK = 4
--------------------------------------------------------------------- 设备与临时文件
local DEVS = { health = "/dev/health" } -- 键名 -> 设备节点(Lua 表里只存键名)
local DEV = DEVS.health
local VOLUME = "health" -- ROMFS superblock 里的卷名,预检查要逐字节对上
local TMPR = "/tmp/s5p_back.bin" -- 回读出来的那一份
local TMPB = "/tmp/s5p_blob.bin" -- 待写入的负载(resource.bin 里抠出来的那一份)
-- 等长覆盖只保证「偏移」512 对齐;RLE 素材的长度常常不是 4 的倍数(如 4485 / 196538),
-- 那种记录必须退化成 bs=1(偏移本身对齐,所以 bs=1 也精确),否则会多写相邻文件的字节。
local function ddBlk(off, len)
if off % BLK == 0 and len % BLK == 0 then return BLK end
return 1
end
--------------------------------------------------------------------- 自身目录
local SRC = ""
if debug and debug.getinfo then
local info = debug.getinfo(1, "S")
SRC = (info and info.source) or ""
end
if type(SRC) ~= "string" then SRC = "" end
if SRC:sub(1, 1) == "@" then SRC = SRC:sub(2) end
SRC = SRC:gsub("\\", "/")
local THIS_DIR = SRC:match("^(.*)/[^/]+$") or ""
local SP = THIS_DIR ~= "" and (THIS_DIR .. "/") or ""
local PARENT = SP:gsub("[^/]+/$", "")
local RES = nil
do
local c = { PARENT .. "resource.bin", SP .. "../resource.bin", SP .. "resource.bin" }
for i = 1, #c do
local f = io.open(c[i], "rb")
if f then f:close(); RES = c[i]; break end
end
end
--------------------------------------------------------------------- 资源偏移
-- UI 图片 { resource.bin 内偏移, 长度, 名字 } —— 由 tools/s5e_build.py 回填
local UIIMG = {
__UIIMG__
}
-- 角色:'replace' = 只负责替换(写 q63 的帧);'restore' = 只负责还原(写回原厂字节)
-- 两份盘共用同一个 watchface id,所以设备上只会占一份空间(见 README)
local ROLE = '__ROLE__'
-- 负载表 { resource.bin 内偏移, /dev/health 内偏移, 长度 } —— 由脚本回填
-- pay = 本盘要写进去的字节
-- rb = **另一侧字节前 64 B 的指纹**(只给写入前的版本校验用)
-- replace 盘的 rb 是原厂前 64 B;restore 盘的 rb 是替换后前 64 B
local PAYTBL = {
__PAY__
__RB__
}
local function prepImages()
local paths = {}
if not RES then return paths end
local f = io.open(RES, "rb")
if not f then return paths end
for i = 1, #UIIMG do
local e = UIIMG[i]
f:seek("set", e[1])
local d = f:read(e[2])
if d and #d == e[2] then
-- 直接写到 /tmp 根下:不能写子目录(目录不存在,且这台机器的 Lua 没有 mkdir)
local p = "/tmp/s5p_ui_" .. e[3] .. ".bin"
local o = io.open(p, "wb")
if o then o:write(d); o:close(); paths[e[3]] = p end
end
end
f:close()
return paths
end
local IMG = prepImages()
--------------------------------------------------------------------- 颜色与字体
local C_GRAY, C_WHITE = '#999999', '#ffffff'
local C_OK, C_ERR = '#43cf7c', '#d43030'
local C_PLATE, C_CIRC = 0x002927, 0x4f4f4f
local function getFont(name, size)
local f = nil
pcall(function() f = lvgl.Font(name, size) end)
return f
end
local F_CLOCK = getFont('MiSans-Semibold', 29)
local F_TITLE = getFont('MiSans-Medium', 31)
local F_ITEM = getFont('MiSans-Medium', 29)
local F_BODY = getFont('MiSans-Medium', 25)
local F_SMALL = getFont('MiSans-Medium', 24) -- 说明文字 / 日志(一行 348px 以内)
local F_VER = getFont('MiSans-Medium', 21)
--------------------------------------------------------------------- 按下高亮
-- onPressed / onClicked 是真实的 LVGL 对象方法(就在 onevent 旁边,属性表里可查)
local HL = 0x005149 -- 按下高亮色(比底板 0x002927 明显亮)
local pressed = nil -- { obj = , t = }
local tickCount = 0
local function setBg(obj, c)
pcall(function() obj:set { bg_color = c } end)
end
local function pressRelease()
if pressed then
setBg(pressed.obj, C_PLATE)
pressed = nil
end
end
--------------------------------------------------------------------- 基础
local ui = {}
local built = false
local pages = {}
local clocks = {}
local job = { run = false, list = nil, i = 0, ok = 0, errs = 0, vers = 0,
skipRest = false }
-- 从哪个入口进确认页:首页 -> 'replace'(本盘的 pay 表就是该角色的字节);
-- 帮助页「回滚修改」只提示装另一份盘(本盘不含另一侧的完整负载)
local pendingVariant = 'replace'
-- 确认页是从哪进来的,返回就回哪:首页 -> 'home';帮助页 -> 'help'
local confirmFrom = 'home'
local function newPage()
local p = nil
pcall(function()
p = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000,
bg_opa = 255, border_width = 0, pad_all = 0 })
p:clear_flag(lvgl.FLAG.SCROLLABLE)
p:add_flag(lvgl.FLAG.EVENT_BUBBLE)
end)
return p
end
local function show(p)
if not p then return end
pressRelease() -- 切页时清掉可能残留的高亮
for k, v in pairs(pages) do
pcall(function() v:add_flag(lvgl.FLAG.HIDDEN) end)
end
pcall(function() p:clear_flag(lvgl.FLAG.HIDDEN) end)
end
local function bind(obj, fn)
if not obj then return end
-- 成品样本用的是 onevent(lvgl.EVENT.SHORT_CLICKED, fn)(CLICKED 这个常量
-- 在这台机器上不存在),所以按 SHORT_CLICKED -> onClicked -> CLICKED 依次试
local ev = lvgl.EVENT
if ev and ev.SHORT_CLICKED then
if pcall(function() obj:onevent(ev.SHORT_CLICKED, function() fn() end) end) then return end
end
if pcall(function() obj:onClicked(function() fn() end) end) then return end
pcall(function() obj:onevent(ev and ev.CLICKED, fn) end)
end
--------------------------------------------------------------------- 点按
-- 点按 = 按下高亮 + 抬起恢复 + 执行动作;带超时兜底(手指滑出时 click 不触发)
local function bindTap(obj, onTap)
if not obj then return end
local ok = pcall(function()
obj:onPressed(function()
setBg(obj, HL)
pressed = { obj = obj, t = tickCount }
end)
obj:onClicked(function()
pressRelease()
if onTap then onTap() end
end)
end)
if not ok then bind(obj, onTap) end
end
local function setClickable(o)
if not o then return end
pcall(function() o:add_flag(lvgl.FLAG.CLICKABLE) end)
end
local function lab(parent, o)
o.text_font = o.text_font or F_BODY
local l = nil
pcall(function() l = parent:Label(o) end)
return l
end
-- 时钟:居中不用 text_align(它要的是 lv_text_align_t,传 ALIGN.CENTER 越界会被忽略
-- -> 实测变成左对齐)。改为按真字体量出的宽度手工算 x:'10:24' 29px 量得 78
local function clockLabel(parent)
local l = lab(parent, { x = 201, y = 4, w = 78, h = 46, text = '--:--',
text_color = C_GRAY, font_size = 29, text_font = F_CLOCK })
clocks[#clocks + 1] = l
return l
end
-- 标题:框 y=36 h=46,字号 31 行高 42 -> 从框顶画即可(LVGL 标签从框顶起排)
local function pageTitle(parent, text, x, w)
return lab(parent, { x = x, y = 36, w = w, h = 46, text = text,
text_color = C_WHITE, font_size = 31, text_font = F_TITLE })
end
local function backArrow(parent, x, onTap)
local im = nil
if IMG.back then
pcall(function() im = parent:Image { src = IMG.back, x = x or 167, y = 44 } end)
setClickable(im)
bind(im, onTap)
end
return im
end
-- 列表项:底板(整块可点) + 圆形遮罩 + 图标 + 标题
-- pad_all=0 是关键:LVGL 主题会给 Object 加内边距,不清零子元素会整体右下偏移。
-- radius=40 也不是审美选择:y=88 那行离圆心 152px,圆屏在那里的弦半宽只有 185.8,
-- 圆角不够大时四个角会越过圆边(tools/s5e_check_clip.py 会量出来)。
local function listItem(parent, y, iconkey, title, onTap)
local plate = nil
pcall(function()
plate = parent:Object { x = 50, y = y, w = 379, h = 91, pad_all = 0,
bg_color = C_PLATE, border_width = 0, radius = 40 }
end)
if plate then
if onTap then
setClickable(plate)
bindTap(plate, onTap)
end
pcall(function()
plate:Object { x = 21, y = 20, w = 52, h = 52,
bg_color = C_CIRC, border_width = 0, radius = 26 }
end)
if IMG[iconkey] then
pcall(function() plate:Image { src = IMG[iconkey], x = 31, y = 30 } end)
end
-- 标题 29px 行高 40,框高 42 -> 从框顶画就是垂直居中
lab(plate, { x = 82, y = 26, w = 214, h = 42, text = title,
text_color = C_WHITE, font_size = 29, text_font = F_ITEM })
end
return plate
end
-- 大按钮 258x82 圆角 41(y=376):圆角把四角收进圆内(本机像素判据实测 0 越界)
local function bigButton(parent, text)
local b = nil
pcall(function()
b = parent:Object { x = 111, y = 376, w = 258, h = 82, pad_all = 0,
bg_color = C_PLATE, border_width = 0, radius = 41 }
end)
if b then setClickable(b) end
-- 按钮文字:框 (71,22,126,40) 相对底板;'确认替换'/'确认还原'/'重启手表' 29px 量得 116
-- -> 居中 x = (258-116)/2 = 71
lab(b, { x = 71, y = 22, w = 126, h = 40, text = text,
text_color = C_WHITE, font_size = 29, text_font = F_ITEM })
return b
end
local function clockTick()
local t = '--:--'
if os and type(os.date) == "function" then
local ok, s = pcall(os.date, "%H:%M")
if ok and type(s) == "string" then t = s end
end
for i = 1, #clocks do
pcall(function() clocks[i]:set { text = t } end)
end
end
--------------------------------------------------------------------- 外壳
local function slurp(p, n)
local f = io.open(p, "rb")
if not f then return nil end
local d = f:read(n or "*a")
f:close()
return d
end
local function sh(cmd)
if type(os.execute) ~= "function" then return -1 end
local a, b, c = os.execute(cmd)
if type(a) == "number" then return a end
if a == true then return 0 end
if type(c) == "number" then return c end
if type(b) == "number" then return b end
return -1
end
-- 从 resource.bin 里取一段(负载 / 另一侧指纹)
local function slice(off, len)
if not RES then return nil end
local f = io.open(RES, "rb")
if not f then return nil end
f:seek("set", off)
local d = f:read(len)
f:close()
return d
end
-- 偏移都是 512 对齐,RLE 素材的长度却常常不是 4/512 的倍数(4485 / 196538 …)。
-- 老写法对这种长度只能 bs=1 -> 一条 19 万字节要 38 万次单字节读写,慢到偶发失败
-- (S4e 真机上就是这么翻车的)。这里把它切成 ≤3 段:bs=512 的大块 + bs=4 的次块 +
-- 最后 1~3 字节 bs=1,syscall 直接降两个数量级。
local BIG = 512
local function segs(off, len)
local out = {}
if off % BIG == 0 then
local head = len - (len % BIG)
if head > 0 then out[#out + 1] = { BIG, 0, head } end
local rest = len - head
local t4 = rest - (rest % 4)
if t4 > 0 then out[#out + 1] = { 4, head, t4 } end
if rest - t4 > 0 then out[#out + 1] = { 1, head + t4, rest - t4 } end
else
out[1] = { 1, 0, len }
end
return out
end
-- 读设备上某偏移处的 len 字节(只读)
local function ddRead(off, len)
local parts = {}
for _, s in ipairs(segs(off, len)) do
local b, rel, n = s[1], s[2], s[3]
if os.remove then pcall(os.remove, TMPR) end
sh(string.format("dd if=%s of=%s bs=%d skip=%d count=%d",
DEV, TMPR, b, (off + rel) // b, n // b))
local d = slurp(TMPR, n)
if not d or #d ~= n then return nil end
parts[#parts + 1] = d
end
return table.concat(parts)
end
-- 每段单独一个临时文件:这样 dd 只需要 seek(输出偏移),不依赖输入侧的 skip
local function ddWrite(off, len, data)
for _, s in ipairs(segs(off, len)) do
local b, rel, n = s[1], s[2], s[3]
local o = io.open(TMPB, "wb")
if not o then return false end
o:write(data:sub(rel + 1, rel + n))
o:close()
sh(string.format("dd if=%s of=%s bs=%d seek=%d count=%d",
TMPB, DEV, b, (off + rel) // b, n // b))
end
return true
end
--------------------------------------------------------------------- 预检查
local desc1 = nil
local function setDesc1(txt, color)
if not desc1 then return end
pcall(function() desc1:set { text = txt, text_color = color } end)
end
-- 同一条记录,两边各自叫什么:replace 盘的 pay 是"改后"、rb 是"原厂"
local function sideName(which)
if which == 'mine' then
return (ROLE == 'restore') and '原厂' or '已替换'
end
return (ROLE == 'restore') and '已替换' or '原厂'
end
local function doCheck()
if not RES then
setDesc1('找不到 resource.bin,无法操作。', C_ERR)
return false
end
local list = PAYTBL.pay or {}
if #list == 0 then
setDesc1('预检查失败:负载表为空。', C_ERR)
return false
end
local h = ddRead(0, 48)
if not h or #h < 48 then
setDesc1('预检查失败:设备节点读不到。', C_ERR)
return false
end
if h:sub(1, 8) ~= "-rom1fs-" then
setDesc1('预检查失败:设备节点读不到,\n或分区头不是 ROMFS。', C_ERR)
return false
end
local z = h:find("\0", 17, true)
local vol = h:sub(17, (z or 33) - 1)
if vol ~= VOLUME then
setDesc1('预检查失败:卷名是 ' .. vol .. '。', C_ERR)
return false
end
-- superblock 的 size 是**大端**(romfs 规范:__be32),所以这里必须用 ">I4"。
-- (S4e 那版写成 "<I4",读出来是个天文数字 -> 越界判断形同虚设;这里修正。)
local size = string.unpack(">I4", h, 9)
local bad, total = 0, 0
for i = 1, #list do
local r = list[i]
local b = ddBlk(r[2], r[3])
if (r[2] % b) ~= 0 or (r[3] % b) ~= 0 then bad = bad + 1 end
if (r[2] + r[3]) > size then bad = bad + 1 end
total = total + r[3]
end
if bad > 0 then
setDesc1(string.format('预检查失败:%d 项越界或未对齐。', bad), C_ERR)
return false
end
-- 顺带确认 resource.bin 第一条能读出来
local probe = slice(list[1][1], list[1][3])
if not probe or #probe ~= list[1][3] then
setDesc1('预检查失败:resource.bin 读取异常。', C_ERR)
return false
end
-- 抽样版本校验(只读):首 / 中 / 尾三条,比对设备上真实的 64 字节与本盘的
-- 「要写进去的字节」或「另一侧指纹」—— 能确认负载偏移就是给这一版固件算的。
local marks = { 1, math.max(1, #list // 2), #list }
local samp, okAll = {}, true
for i = 1, #marks do
local k = marks[i]
local rec, oth = list[k], (PAYTBL.rb or {})[k]
local got = ddRead(rec[2], 64)
local mine = slice(rec[1], 64)
local other = oth and slice(oth[1], 64) or nil
if not got or #got < 64 then
samp[#samp + 1] = '×'
okAll = false
elseif mine and got == mine then
samp[#samp + 1] = sideName('mine') .. '✓'
elseif other and got == other then
samp[#samp + 1] = sideName('other') .. '✓'
else
samp[#samp + 1] = '抽样×'
okAll = false
end
end
local head = string.format('预检查通过 | %d 条 | %.2fMB', #list, total / 1048576)
setDesc1(head .. '\n' .. VOLUME .. ' 分区可读。' .. table.concat(samp, ' '),
okAll and C_OK or C_ERR)
return okAll
end
--------------------------------------------------------------------- 替换 / 还原
local logLabel = nil
local rebootBtn = nil
local runningTitle = nil
local logLines = {}
local function logOut(s)
logLines[#logLines + 1] = s
-- 日志框 h=280、24px 行高 34 -> 最多 8 行;留 8 行保证最后一行完整可见
while #logLines > 8 do table.remove(logLines, 1) end
if logLabel then
pcall(function() logLabel:set { text = table.concat(logLines, "\n") } end)
end
end
local function showReboot()
if rebootBtn then pcall(function() rebootBtn:clear_flag(lvgl.FLAG.HIDDEN) end) end
end
-- 一条记录三步:① 版本校验(设备上那段必须等于"本盘要写的"或"另一侧"的前 64 B)
-- ② 写入 ③ 整段回读比对
-- 版本两边都不是 -> 'ver',并且**跳过 /dev/health 剩余记录**(换固件后最安全的行为:
-- 绝不把字节写进别人的文件里)。os.execute 的返回值在这台机器上不可靠,所以判据
-- 一律是内容比对。
local function writeOne(rec, want)
local data = slice(rec[1], rec[3])
if not data or #data ~= rec[3] then return 'err' end
local n = 64
if want then
if want[3] < n then n = want[3] end
local other = slice(want[1], n)
local cur = ddRead(rec[2], n)
if not cur or #cur ~= n or not other or #other ~= n then return 'err' end
if cur ~= data:sub(1, n) and cur ~= other then return 'ver' end
end
local ok, lastBack = false, nil
for attempt = 1, 3 do
if not ddWrite(rec[2], rec[3], data) then return 'err' end
sh("sync")
lastBack = ddRead(rec[2], rec[3])
if lastBack and #lastBack == rec[3] and lastBack == data then ok = true break end
logOut(string.format('> 第 %d 条校验不过,重试 %d/3', job.i, attempt))
end
if not ok then
-- 计划 B:常规「分段写」3 次不过时,改成整条一次 dd(bs=4 或 bs=1)。
-- 有些驱动对「同一区域被拆成多次小写」比「一次大写」更敏感,换个写法常常就过了。
local b = (rec[3] % 4 == 0) and 4 or 1
local o = io.open(TMPB, "wb")
if o then
o:write(data)
o:close()
sh(string.format("dd if=%s of=%s bs=%d seek=%d count=%d",
TMPB, DEV, b, rec[2] // b, rec[3] // b))
sh("sync")
local back2 = ddRead(rec[2], rec[3])
if back2 and #back2 == rec[3] and back2 == data then
ok = true
logOut(string.format('> 第 %d 条改用整条 bs=%d 写入后校验通过', job.i, b))
end
end
end
if not ok then
-- 诊断:把「哪一条 / 目标偏移 / 长度 / 读回多少字节 / 第一个不同的字节」打出来
local why = '读回 nil'
if lastBack then
if #lastBack ~= rec[3] then
why = string.format('读回 %d 字节', #lastBack)
else
why = '内容全同?'
for i = 1, #lastBack do
if lastBack:byte(i) ~= data:byte(i) then
why = string.format('首差@%d 得%02X 期%02X', i - 1, lastBack:byte(i), data:byte(i))
break
end
end
end
end
logOut(string.format('> 失败 #%d off=0x%X len=%d %s', job.i, rec[2], rec[3], why))
return 'err'
end
return 'ok'
end
local function startJob(variant)
if job.run then return end
local list = PAYTBL.pay or {}
if #list == 0 then
setDesc1('负载表为空,已中止。', C_ERR)
return
end
job.run, job.list, job.i, job.ok, job.errs, job.vers = true, list, 0, 0, 0, 0
job.skipRest = false
logLines = {}
logOut(ROLE == 'restore' and '> 开始还原' or '> 开始替换')
logOut(string.format('> %s 119 帧 / 共 %d 条', VOLUME, #list))
logOut('> 读取 resource.bin')
if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end
if runningTitle then
pcall(function()
runningTitle:set { text = (ROLE == 'restore') and '正在还原' or '正在替换' }
end)
end
show(pages.running)
end
local function tick()
tickCount = tickCount + 1
-- 兜底:按下超过 1.6s 没收到 click(手指滑出等)就恢复高亮
if pressed and (tickCount - pressed.t) > 40 then pressRelease() end
if not job.run or not job.list then return end
job.i = job.i + 1
local n = #job.list
if job.i > n then
job.run = false
logOut(ROLE == 'restore' and '> 还原完毕,请重启手表。'
or '> 替换完毕,请重启手表。')
logOut(string.format('> 共 %d 条,成功 %d 失败 %d', n, job.ok, job.errs))
if job.vers > 0 then
logOut(string.format('> 版本不符跳过 %d 条', job.vers))
logOut('> 请勿重启,先装回匹配版本。')
else
logOut('> 若发现问题,请回滚修改。')
end
showReboot()
return
end
if job.skipRest then
job.vers = job.vers + 1
else
local rb = PAYTBL.rb and PAYTBL.rb[job.i]
local r = writeOne(job.list[job.i], rb)
if r == 'ver' then
job.vers = job.vers + 1
job.skipRest = true -- 版本不符:跳过 /dev/health 剩余记录
logOut('> /dev/health 版本不符,已跳过剩余记录')
elseif r == 'ok' then
job.ok = job.ok + 1
else
job.errs = job.errs + 1
end
end
if job.i % 10 == 0 or job.i == n then
logOut(string.format('> 写入 %d/%d 成功 %d 失败 %d', job.i, n, job.ok, job.errs))
end
end
--------------------------------------------------------------------- 页面
local restore = (ROLE == 'restore')
local function buildHome()
local p = newPage()
if not p then return nil end
clockLabel(p)
-- 盘内标题 = 显示名(不再用中文描述名);x 按真 MiSans 31px 量出的宽度居中:
-- 'S5e Pt.2' = 125 -> x = (480-125)/2 = 177.5 -> 177
-- 'S5e Pt.2 · 还原' = 225 -> x = (480-225)/2 = 127.5 -> 127
if restore then
pageTitle(p, 'S5e Pt.2 · 还原', 127, 227)
else
pageTitle(p, 'S5e Pt.2', 177, 127)
end
listItem(p, 88, 'ic_help', '快速帮助', function() show(pages.help) end)
listItem(p, 186, 'ic_replace', restore and '开始还原' or '开始替换', function()
pendingVariant = 'replace'
confirmFrom = 'home'
show(pages.confirm)
end)
listItem(p, 285, 'ic_about', '关于', function() show(pages.about) end)
return p
end
local function buildHelp()
local p = newPage()
if not p then return nil end
clockLabel(p)
backArrow(p, 167, function() show(pages.home) end)
local t = pageTitle(p, '快速帮助', 188, 124)
setClickable(t)
bind(t, function() show(pages.home) end)
listItem(p, 88, 'ic_check', '环境预检查', function() doCheck() end)
desc1 = lab(p, { x = 67, y = 182, w = 348, h = 76,
text = '检查 health 分区与负载完整性。',
text_color = C_GRAY, font_size = 24, text_font = F_SMALL })
-- 拆盘之后:本盘只装一份负载,「回滚/恢复」由另一份盘负责
listItem(p, 264, 'ic_rollback', '回滚修改', function()
if restore then
setDesc1('本盘负责还原,换回动画\n请装 S5e Pt.2。', C_GRAY)
else
setDesc1('本盘只负责替换,请装\nS5e Pt.2 · 还原。', C_GRAY)
end
end)
lab(p, { x = 67, y = 360, w = 348, h = 42,
text = restore and '把 119 帧动画换回原厂样式。'
or '恢复 119 帧动画的原厂样式。',
text_color = C_GRAY, font_size = 25, text_font = F_BODY })
return p
end
local function buildConfirm()
local p = newPage()
if not p then return nil end
clockLabel(p)
-- 确认页的返回:回到进来时的那个页面(首页 或 快速帮助)
local function goBack() show(pages[confirmFrom] or pages.home) end
backArrow(p, 167, goBack)
local t = pageTitle(p, restore and '开始还原' or '开始替换', 188, 124)
setClickable(t)
bind(t, goBack)
-- 多色文本**不能**用 recolor:AP 里那几处 recolor 是图片重着色(bg_img_recolor)
-- 和表盘编辑器的主题重着色,不是文本属性;实测标记会被原样显示出来。
-- 改为拆成多个标签 + 手工算 x(宽度用真 MiSans 25px 量出),框宽 ≥ 文本宽
-- -> 保证不折行(折行会让手工算 x 的片段错位),并且整框都在圆内。
-- 型号拆两行:圆屏在 y=243 处的弦半宽只有 ~190px,而 'Xiaomi Watch S5 eSIM 46mm'
-- 量得 367px,放一行会被圆边切掉(tools/s5e_check_clip.py 抓过这个)。
local WARN = {
{ 60, 95, 360, 34, '替换压力检测动画之前,', C_GRAY },
{ 60, 129, 360, 34, '请先确认自己的系统版本号为 ', C_GRAY },
{ 304, 129, 120, 34, '3.112.035', C_WHITE },
{ 422, 129, 40, 34, '!', C_GRAY },
{ 64, 195, 360, 34, '请确认自己的手表型号为', C_GRAY },
{ 64, 229, 360, 34, 'Xiaomi Watch S5', C_WHITE },
{ 64, 263, 360, 34, 'eSIM 46mm(p62lte)', C_WHITE },
{ 60, 341, 360, 34, '搞机有风险,变砖后果自负。', C_GRAY },
}
for i = 1, #WARN do
local w = WARN[i]
lab(p, { x = w[1], y = w[2], w = w[3], h = w[4], text = w[5],
text_color = w[6], font_size = 25, text_font = F_BODY })
end
local b = bigButton(p, restore and '确认还原' or '确认替换')
bindTap(b, function() startJob(pendingVariant) end)
return p
end
local function buildAbout()
local p = newPage()
if not p then return nil end
clockLabel(p)
backArrow(p, 198, function() show(pages.home) end)
local t = pageTitle(p, '关于', 220, 62)
setClickable(t)
bind(t, function() show(pages.home) end)
-- 信息卡:底板 + 圆形 logo + 大标题 + 版本号
pcall(function()
p:Object { x = 54, y = 81, w = 373, h = 210, pad_all = 0,
bg_color = C_PLATE, border_width = 0, radius = 31 }
end)
if IMG.logo then
pcall(function() p:Image { src = IMG.logo, x = 200, y = 111 } end)
end
-- 卡内 29px 量得:'S5e Pt.2' = 118 -> x = 54 + (373-118)/2 = 181.5 -> 181
-- 'S5e Pt.2 · 还原' = 211 -> x = 54 + (373-211)/2 = 135(整)
if restore then
lab(p, { x = 135, y = 200, w = 213, h = 42, text = 'S5e Pt.2 · 还原',
text_color = C_WHITE, font_size = 29, text_font = F_ITEM })
else
lab(p, { x = 181, y = 200, w = 120, h = 42, text = 'S5e Pt.2',
text_color = C_WHITE, font_size = 29, text_font = F_ITEM })
end
-- '1.0.0' 21px 量得 49 -> 居中 x = 54 + (373-49)/2 = 216
lab(p, { x = 222, y = 237, w = 49, h = 28, text = '1.0.0',
text_color = C_GRAY, font_size = 21, text_font = F_VER })
-- QQ 联系方式:结构与列表项一致(无动作,所以不给它绑点击)
listItem(p, 300, 'ic_qq', '732681995', nil)
return p
end
local function buildRunning()
local p = newPage()
if not p then return nil end
clockLabel(p)
-- 本页没有返回图标(规格:顶栏同首页),所以标题要屏幕居中:
-- '正在替换'/'正在还原' 31px 量得 124 -> x = 240 - 124/2 = 178
runningTitle = pageTitle(p, restore and '正在还原' or '正在替换', 178, 124)
logLabel = lab(p, { x = 67, y = 95, w = 348, h = 280, text = '',
text_color = C_WHITE, font_size = 24, text_font = F_SMALL })
rebootBtn = bigButton(p, '重启手表')
-- 替换过程中不显示,替换完再显示
if rebootBtn then pcall(function() rebootBtn:add_flag(lvgl.FLAG.HIDDEN) end) end
bindTap(rebootBtn, function()
logOut('> 正在重启…')
sh("sync")
sh("reboot")
end)
return p
end
--------------------------------------------------------------------- 组装
local function build()
if built then return end
built = true
pages.home = buildHome()
pages.help = buildHelp()
pages.confirm = buildConfirm()
pages.about = buildAbout()
pages.running = buildRunning()
if pages.home then show(pages.home) end
clockTick()
pcall(function()
ui.timer = lvgl.Timer { period = 40, repeat_count = -1, cb = function() tick() end }
if ui.timer and ui.timer.resume then ui.timer:resume() end
end)
pcall(function()
ui.clockTimer = lvgl.Timer { period = 1000, repeat_count = -1,
cb = function() clockTick() end }
if ui.clockTimer and ui.clockTimer.resume then ui.clockTimer:resume() end
end)
end
local okbuild, errbuild = pcall(build)
if not okbuild then
pcall(function()
local r = lvgl.Object(nil, { x = 0, y = 0, w = W, h = H, bg_color = 0x000000,
bg_opa = 255, border_width = 0 })
r:Label { x = 20, y = 20, w = W - 40, h = H - 40,
text = 'BUILD FAILED:\n' .. tostring(errbuild),
text_color = '#ffffff', font_size = 13, text_font = F_BODY }
end)
end
ui.init = function()
pcall(build)
return ui.root
end
function pageOnPause()
if ui.timer then pcall(function() ui.timer:pause() end) end
if ui.clockTimer then pcall(function() ui.clockTimer:pause() end) end
end
function pageOnResume()
if ui.timer then pcall(function() ui.timer:resume() end) end
if ui.clockTimer then pcall(function() ui.clockTimer:resume() end) end
end
return ui