818 lines
30 KiB
Lua
818 lines
30 KiB
Lua
--[[----------------------------------------------------------------------------
|
||
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
|