update at 2026-03-17 10:37:27
This commit is contained in:
115
dash/docs/kindle-voyage-5.13.6-bootstrap-zh.md
Normal file
115
dash/docs/kindle-voyage-5.13.6-bootstrap-zh.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Kindle Voyage 5.13.6 新机 Bootstrap 说明
|
||||
|
||||
## 目标
|
||||
|
||||
把“同型号新机拉齐能力”收敛成一个单入口脚本:
|
||||
|
||||
- 预置 `WatchThis` payload
|
||||
- 预置 `KUAL / MRPI / USBNetwork / kindle-dash`
|
||||
- 预置 SSH 恢复脚本
|
||||
- SSH 打通后自动同步 dashboard、切主题、立即出图
|
||||
|
||||
对应脚本:
|
||||
|
||||
- [bootstrap-new-kindle.sh](/Users/gavin/kindle-dash/bootstrap-new-kindle.sh)
|
||||
|
||||
## 先说结论
|
||||
|
||||
这个脚本不是“100% 零交互刷机”。
|
||||
|
||||
当前仍然不能完全自动化的部分有:
|
||||
|
||||
1. `WatchThis` demo 流程本身需要设备端手势与点击
|
||||
2. `;log mrpi` 需要在 Kindle 搜索栏手工触发
|
||||
3. 首次 SSH 最稳的方式仍然是在 `KTerm` 里执行:
|
||||
|
||||
```sh
|
||||
sh /mnt/us/ssh-force-dropbear-22.sh
|
||||
```
|
||||
|
||||
所以这套 bootstrap 的真实定位是:
|
||||
|
||||
- 尽量把 Mac 侧和文件预置自动化
|
||||
- 把设备侧必须手工的动作压到最少
|
||||
|
||||
## 脚本模式
|
||||
|
||||
### 1. `prepare-storage`
|
||||
|
||||
当 Kindle 以 USB 存储方式挂载到 Mac 后执行:
|
||||
|
||||
```sh
|
||||
sh bootstrap-new-kindle.sh prepare-storage
|
||||
```
|
||||
|
||||
它会自动:
|
||||
|
||||
- 把 `KV-5.13.6.zip`、`demo.json` 放到 `.demo/`
|
||||
- 创建 `.demo/goodreads/`
|
||||
- 把 `Update_hotfix_watchthis_custom.bin` 放到 Kindle 根目录
|
||||
- 把 `extensions/`、`mrpackages/`、`dashboard/` 预置到 Kindle
|
||||
- 把 `scripts/kindle/*.sh` 拷到 Kindle 根目录,供 `KTerm` 使用
|
||||
|
||||
### 2. `post-ssh`
|
||||
|
||||
当 Kindle 已连上 Wi‑Fi,且你已经在 `KTerm` 拉起 `dropbear` 后执行:
|
||||
|
||||
```sh
|
||||
sh bootstrap-new-kindle.sh post-ssh
|
||||
```
|
||||
|
||||
它会自动:
|
||||
|
||||
- 修复设备侧 SSH 辅助脚本权限
|
||||
- 尝试同步 `authorized_keys`
|
||||
- 同步 dashboard 运行时和主题包
|
||||
- 立即切到指定主题并把背景画到屏幕上
|
||||
|
||||
可选:
|
||||
|
||||
```sh
|
||||
sh bootstrap-new-kindle.sh post-ssh -t simple -o portrait
|
||||
sh bootstrap-new-kindle.sh post-ssh --start-dashboard
|
||||
```
|
||||
|
||||
### 3. `all`
|
||||
|
||||
默认模式:
|
||||
|
||||
```sh
|
||||
sh bootstrap-new-kindle.sh
|
||||
```
|
||||
|
||||
逻辑是:
|
||||
|
||||
- 如果检测到 `/Volumes/Kindle`,先做 `prepare-storage`
|
||||
- 如果同时检测到 `ssh kindle` 可用,再继续做 `post-ssh`
|
||||
- 哪一段当前做不了,就明确打印下一步人工动作
|
||||
|
||||
## 设备侧最短人工步骤
|
||||
|
||||
1. 恢复出厂并进入 demo mode
|
||||
2. 到真正的 `Sideload Content` 时机
|
||||
3. 让脚本已预置好的 `.demo` payload 生效
|
||||
4. 通过 `Get Started` 完成越狱
|
||||
5. 搜索 `;log mrpi`
|
||||
6. 在 `KUAL` 中先执行 `Rename OTA Binaries -> Rename`
|
||||
7. 连上 Wi‑Fi
|
||||
8. 打开 `KTerm`,执行:
|
||||
|
||||
```sh
|
||||
sh /mnt/us/ssh-force-dropbear-22.sh
|
||||
```
|
||||
|
||||
9. 回到 Mac,执行:
|
||||
|
||||
```sh
|
||||
sh bootstrap-new-kindle.sh post-ssh
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- WatchThis 越狱路径:
|
||||
[kindle-voyage-5.13.6-watchthis-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-watchthis-zh.md)
|
||||
- SSH 打通与 KTerm 兜底:
|
||||
[kindle-voyage-5.13.6-dual-ssh-playbook-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md)
|
||||
@@ -12,6 +12,12 @@
|
||||
- 白屏出现时,dashboard 本身往往没有真正接管成功,更像是 `framework/KUAL` 启动链在中途被打断
|
||||
- 当前最稳定的恢复路径,仍然是通过 SSH 执行 `./stop.sh`
|
||||
|
||||
补充记录一个当前仍未修住、但边界已经比较清楚的问题:
|
||||
|
||||
- 从 `KUAL` 进入 dashboard 后,再尝试回到 dashboard / KUAL 的原生 UI 路径,仍可能落入白屏
|
||||
- 这个问题不应再继续归因到背景图、时钟或页面布局
|
||||
- 当前更合理的判断,仍然是 `KUAL -> start.sh -> dash.sh` 的切换链路不稳定
|
||||
|
||||
这份文档只记录当前交接结论,不再继续尝试修复。
|
||||
|
||||
## 已确认的事实
|
||||
@@ -290,6 +296,23 @@ ssh kindle 'start webreader'
|
||||
|
||||
而不是继续怀疑背景图、时钟绘制或顶栏遮罩。
|
||||
|
||||
进一步说,当前建议把这个遗留问题固定表述为:
|
||||
|
||||
- “`KUAL -> Kindle Dashboard` 与 `dashboard -> 原生 UI/KUAL` 之间的边界切换不稳定,表现为白屏”
|
||||
|
||||
建议的修复方向是:
|
||||
|
||||
1. 不要让 KUAL 直接同步执行 `/mnt/us/dashboard/start.sh`
|
||||
2. 改成由一个独立 wrapper 先脱离 KUAL / framework 会话,再延迟启动 dashboard
|
||||
3. 继续保留 `ssh kindle 'cd /mnt/us/dashboard && DEBUG=true ./start.sh'` 作为唯一已验证稳定的进入方式
|
||||
4. 继续保留 `./stop.sh` 作为唯一已验证稳定的退出恢复方式
|
||||
|
||||
等 SSH 恢复后,再围绕下面三点做实机验证:
|
||||
|
||||
1. KUAL wrapper 是否还能触发 `framework` 被 TERM
|
||||
2. `start.sh` 的后台脱离方式是否足够彻底
|
||||
3. `stop.sh` 后是否还需要补 `start webreader`
|
||||
|
||||
## 这轮涉及的关键文件
|
||||
|
||||
- [dash/src/dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh)
|
||||
|
||||
@@ -499,7 +499,7 @@ export PRE_SLEEP_GRACE_SECONDS=10
|
||||
|
||||
补充两条运行期约束:
|
||||
|
||||
- `clock-index.sh` 取当前时间时必须沿用 `TIMEZONE`,不能直接读系统默认时区
|
||||
- `TIMEZONE` 只用于 `next-wakeup` 的 cron 时区计算;`clock-index.sh` 需要单独走固定 UTC 偏移或设备可识别的时区配置,不能直接退回系统默认时区
|
||||
- `PRE_SLEEP_GRACE_SECONDS` 这类“进入休眠前的可中断窗口”必须从实际休眠时长里扣掉,否则分钟刷新会长期落后一个节拍
|
||||
|
||||
当前实现里,时钟区域的适配已经分成两层:
|
||||
|
||||
415
dash/docs/pagepress-theme-menu-plan.zh.md
Normal file
415
dash/docs/pagepress-theme-menu-plan.zh.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# Kindle Dashboard 双翻页键主题菜单方案
|
||||
|
||||
## 0. 当前状态
|
||||
|
||||
本文是评审方案,不是实机结论。
|
||||
|
||||
当前 Kindle 已掉出 Wi-Fi,SSH 中断,因此这份文档的目标是:
|
||||
|
||||
- 先把“左右同时按下翻页键,呼出主题选择页面”的方案固定下来
|
||||
- 明确交互、进程模型、文件落点和风险
|
||||
- 等 SSH 恢复后,再按这份方案做实机联调和裁剪
|
||||
|
||||
本文默认设备为:
|
||||
|
||||
- Kindle Voyage
|
||||
- 固件 5.13.6
|
||||
- dashboard 运行时会停掉 framework / webreader
|
||||
|
||||
## 1. 已确认事实
|
||||
|
||||
### 1.1 左右翻页键在内核里是两个独立键
|
||||
|
||||
实机上已经确认:
|
||||
|
||||
- 输入设备名为 `fsr_keypad`
|
||||
- 设备节点为 `/dev/input/event2`
|
||||
- `evtest /dev/input/event2` 可识别:
|
||||
- `Event code 104 (PageUp)`
|
||||
- `Event code 109 (PageDown)`
|
||||
|
||||
这意味着:
|
||||
|
||||
- 左右翻页键不是一个“翻页动作”
|
||||
- 而是两个可以分别监听的 `EV_KEY`
|
||||
- 软件层可以把它们组合成一个“组合键”
|
||||
|
||||
### 1.2 组合键不可能在同一时刻上报
|
||||
|
||||
Linux 输入事件一定是串行上报的。
|
||||
|
||||
所以“左右同时按下”在实现上必须解释为:
|
||||
|
||||
- 两个键在一个很短的时间窗口内先后进入 pressed
|
||||
|
||||
而不能解释为:
|
||||
|
||||
- 两个键共享同一个内核时间戳
|
||||
|
||||
推荐窗口:
|
||||
|
||||
- `300ms` 到 `350ms`
|
||||
|
||||
### 1.3 真正系统挂起时,用户态监听器不会继续工作
|
||||
|
||||
当前 dashboard 会在休眠路径里进入:
|
||||
|
||||
```sh
|
||||
echo "mem" >/sys/power/state
|
||||
```
|
||||
|
||||
一旦进入这个状态:
|
||||
|
||||
- 用户态 shell / Lua / evtest 监听器都会停掉
|
||||
- 无法继续等待双键输入
|
||||
|
||||
因此本方案的有效范围是:
|
||||
|
||||
- dashboard 正在运行
|
||||
- Kindle 尚未进入真正的 `mem` 挂起
|
||||
|
||||
这也是为什么本方案先聚焦:
|
||||
|
||||
- 运行态主题菜单
|
||||
|
||||
而不把目标直接扩展为:
|
||||
|
||||
- 真休眠态唤醒菜单
|
||||
|
||||
## 2. 目标
|
||||
|
||||
目标能力如下:
|
||||
|
||||
1. 用户在 dashboard 运行态下,同时按下左右翻页键
|
||||
2. 屏幕弹出一个 KUAL 风格的主题选择页面
|
||||
3. 用户通过翻页键在主题列表中移动选中项
|
||||
4. 用户再次同时按下左右翻页键,确认并切换主题
|
||||
5. 切换完成后,立即刷新背景和时钟
|
||||
|
||||
本阶段非目标:
|
||||
|
||||
- 不做真正休眠态下的按键唤醒菜单
|
||||
- 不在 Kindle 上实现复杂触摸手势
|
||||
- 不做缩略图式主题画廊
|
||||
- 不做 theme + orientation 同时编辑的完整设置页
|
||||
|
||||
## 3. 推荐交互
|
||||
|
||||
### 3.1 触发方式
|
||||
|
||||
触发手势:
|
||||
|
||||
- 左右翻页键在 `350ms` 内同时按下
|
||||
|
||||
解释规则:
|
||||
|
||||
- 先按左,再按右,且间隔不超过 `350ms`,视为组合键
|
||||
- 先按右,再按左,且间隔不超过 `350ms`,也视为组合键
|
||||
- 单独按左或单独按右,不触发菜单
|
||||
|
||||
### 3.2 菜单打开后的操作
|
||||
|
||||
推荐第一版交互保持纯物理键可用:
|
||||
|
||||
- `PageUp`:向上移动
|
||||
- `PageDown`:向下移动
|
||||
- 再次双键同时按下:确认当前主题
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 不依赖 framework
|
||||
- 不依赖系统触摸 UI
|
||||
- 行为闭环简单,便于在无 SSH 时本机恢复
|
||||
|
||||
### 3.3 方向处理
|
||||
|
||||
推荐第一版只切换 `theme id`,保持当前方向不变。
|
||||
|
||||
例如:
|
||||
|
||||
- 当前是 `simple / landscape`
|
||||
- 菜单里选中 `paper`
|
||||
- 最终切到 `paper / landscape`
|
||||
|
||||
如果目标主题不支持当前方向,再回退到:
|
||||
|
||||
- 该主题可用的默认方向
|
||||
|
||||
这样能避免菜单第一版就把交互复杂度拉高到二维。
|
||||
|
||||
## 4. 菜单布局
|
||||
|
||||
### 4.1 布局风格
|
||||
|
||||
菜单布局可以模拟 KUAL,但不追求逐像素复刻。
|
||||
|
||||
推荐视觉结构:
|
||||
|
||||
- 全屏白底
|
||||
- 顶部两行标题
|
||||
- `Kindle Dashboard`
|
||||
- `Theme Menu`
|
||||
- 中部竖向列表
|
||||
- 当前选中项前用 `>` 标识
|
||||
- 底部两行提示文案
|
||||
|
||||
示意:
|
||||
|
||||
```text
|
||||
Kindle Dashboard
|
||||
Theme Menu
|
||||
Orientation: landscape
|
||||
Selected: Simple
|
||||
--------------------------------
|
||||
|
||||
> Default (default)
|
||||
Paper (paper)
|
||||
Classic (classic)
|
||||
Simple (simple)
|
||||
|
||||
PageUp/PageDown: move
|
||||
Press both keys: apply
|
||||
```
|
||||
|
||||
### 4.2 为什么不做缩略图页
|
||||
|
||||
在当前阶段,不建议直接做缩略图式主题预览页。
|
||||
|
||||
原因:
|
||||
|
||||
- 需要更多图形绘制和排版能力
|
||||
- 需要解决选中态、滚动和点击区域映射
|
||||
- 在 SSH 不稳定时,调试成本显著上升
|
||||
|
||||
所以推荐第一版先做:
|
||||
|
||||
- 纯文本 KUAL 风格列表
|
||||
|
||||
确认流程稳定后,再考虑升级到:
|
||||
|
||||
- 缩略图 + 文本说明
|
||||
|
||||
## 5. 进程模型
|
||||
|
||||
### 5.1 推荐采用独立监听服务
|
||||
|
||||
推荐新增一个独立后台服务,例如:
|
||||
|
||||
- `dash/src/local/theme-menu-service.sh`
|
||||
|
||||
职责:
|
||||
|
||||
- 独立监听 `/dev/input/event2`
|
||||
- 识别组合键
|
||||
- 维护菜单状态
|
||||
- 在确认后调用现有主题切换链路
|
||||
|
||||
不建议把这套逻辑直接塞进 `dash.sh` 主循环。
|
||||
|
||||
原因:
|
||||
|
||||
- 主循环核心职责已经是拉图、刷屏、休眠
|
||||
- 组合键菜单属于输入状态机
|
||||
- 两者耦合后,调试和排错都会变差
|
||||
|
||||
### 5.2 与现有链路的关系
|
||||
|
||||
推荐关系如下:
|
||||
|
||||
1. `dash.sh` 启动时拉起 `theme-menu-service.sh`
|
||||
2. `theme-menu-service.sh` 常驻监听 `event2`
|
||||
3. 识别到组合键后,本机绘制菜单
|
||||
4. 用户确认后,调用现有:
|
||||
- `/mnt/us/dashboard/switch-theme.sh <theme-id> [orientation]`
|
||||
5. `switch-theme.sh` 继续负责:
|
||||
- 同步主题配置
|
||||
- 拉最新背景
|
||||
- 刷新屏幕
|
||||
- 重绘时钟
|
||||
|
||||
也就是说,菜单服务只负责:
|
||||
|
||||
- 触发
|
||||
- 选择
|
||||
- 调用已有切换入口
|
||||
|
||||
而不是重新实现一套主题切换。
|
||||
|
||||
## 6. 事件状态机
|
||||
|
||||
### 6.1 空闲态
|
||||
|
||||
空闲态只关注组合键触发。
|
||||
|
||||
状态变量:
|
||||
|
||||
- `pending_key`
|
||||
- `pending_time`
|
||||
- `combo_window_seconds`
|
||||
|
||||
逻辑:
|
||||
|
||||
1. 收到 `PageUp down`
|
||||
- 记录 `pending_key=up`
|
||||
2. 收到 `PageDown down`
|
||||
- 如果此前 `pending_key=up` 且时间差小于窗口,判定为组合键
|
||||
3. 反向顺序同理
|
||||
4. 组合键成立后,打开菜单
|
||||
|
||||
### 6.2 菜单态
|
||||
|
||||
菜单态下,状态机切成另一套规则:
|
||||
|
||||
- `PageUp` release:选中项上移
|
||||
- `PageDown` release:选中项下移
|
||||
- 组合键:确认当前项
|
||||
|
||||
推荐选中项循环:
|
||||
|
||||
- 第一项再上移 -> 跳到最后一项
|
||||
- 最后一项再下移 -> 跳到第一项
|
||||
|
||||
这样可以减少边界判断和失败反馈。
|
||||
|
||||
## 7. 文件落点
|
||||
|
||||
推荐新增或修改这些文件。
|
||||
|
||||
### 7.1 新增
|
||||
|
||||
- `dash/src/local/theme-menu-service.sh`
|
||||
- 常驻监听器和菜单状态机
|
||||
|
||||
### 7.2 修改
|
||||
|
||||
- `dash/src/local/theme-json.lua`
|
||||
- 新增 `list` 子命令
|
||||
- 输出主题列表给菜单服务消费
|
||||
|
||||
- `dash/src/local/env.sh`
|
||||
- 新增菜单相关开关,例如:
|
||||
- `THEME_MENU_ENABLED`
|
||||
- `THEME_MENU_EVENT_DEVICE`
|
||||
- `THEME_MENU_COMBO_WINDOW_SECONDS`
|
||||
|
||||
- `dash/src/dash.sh`
|
||||
- 在初始化阶段拉起菜单监听服务
|
||||
|
||||
- `dash/src/stop.sh`
|
||||
- 停止 dashboard 时顺手停止菜单服务
|
||||
|
||||
- `scripts/sync-layered-clock-to-kindle.sh`
|
||||
- 把新增服务脚本同步到设备
|
||||
|
||||
## 8. 推荐配置
|
||||
|
||||
建议新增这些环境变量:
|
||||
|
||||
```sh
|
||||
export THEME_MENU_ENABLED=true
|
||||
export THEME_MENU_EVENT_DEVICE="/dev/input/event2"
|
||||
export THEME_MENU_COMBO_WINDOW_SECONDS=0.35
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `THEME_MENU_ENABLED`
|
||||
- 总开关
|
||||
- `THEME_MENU_EVENT_DEVICE`
|
||||
- Voyage 上通常是 `event2`
|
||||
- 后续换机型时可以只改这个
|
||||
- `THEME_MENU_COMBO_WINDOW_SECONDS`
|
||||
- 组合键判定窗口
|
||||
|
||||
## 9. 风险与限制
|
||||
|
||||
### 9.1 真休眠态不可用
|
||||
|
||||
这是本方案最重要的限制。
|
||||
|
||||
只要系统真的进入:
|
||||
|
||||
- `echo "mem" >/sys/power/state`
|
||||
|
||||
那么:
|
||||
|
||||
- 监听器就不会继续运行
|
||||
- 双键菜单也不会再响应
|
||||
|
||||
因此第一版推荐配套策略是:
|
||||
|
||||
- 调试阶段 `DISABLE_SYSTEM_SUSPEND=true`
|
||||
- 菜单只保证运行态可用
|
||||
|
||||
后续如果要支持“近似待机但仍可双键唤出菜单”,再单独设计:
|
||||
|
||||
- sleeping 页面但不进真 suspend
|
||||
|
||||
### 9.2 菜单样式是 KUAL 风格,不是 KUAL 组件
|
||||
|
||||
本方案里的“KUAL 风格”是指:
|
||||
|
||||
- 白底
|
||||
- 列表
|
||||
- 简洁标题
|
||||
- 文本选中态
|
||||
|
||||
并不是:
|
||||
|
||||
- 真的把 KUAL 的 Java/UI 组件拉起来
|
||||
|
||||
原因是当前 dashboard 已经停掉 framework,直接复用 KUAL UI 的成本会更高。
|
||||
|
||||
### 9.3 第一版不依赖触摸
|
||||
|
||||
从评审角度看,第一版最好不要把选择动作建立在触摸上。
|
||||
|
||||
原因:
|
||||
|
||||
- 触摸设备事件会更复杂
|
||||
- 点选区域映射也更容易出错
|
||||
- 无 SSH 时问题更难恢复
|
||||
|
||||
所以第一版推荐:
|
||||
|
||||
- 只用翻页键完成整个菜单闭环
|
||||
|
||||
## 10. 后续验证路径
|
||||
|
||||
等 SSH 恢复后,建议按这个顺序验证:
|
||||
|
||||
1. 确认 `evtest /dev/input/event2` 仍能看到:
|
||||
- `PageUp`
|
||||
- `PageDown`
|
||||
2. 先同步本地最新 dashboard 代码到 Kindle
|
||||
- 本轮已在本地加入一个“短按 `power` 提前唤醒后,额外保持 60 秒”的改动
|
||||
- 相关配置在 `dash/src/local/env.sh`:
|
||||
- `MANUAL_WAKE_KEEP_AWAKE_SECONDS=60`
|
||||
- `MANUAL_WAKE_EARLY_TOLERANCE_SECONDS=5`
|
||||
- 相关运行逻辑在 `dash/src/dash.sh`
|
||||
3. 重启 dashboard,先单独验证短按 `power`:
|
||||
- 从休眠态短按 `power`
|
||||
- 预期不再只亮约 3 秒
|
||||
- 预期应保持约 60 秒再回到下一轮休眠
|
||||
4. 让 dashboard 保持运行态,不进真 suspend
|
||||
5. 手动启动菜单服务
|
||||
6. 验证:
|
||||
- 双键能否稳定打开菜单
|
||||
- 单键移动是否会误触组合键
|
||||
- 再次双键能否稳定确认
|
||||
7. 验证主题切换后:
|
||||
- 背景立即更新
|
||||
- 时钟位置和方向保持正确
|
||||
|
||||
## 11. 结论
|
||||
|
||||
在当前仓库和 Kindle Voyage 的约束下,推荐采用下面这条路线:
|
||||
|
||||
- 监听 `/dev/input/event2`
|
||||
- 用短时间窗口识别 `PageUp + PageDown` 组合键
|
||||
- 在运行态下绘制一个 KUAL 风格的文本主题菜单
|
||||
- 用 `PageUp / PageDown` 导航
|
||||
- 再次双键确认
|
||||
- 最终复用现有 `switch-theme.sh` 完成主题切换
|
||||
|
||||
这是当前成本最低、最容易恢复、也最适合在 SSH 不稳定阶段先落地评审的方案。
|
||||
@@ -1,4 +1,52 @@
|
||||
# Kindle Dashboard 多主题方案
|
||||
# Kindle Dashboard 多主题与方向方案
|
||||
|
||||
## 0. 当前实现状态(2026-03-16)
|
||||
|
||||
以下能力已经落地,不再只是设计目标:
|
||||
|
||||
- `calendar/` 网站已支持 `theme + orientation` 两维预览
|
||||
- `mode=background` 下不会显示预览菜单,避免污染截图
|
||||
- `calendar/` 已产出:
|
||||
- `dist/themes.json`
|
||||
- `dist/themes/<theme-id>.json`
|
||||
- `dist/themes/<theme-id>/<orientation>/kindlebg.png`
|
||||
- 兼容输出仍保留:
|
||||
- `dist/kindlebg.png`
|
||||
- `dist/clock-region.json`
|
||||
- Kindle 已支持:
|
||||
- 拉取 `themes.json` 和主题级 JSON
|
||||
- 通过 `/mnt/us/dashboard/switch-theme.sh <theme-id> [orientation]` 立即切换
|
||||
- 优先使用本地已同步的主题背景图
|
||||
- 优先使用本地已同步的主题索引和主题 JSON
|
||||
- 本地没有对应背景图时,再回退到远端 `background.url`
|
||||
- 同步脚本 `scripts/sync-layered-clock-to-kindle.sh` 会先批量导出全部主题背景,再把 `/mnt/us/dashboard/themes/` 同步到设备
|
||||
|
||||
### 0.1 横向主题的抓屏评审约定
|
||||
|
||||
`fbgrab` 抓到的是 Kindle 的原始 framebuffer 图,尺寸始终是纵向的 `1072 x 1448`。
|
||||
|
||||
这意味着:
|
||||
|
||||
- `portrait` 主题下,`physical.png` 与 `raw.png` 相同
|
||||
- `landscape` + `logo_right` 主题下,日常评审应优先看 `physical.png`
|
||||
- `raw.png` 只用于排查原始 framebuffer 坐标
|
||||
- `physical.png` 等于把 raw 图按设备实际摆放方向逆时针旋转 90 度后的结果
|
||||
|
||||
仓库里已经补了一个辅助脚本:
|
||||
|
||||
```sh
|
||||
sh scripts/capture-kindle-screen.sh kindle landscape-check
|
||||
```
|
||||
|
||||
它会同时输出两张图:
|
||||
|
||||
- `tmp/landscape-check-raw.png`
|
||||
- `tmp/landscape-check-physical.png`
|
||||
|
||||
其中:
|
||||
|
||||
- `raw.png` 用于排查 framebuffer 原始坐标
|
||||
- `physical.png` 用于按 Kindle 实际摆放方向评审横向主题
|
||||
|
||||
## 1. 背景
|
||||
|
||||
@@ -7,39 +55,48 @@
|
||||
- `calendar/` 负责渲染网页和导出背景图
|
||||
- `dash/` 运行在 Kindle 上,负责拉取背景图并在本地重画时钟
|
||||
|
||||
这里先明确一个约束,后续方案都基于它展开:
|
||||
这次方案先明确两个概念:
|
||||
|
||||
- `theme`:视觉主题,例如 `default`、`paper`、`classic`
|
||||
- `orientation`:显示方向,例如 `portrait`、`landscape`
|
||||
|
||||
其中方向不是主题名的一部分,而是每个主题都必须包含的两套样式:
|
||||
|
||||
- `portrait`
|
||||
- Kindle 竖向摆放
|
||||
- 设备外部面板上的 `kindle` logo 在下方
|
||||
- `landscape`
|
||||
- Kindle 横向摆放
|
||||
- 设备外部面板上的 `kindle` logo 在右侧
|
||||
|
||||
也就是说,后续系统模型应当是:
|
||||
|
||||
- 每个主题都包含纵向和横向两套显示样式
|
||||
- Kindle 端仍然保持“拉背景图片 + 绘制时钟”的职责
|
||||
- 不把整页日历渲染放回 Kindle
|
||||
- 主题的名称、路径、时钟占位信息都由 `calendar/` 提供
|
||||
|
||||
这次多主题方案要同时满足两类需求:
|
||||
|
||||
1. 访问 `calendar` 网站时,可以在页面上切换主题预览效果
|
||||
2. 后续 Kindle 端如果需要切换主题,也只依赖 `calendar` 提供的主题配置,不自己猜路径或布局
|
||||
- 时钟的位置、尺寸和绘制参数都由 `calendar/` 提供
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本方案的目标是:
|
||||
|
||||
1. 访问网站时,可以通过顶部可选菜单切换主题样式
|
||||
1. 访问网站时,可以切换主题和方向进行预览
|
||||
2. 网站预览效果应尽量与 Kindle 最终显示效果一致
|
||||
3. 当前背景生成流程继续可用,不因主题预览被破坏
|
||||
4. `calendar/` 提供统一的主题清单 JSON,作为主题的单一真相源
|
||||
3. 当前背景生成流程继续可用,不因预览能力被破坏
|
||||
4. `calendar/` 提供统一的主题清单 JSON,作为主题与方向配置的单一真相源
|
||||
5. Kindle 只需要读取固定位置的主题清单和主题配置 JSON
|
||||
6. Kindle 切换主题后,应立即拉取该主题背景和主题级配置并刷新
|
||||
6. Kindle 切换主题或方向后,应立即拉取对应背景并刷新
|
||||
7. 时钟的位置、尺寸和绘制参数由 `calendar` 的主题配置决定,Kindle 不自行决定
|
||||
|
||||
非目标:
|
||||
|
||||
- 不在 Kindle 上重新渲染整张页面
|
||||
- 不让 Kindle 维护主题路径规则
|
||||
- 不要求第一阶段就把现有导出脚本改成强制批量导出
|
||||
- 不让 Kindle 自行推导时钟区域布局
|
||||
- 不要求第一阶段就做“本地表盘素材包”体系
|
||||
|
||||
## 3. 设计原则
|
||||
|
||||
### 3.1 `calendar/` 是主题真相源
|
||||
### 3.1 `calendar/` 是单一真相源
|
||||
|
||||
主题系统的单一真相源放在 `calendar/` 侧。
|
||||
|
||||
@@ -47,7 +104,7 @@
|
||||
|
||||
- 一个主题清单文件,例如 `themes.json`
|
||||
- 每个主题自己的配置文件,例如 `default.json`、`paper.json`
|
||||
- 每个主题自己的背景图
|
||||
- 每个主题在不同方向下的背景图资源
|
||||
|
||||
其中 `themes.json` 至少描述:
|
||||
|
||||
@@ -55,6 +112,7 @@
|
||||
- 每个主题叫什么名字
|
||||
- 每个主题的配置 JSON 在哪里
|
||||
- 默认主题是谁
|
||||
- 默认方向是谁
|
||||
|
||||
这样做的好处是:
|
||||
|
||||
@@ -62,46 +120,56 @@
|
||||
- 新增主题时,Kindle 无需改代码里的路径规则
|
||||
- 网站预览和 Kindle 切换都依赖同一套主题元数据
|
||||
|
||||
这里再明确一下边界。
|
||||
### 3.2 `theme` 和 `orientation` 分开建模
|
||||
|
||||
远端主题配置只有两类:
|
||||
不建议把“横向主题”“纵向主题”作为主题名本身。
|
||||
|
||||
- `themes.json`
|
||||
- 每个主题自己的 JSON,例如 `themes/default.json`
|
||||
推荐模型是:
|
||||
|
||||
除此之外:
|
||||
- `theme`
|
||||
- `orientation`
|
||||
|
||||
- 背景图属于资源文件,不属于主题配置
|
||||
- Kindle 本地缓存属于运行时状态,不属于主题配置
|
||||
例如:
|
||||
|
||||
### 3.2 网站预览与导出解耦
|
||||
- `theme=default` + `orientation=portrait`
|
||||
- `theme=default` + `orientation=landscape`
|
||||
- `theme=paper` + `orientation=portrait`
|
||||
|
||||
主题预览只是网站访问时的交互能力,不应破坏当前导出主流程。
|
||||
这样更符合实际含义:
|
||||
|
||||
建议明确两条链路:
|
||||
- `theme` 代表视觉风格
|
||||
- `orientation` 代表设备摆放方向与对应布局
|
||||
|
||||
- 预览链路:用户访问 `/?mode=full&theme=<id>`,通过页面菜单切换主题
|
||||
- 导出链路:现有脚本继续生成默认背景图,保持兼容
|
||||
### 3.3 网站预览与导出解耦
|
||||
|
||||
主题和方向预览只是网站访问时的交互能力,不应破坏当前导出主流程。
|
||||
|
||||
当前已明确三条链路:
|
||||
|
||||
- 预览链路:用户访问 `/?mode=full&theme=<id>&orientation=<orientation>`,通过页面菜单切换主题和方向
|
||||
- 兼容导出链路:`export:background` 继续生成默认背景图,保持兼容
|
||||
- 批量导出链路:`export:themes` 一次性生成全部主题和方向背景
|
||||
|
||||
也就是说:
|
||||
|
||||
- 预览能力先落地
|
||||
- 当前 `kindlebg.png` 导出链路先不强制变化
|
||||
- 多主题导出作为后续增量能力接入
|
||||
- 预览能力已经落地
|
||||
- 当前 `kindlebg.png` 导出链路继续保留
|
||||
- 多主题多方向导出已经接入
|
||||
|
||||
### 3.3 Kindle 侧最简化
|
||||
### 3.4 Kindle 侧最简化
|
||||
|
||||
Kindle 侧只保留最小能力:
|
||||
|
||||
- 拉取背景图
|
||||
- 读取主题 JSON
|
||||
- 根据当前方向读取对应 variant
|
||||
- 按 JSON 提供的时钟参数绘制时钟
|
||||
|
||||
Kindle 不负责:
|
||||
|
||||
- 推导主题路径
|
||||
- 决定时钟区域坐标
|
||||
- 维护主题布局规则
|
||||
- 维护布局规则
|
||||
|
||||
推荐让 Kindle 只认识一个固定入口,例如:
|
||||
|
||||
@@ -112,14 +180,14 @@ https://shell.biboer.cn:20001/themes.json
|
||||
然后从这个 JSON 中知道:
|
||||
|
||||
- 当前有哪些主题
|
||||
- 默认主题和默认方向是什么
|
||||
- 每个主题的配置 JSON 在哪里
|
||||
- 切换到该主题时该去拉哪个背景图
|
||||
|
||||
### 3.4 当前时钟实现已经足够轻量
|
||||
### 3.5 当前时钟实现已经足够轻量
|
||||
|
||||
当前 Kindle 侧时钟是本地几何绘制,不依赖外部表盘或指针素材文件。
|
||||
|
||||
这意味着第一阶段主题切换只需要关注:
|
||||
这意味着第一阶段主题与方向切换只需要关注:
|
||||
|
||||
- 背景图 URL
|
||||
- 时钟区域位置和尺寸
|
||||
@@ -137,24 +205,25 @@ https://shell.biboer.cn:20001/themes.json
|
||||
{
|
||||
"updatedAt": "2026-03-16T10:00:00+08:00",
|
||||
"defaultThemeId": "default",
|
||||
"defaultOrientation": "portrait",
|
||||
"themes": [
|
||||
{
|
||||
"id": "default",
|
||||
"label": "Default",
|
||||
"configUrl": "https://shell.biboer.cn:20001/themes/default.json",
|
||||
"previewQuery": "?mode=full&theme=default"
|
||||
"orientations": ["portrait", "landscape"]
|
||||
},
|
||||
{
|
||||
"id": "paper",
|
||||
"label": "Paper",
|
||||
"configUrl": "https://shell.biboer.cn:20001/themes/paper.json",
|
||||
"previewQuery": "?mode=full&theme=paper"
|
||||
"orientations": ["portrait", "landscape"]
|
||||
},
|
||||
{
|
||||
"id": "classic",
|
||||
"label": "Classic",
|
||||
"configUrl": "https://shell.biboer.cn:20001/themes/classic.json",
|
||||
"previewQuery": "?mode=full&theme=classic"
|
||||
"orientations": ["portrait", "landscape"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -167,33 +236,66 @@ https://shell.biboer.cn:20001/themes.json
|
||||
|
||||
### 4.2 主题配置 `<theme-id>.json`
|
||||
|
||||
每个主题再提供一份主题级配置,例如:
|
||||
每个主题提供一份主题级配置,内部包含两个方向的 variant,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "default",
|
||||
"label": "Default",
|
||||
"background": {
|
||||
"url": "https://shell.biboer.cn:20001/themes/default/kindlebg.png",
|
||||
"refreshIntervalMinutes": 120
|
||||
},
|
||||
"clock": {
|
||||
"x": 262,
|
||||
"y": 55,
|
||||
"width": 220,
|
||||
"height": 220,
|
||||
"faceRadiusRatio": 0.47,
|
||||
"faceStroke": 3,
|
||||
"tickOuterInset": 6,
|
||||
"majorTickLength": 14,
|
||||
"minorTickLength": 7,
|
||||
"majorTickThickness": 4,
|
||||
"minorTickThickness": 2,
|
||||
"hourLengthRatio": 0.48,
|
||||
"minuteLengthRatio": 0.72,
|
||||
"hourThickness": 9,
|
||||
"minuteThickness": 5,
|
||||
"centerRadius": 7
|
||||
"variants": {
|
||||
"portrait": {
|
||||
"devicePlacement": "logo_bottom",
|
||||
"background": {
|
||||
"path": "themes/default/portrait/kindlebg.png",
|
||||
"url": "https://shell.biboer.cn:20001/themes/default/portrait/kindlebg.png",
|
||||
"refreshIntervalMinutes": 120
|
||||
},
|
||||
"clock": {
|
||||
"x": 347,
|
||||
"y": 55,
|
||||
"width": 220,
|
||||
"height": 220,
|
||||
"faceRadiusRatio": 0.47,
|
||||
"faceStroke": 3,
|
||||
"tickOuterInset": 6,
|
||||
"majorTickLength": 14,
|
||||
"minorTickLength": 7,
|
||||
"majorTickThickness": 4,
|
||||
"minorTickThickness": 2,
|
||||
"hourLengthRatio": 0.48,
|
||||
"minuteLengthRatio": 0.72,
|
||||
"hourThickness": 9,
|
||||
"minuteThickness": 5,
|
||||
"centerRadius": 7
|
||||
}
|
||||
},
|
||||
"landscape": {
|
||||
"devicePlacement": "logo_right",
|
||||
"background": {
|
||||
"path": "themes/default/landscape/kindlebg.png",
|
||||
"url": "https://shell.biboer.cn:20001/themes/default/landscape/kindlebg.png",
|
||||
"refreshIntervalMinutes": 120
|
||||
},
|
||||
"clock": {
|
||||
"x": 795,
|
||||
"y": 659,
|
||||
"width": 220,
|
||||
"height": 220,
|
||||
"faceRadiusRatio": 0.47,
|
||||
"faceStroke": 3,
|
||||
"tickOuterInset": 6,
|
||||
"majorTickLength": 14,
|
||||
"minorTickLength": 7,
|
||||
"majorTickThickness": 4,
|
||||
"minorTickThickness": 2,
|
||||
"hourLengthRatio": 0.48,
|
||||
"minuteLengthRatio": 0.72,
|
||||
"hourThickness": 9,
|
||||
"minuteThickness": 5,
|
||||
"centerRadius": 7,
|
||||
"rotationDegrees": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -201,10 +303,26 @@ https://shell.biboer.cn:20001/themes.json
|
||||
说明:
|
||||
|
||||
- 背景图真实路径由主题配置提供,不在 Kindle 端拼接猜测
|
||||
- 时钟坐标和大小来自 `calendar` 中该主题的时钟占位,不允许 Kindle 任意改
|
||||
- 后续如果某个主题需要不同的时钟绘制参数,也由该 JSON 一起提供
|
||||
- `background.path` 是设备本地相对路径,用于优先读取已同步的本地背景
|
||||
- 每个方向都有自己的背景图和时钟区域
|
||||
- `landscape` 的 `clock` 已经是 Kindle 设备坐标,不再是网页坐标
|
||||
- `landscape` 的 `rotationDegrees=90` 表示 Kindle 本地绘制时钟时,表盘和指针再顺时针旋转 90 度
|
||||
- 时钟坐标和大小来自 `calendar` 中该主题该方向下的时钟占位,不允许 Kindle 任意改
|
||||
- 后续如果某个主题在不同方向下需要不同绘制参数,也由该 JSON 一起提供
|
||||
|
||||
### 4.3 Kindle 本地状态
|
||||
### 4.3 远端配置边界
|
||||
|
||||
远端主题配置只有两类:
|
||||
|
||||
- `themes.json`
|
||||
- 每个主题自己的 JSON,例如 `themes/default.json`
|
||||
|
||||
除此之外:
|
||||
|
||||
- 背景图属于资源文件,不属于主题配置
|
||||
- Kindle 本地缓存属于运行时状态,不属于主题配置
|
||||
|
||||
### 4.4 Kindle 本地状态
|
||||
|
||||
Kindle 本地只需要保存少量状态:
|
||||
|
||||
@@ -212,35 +330,67 @@ Kindle 本地只需要保存少量状态:
|
||||
/mnt/us/dashboard/local/theme.env
|
||||
/mnt/us/dashboard/local/state/themes.json
|
||||
/mnt/us/dashboard/local/state/current-theme.json
|
||||
/mnt/us/dashboard/local/state/theme-runtime.env
|
||||
/mnt/us/dashboard/local/state/background-updated-at
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `theme.env` 只记录当前主题 ID
|
||||
- `theme.env` 记录当前 `THEME_ID` 和 `ORIENTATION`
|
||||
- `themes.json` 是最近一次同步到本地的主题清单缓存
|
||||
- `current-theme.json` 是当前主题配置缓存
|
||||
- `theme-runtime.env` 是 Kindle 实际消费的运行时快照,包含背景路径和时钟参数
|
||||
|
||||
这几项是 Kindle 本地运行状态,不属于远端主题配置。
|
||||
例如:
|
||||
|
||||
```sh
|
||||
export THEME_ID=default
|
||||
export ORIENTATION=portrait
|
||||
```
|
||||
|
||||
### 4.5 Kindle 本地主题资源
|
||||
|
||||
当前实现中,主题背景图会随部署一起同步到 Kindle:
|
||||
|
||||
```text
|
||||
/mnt/us/dashboard/themes/default/portrait/kindlebg.png
|
||||
/mnt/us/dashboard/themes/default/landscape/kindlebg.png
|
||||
/mnt/us/dashboard/themes/paper/portrait/kindlebg.png
|
||||
/mnt/us/dashboard/themes/paper/landscape/kindlebg.png
|
||||
/mnt/us/dashboard/themes/classic/portrait/kindlebg.png
|
||||
/mnt/us/dashboard/themes/classic/landscape/kindlebg.png
|
||||
```
|
||||
|
||||
Kindle 拉背景时的规则是:
|
||||
|
||||
1. 先读 `theme-runtime.env` 中的 `BACKGROUND_PATH`
|
||||
2. 如果本地对应文件存在,直接从 `/mnt/us/dashboard/themes/...` 拷贝
|
||||
3. 如果本地不存在,再回退到 `BACKGROUND_URL`
|
||||
|
||||
主题元数据同步的规则是:
|
||||
|
||||
1. 先读本地 `/mnt/us/dashboard/themes.json`
|
||||
2. 再读本地 `/mnt/us/dashboard/themes/<theme-id>.json`
|
||||
3. 本地 bundle 不存在时,才回退到远端 URL
|
||||
|
||||
## 5. `calendar/` 侧方案
|
||||
|
||||
### 5.1 网站访问时增加主题预览菜单
|
||||
### 5.1 网站访问时增加主题与方向预览菜单
|
||||
|
||||
主题预览菜单只出现在网站访问场景,不出现在背景截图结果里。
|
||||
预览菜单只出现在网站访问场景,不出现在背景截图结果里。
|
||||
|
||||
建议行为:
|
||||
|
||||
1. 用户访问 `/?mode=full` 时,在页面顶部显示一个可选主题菜单
|
||||
2. 切换菜单时更新当前页面的 `theme` 参数
|
||||
3. 菜单位置放在顶部任意不影响截图的位置即可
|
||||
4. 在 `mode=background` 下不显示该菜单,避免影响截图导出
|
||||
1. 用户访问 `/?mode=full` 时,在页面顶部显示两个可选菜单
|
||||
2. 一个菜单切换 `theme`
|
||||
3. 一个菜单切换 `orientation`
|
||||
4. 在 `mode=background` 下不显示这些菜单,避免影响截图导出
|
||||
|
||||
建议 URL 形态如下:
|
||||
|
||||
- `/?mode=full&theme=default`
|
||||
- `/?mode=full&theme=paper`
|
||||
- `/?mode=full&theme=classic`
|
||||
- `/?mode=full&theme=default&orientation=portrait`
|
||||
- `/?mode=full&theme=default&orientation=landscape`
|
||||
- `/?mode=full&theme=paper&orientation=portrait`
|
||||
|
||||
### 5.2 预览效果与 Kindle 效果对齐
|
||||
|
||||
@@ -249,8 +399,8 @@ Kindle 本地只需要保存少量状态:
|
||||
建议做到:
|
||||
|
||||
- 预览和导出使用同一套主题配置
|
||||
- 背景导出与预览使用同一套时钟占位信息
|
||||
- Kindle 侧按主题 JSON 中的时钟参数绘制,避免预览和实机错位
|
||||
- 预览和导出使用同一套方向布局配置
|
||||
- Kindle 侧按主题 JSON 中当前方向的参数绘制,避免预览和实机错位
|
||||
|
||||
这样评审主题时,看到的页面效果和 Kindle 最终效果才是一致的。
|
||||
|
||||
@@ -267,34 +417,41 @@ calendar/dist/clock-region.json
|
||||
兼容规则建议如下:
|
||||
|
||||
- 如果导出脚本没有显式传入 `theme`,默认导出 `default`
|
||||
- 如果导出脚本没有显式传入 `orientation`,默认导出 `portrait`
|
||||
- 现有 `export:background` 的行为保持不变
|
||||
- 先不强制要求所有主题都参与默认导出
|
||||
- 默认导出继续只产出 `default + portrait`
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- 网站预览菜单先落地
|
||||
- 当前生产链路不被打断
|
||||
- Kindle 当前单主题使用方式继续可用
|
||||
- Kindle 当前单主题纵向使用方式继续可用
|
||||
|
||||
### 5.4 多主题导出作为附加能力
|
||||
### 5.4 多主题多方向导出已作为附加能力落地
|
||||
|
||||
当需要支持 Kindle 端多主题切换时,再增加多主题导出,例如:
|
||||
当前 `export:themes` 会生成如下产物:
|
||||
|
||||
```text
|
||||
calendar/dist/themes.json
|
||||
calendar/dist/themes/default.json
|
||||
calendar/dist/themes/default/kindlebg.png
|
||||
calendar/dist/themes/default/portrait/kindlebg.png
|
||||
calendar/dist/themes/default/landscape/kindlebg.png
|
||||
calendar/dist/themes/paper.json
|
||||
calendar/dist/themes/paper/kindlebg.png
|
||||
calendar/dist/themes/paper/portrait/kindlebg.png
|
||||
calendar/dist/themes/paper/landscape/kindlebg.png
|
||||
calendar/dist/themes/classic.json
|
||||
calendar/dist/themes/classic/kindlebg.png
|
||||
calendar/dist/themes/classic/portrait/kindlebg.png
|
||||
calendar/dist/themes/classic/landscape/kindlebg.png
|
||||
```
|
||||
|
||||
这里的关键点是:
|
||||
|
||||
- 原有 `calendar/dist/kindlebg.png` 继续保留
|
||||
- 多主题目录和 `themes.json` 是新增能力,不是替换能力
|
||||
- 主题路径管理由 `calendar` 决定,不在 Kindle 侧固化为固定模式
|
||||
- 多主题多方向目录和 `themes.json` 是新增能力,不是替换能力
|
||||
- 主题和方向的路径管理由 `calendar` 决定,不在 Kindle 侧固化为固定模式
|
||||
- 同步到 Kindle 时,会把整个 `calendar/dist/themes/` 目录一起带上
|
||||
- `landscape` 的网页先按 `1448x1072` 渲染,再顺时针旋转 90 度,写成 `1072x1448` 的 Kindle 设备图
|
||||
- 因此 `landscape` 的 `kindlebg.png` 是设备向产物,不是给桌面直接平铺预览的横图
|
||||
|
||||
## 6. Kindle 侧方案
|
||||
|
||||
@@ -310,29 +467,33 @@ themes.json 的 URL
|
||||
|
||||
- 被动同步:Kindle 每天拉取一次 `themes.json`
|
||||
- 主动同步:用户进入主题切换流程时,先即时拉取最新 `themes.json`
|
||||
- 如果设备上已经有本地同步的 `themes.json` 和主题 JSON,本地 bundle 优先
|
||||
|
||||
这样能兼顾:
|
||||
|
||||
- 平时低频更新
|
||||
- 切换主题时拿到最新主题清单
|
||||
|
||||
### 6.2 主题切换流程
|
||||
### 6.2 主题与方向切换流程
|
||||
|
||||
主题切换时建议执行以下流程:
|
||||
|
||||
1. 拉取最新 `themes.json`
|
||||
2. 根据用户选择的主题 ID 找到对应 `configUrl`
|
||||
3. 拉取该主题对应的 `<theme-id>.json`
|
||||
4. 将 `THEME_ID` 写入本地 `theme.env`
|
||||
5. 将主题 JSON 缓存在本地
|
||||
6. 立即拉取该主题背景图
|
||||
7. 立即执行一次全屏刷新
|
||||
8. 之后继续按当前分钟级时钟刷新逻辑运行
|
||||
4. 确定当前要使用的 `orientation`
|
||||
5. 从 `variants[orientation]` 里取出背景和时钟配置
|
||||
6. 将 `THEME_ID` 和 `ORIENTATION` 写入本地 `theme.env`
|
||||
7. 将主题 JSON 缓存在本地
|
||||
8. 优先读取本地 `themes/<theme>/<orientation>/kindlebg.png`
|
||||
9. 如果本地没有对应背景,再回退拉取 `background.url`
|
||||
10. 立即执行一次全屏刷新
|
||||
11. 之后继续按当前分钟级时钟刷新逻辑运行
|
||||
|
||||
这里有一个关键要求:
|
||||
这里有两个关键要求:
|
||||
|
||||
- 主题切换后,不等待下一个两小时周期
|
||||
- 而是立即拉取一次新主题背景
|
||||
- 主题或方向切换后,不等待下一个两小时周期
|
||||
- 而是立即拉取一次对应的新背景
|
||||
|
||||
### 6.3 时钟绘制保持现有模型
|
||||
|
||||
@@ -342,6 +503,7 @@ themes.json 的 URL
|
||||
|
||||
- 拉背景图片
|
||||
- 读取主题 JSON
|
||||
- 读取当前方向的 variant
|
||||
- 用本地 `lua + fbink` 画时钟
|
||||
|
||||
不需要在第一阶段增加:
|
||||
@@ -352,76 +514,97 @@ themes.json 的 URL
|
||||
|
||||
如果未来某个主题确实需要独立表盘素材,再在第二阶段扩展。
|
||||
|
||||
### 6.4 主题切换入口
|
||||
### 6.4 切换入口
|
||||
|
||||
入口建议做成“统一主题入口”,而不是在 KUAL 里硬编码每个主题项。
|
||||
当前已经有一个可直接 SSH 调用的入口:
|
||||
|
||||
目标交互是:
|
||||
```sh
|
||||
/mnt/us/dashboard/switch-theme.sh <theme-id> [orientation]
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```sh
|
||||
ssh kindle '/mnt/us/dashboard/switch-theme.sh default landscape'
|
||||
ssh kindle '/mnt/us/dashboard/switch-theme.sh paper portrait'
|
||||
```
|
||||
|
||||
如果本地刚改过页面样式,推荐先同步一次:
|
||||
|
||||
```sh
|
||||
sh scripts/sync-layered-clock-to-kindle.sh kindle
|
||||
```
|
||||
|
||||
KUAL 动态主题入口仍然是后续可选项,不是当前必需项。
|
||||
|
||||
如果后续要补 KUAL,目标交互仍然可以保持:
|
||||
|
||||
1. 用户点击“Theme”
|
||||
2. 设备根据 `themes.json` 获取当前可用主题清单
|
||||
3. 用户点击具体主题
|
||||
4. 立即切换到该主题
|
||||
|
||||
实现上可以接受两种方式:
|
||||
|
||||
- KUAL 能直接承载该交互,则由脚本在入口内完成
|
||||
- 如果 KUAL 不能直接动态展示,则由脚本先读取 `themes.json`,再生成或更新对应菜单
|
||||
3. 用户选择具体主题
|
||||
4. 用户选择 `portrait` 或 `landscape`
|
||||
5. 立即切换到该主题该方向
|
||||
|
||||
不管具体 UI 形态如何,数据契约都应保持一致:
|
||||
|
||||
- 主题列表来自 `themes.json`
|
||||
- 切换行为来自所选主题对应的 `<theme-id>.json`
|
||||
- 具体切换行为来自所选主题对应的 `<theme-id>.json`
|
||||
- 最终生效资源来自该主题该方向下的 variant
|
||||
|
||||
## 7. 分阶段落地
|
||||
## 7. 分阶段落地与当前进度
|
||||
|
||||
### 第一阶段:网站主题预览
|
||||
### 第一阶段:网站主题与方向预览(已完成)
|
||||
|
||||
目标:
|
||||
|
||||
- 新增 `theme` 参数
|
||||
- 增加顶部主题预览菜单
|
||||
- 新增 `orientation` 参数
|
||||
- 增加顶部预览菜单
|
||||
- 默认主题命名为 `default`
|
||||
- 新增 `paper` 和 `classic`
|
||||
- 每个主题都支持 `portrait` 和 `landscape`
|
||||
|
||||
结果:
|
||||
|
||||
- 用户可以在网站上预览三套主题
|
||||
- 用户可以在网站上预览三套主题的两种方向
|
||||
- `mode=background` 不显示预览控件
|
||||
- 现有截图导出流程保持不变
|
||||
|
||||
### 第二阶段:主题元数据发布
|
||||
### 第二阶段:主题元数据发布(已完成)
|
||||
|
||||
目标:
|
||||
|
||||
- `calendar/` 产出 `themes.json`
|
||||
- 每个主题产出自己的 `<theme-id>.json`
|
||||
- 每个主题有独立背景图 URL
|
||||
- 每个主题在两个方向下都有独立背景图 URL
|
||||
|
||||
结果:
|
||||
|
||||
- 主题列表、主题路径、时钟配置都由 `calendar/` 统一提供
|
||||
- Kindle 侧可以开始按 JSON 理解主题系统
|
||||
- 主题列表、方向列表、时钟配置都由 `calendar/` 统一提供
|
||||
- 主题配置里已同时提供 `background.path` 和 `background.url`
|
||||
|
||||
### 第三阶段:Kindle 端主题切换
|
||||
### 第三阶段:Kindle 端主题与方向切换(已完成)
|
||||
|
||||
目标:
|
||||
|
||||
- Kindle 能拉取 `themes.json`
|
||||
- Kindle 能按选择主题拉取对应的 `<theme-id>.json`
|
||||
- Kindle 能切换 `portrait` 和 `landscape`
|
||||
- 切换后立即拉取新背景并全屏刷新
|
||||
|
||||
结果:
|
||||
|
||||
- Kindle 端正式具备多主题切换能力
|
||||
- Kindle 侧仍保持最小职责
|
||||
- Kindle 端已具备多主题多方向切换能力
|
||||
- 切换后会立即刷新
|
||||
- SSH 命令切换已实机验证通过
|
||||
|
||||
### 第四阶段:按需扩展主题能力
|
||||
### 第四阶段:按需扩展主题能力(未开始)
|
||||
|
||||
只有在未来确实出现需求时,再考虑:
|
||||
|
||||
- 为某些主题提供独立表盘素材
|
||||
- 增加主题级本地资源同步
|
||||
- 增加更复杂的主题切换交互
|
||||
- 增加更复杂的切换交互
|
||||
|
||||
这一步不是当前必需项。
|
||||
|
||||
@@ -429,7 +612,7 @@ themes.json 的 URL
|
||||
|
||||
### 8.1 预览和实机效果偏差
|
||||
|
||||
如果预览和导出用的不是同一套主题配置,最终会出现:
|
||||
如果预览和导出用的不是同一套主题和方向配置,最终会出现:
|
||||
|
||||
- 网页看起来正确
|
||||
- Kindle 上时钟错位或背景不一致
|
||||
@@ -440,42 +623,53 @@ themes.json 的 URL
|
||||
- 导出
|
||||
- Kindle 时钟绘制
|
||||
|
||||
都依赖同一套主题元数据。
|
||||
都依赖同一套主题元数据和方向配置。
|
||||
|
||||
### 8.2 主题切换后的缓存问题
|
||||
### 8.2 方向切换后的缓存问题
|
||||
|
||||
如果主题切换后没有立即拉取新背景,Kindle 可能继续显示旧缓存。
|
||||
如果主题或方向切换后没有立即拉取新背景,Kindle 可能继续显示旧缓存。
|
||||
|
||||
因此切换主题时必须同时处理:
|
||||
因此切换时必须同时处理:
|
||||
|
||||
- 更新 `THEME_ID`
|
||||
- 更新 `ORIENTATION`
|
||||
- 拉取新的 `<theme-id>.json`
|
||||
- 拉取新的背景图
|
||||
- 拉取或读取当前方向对应的新背景图
|
||||
- 重置背景更新时间戳或直接覆盖缓存背景
|
||||
|
||||
如果设备本地没有同步主题背景,而远端对应 PNG 也还没发布完成,Kindle 可能拉到 HTML 或错误页而不是图片。
|
||||
|
||||
因此当前推荐做法是:
|
||||
|
||||
- 同步到 Kindle 时总是一起同步 `/mnt/us/dashboard/themes/`
|
||||
- 设备优先使用本地主题背景
|
||||
- 远端 `background.url` 只作为回退
|
||||
|
||||
### 8.3 主题清单缓存过旧
|
||||
|
||||
如果 Kindle 长时间只读本地缓存,可能看不到新主题。
|
||||
如果 Kindle 长时间只读本地缓存,可能看不到新主题或新方向配置。
|
||||
|
||||
因此建议:
|
||||
|
||||
- 日常低频同步一次 `themes.json`
|
||||
- 用户进入主题切换时再主动刷新一次
|
||||
- 用户进入切换流程时再主动刷新一次
|
||||
|
||||
## 9. 推荐结论
|
||||
## 9. 当前推荐结论
|
||||
|
||||
推荐采用下面这条路线:
|
||||
|
||||
1. 先在 `calendar` 网站上增加顶部主题预览菜单
|
||||
2. 维持当前默认背景导出链路不变
|
||||
3. 由 `calendar` 增量提供 `themes.json` 和主题级 `<theme-id>.json`
|
||||
4. Kindle 只读取固定位置的 `themes.json`
|
||||
5. 切换主题时立即拉取该主题配置和背景图,并继续使用现有本地时钟绘制
|
||||
1. 继续保留默认 `kindlebg.png` 导出链路,默认仍是 `default + portrait`
|
||||
2. 同时保留 `export:themes` 批量导出,作为多主题切换的资源来源
|
||||
3. 由 `calendar` 继续维护 `themes.json` 和主题级 `<theme-id>.json`
|
||||
4. 每个主题配置里同时提供 `portrait` 和 `landscape` 两套 variant
|
||||
5. Kindle 继续读取固定位置的 `themes.json`
|
||||
6. 切换主题或方向时优先使用本地同步的主题背景,并继续使用现有本地时钟绘制
|
||||
|
||||
这条路线的优点是:
|
||||
|
||||
- 不打断现有 `kindlebg.png` 生成流程
|
||||
- 主题路径和布局都由 `calendar` 统一管理
|
||||
- 主题和方向都由 `calendar` 统一管理
|
||||
- Kindle 侧实现最简化
|
||||
- 预览和实机显示可以基于同一套配置对齐
|
||||
- 即使远端主题图片还没发布完整,SSH 主题切换也能工作
|
||||
- 当前时钟实现不依赖表盘和指针素材,第一阶段改造成本低
|
||||
|
||||
@@ -9,6 +9,10 @@ LOW_BATTERY_CMD="$DIR/local/low-battery.sh"
|
||||
CLOCK_RENDER_CMD="$DIR/local/render-clock.sh"
|
||||
STATE_DIR="$DIR/local/state"
|
||||
BACKGROUND_TIMESTAMP_FILE="$STATE_DIR/background-updated-at"
|
||||
THEME_RUNTIME_ENV_FILE="$STATE_DIR/theme-runtime.env"
|
||||
THEME_SYNC_CMD="$DIR/local/theme-sync.sh"
|
||||
THEME_MENU_SERVICE_CMD="$DIR/local/theme-menu-service.sh"
|
||||
THEME_MENU_LOG_FILE="$DIR/logs/theme-menu.log"
|
||||
|
||||
REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"}
|
||||
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
|
||||
@@ -17,6 +21,8 @@ DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
|
||||
BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
|
||||
CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
|
||||
PRE_SLEEP_GRACE_SECONDS=${PRE_SLEEP_GRACE_SECONDS:-10}
|
||||
MANUAL_WAKE_KEEP_AWAKE_SECONDS=${MANUAL_WAKE_KEEP_AWAKE_SECONDS:-60}
|
||||
MANUAL_WAKE_EARLY_TOLERANCE_SECONDS=${MANUAL_WAKE_EARLY_TOLERANCE_SECONDS:-5}
|
||||
STATUS_MASK_ENABLED=${STATUS_MASK_ENABLED:-true}
|
||||
STATUS_MASK_LEFT=${STATUS_MASK_LEFT:-700}
|
||||
STATUS_MASK_TOP=${STATUS_MASK_TOP:-0}
|
||||
@@ -32,6 +38,27 @@ LOW_BATTERY_THRESHOLD_PERCENT=${LOW_BATTERY_THRESHOLD_PERCENT:-10}
|
||||
num_refresh=0
|
||||
background_needs_redraw=true
|
||||
|
||||
start_theme_menu_service() {
|
||||
if [ "${THEME_MENU_ENABLED:-true}" != true ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ ! -x "$THEME_MENU_SERVICE_CMD" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$THEME_MENU_LOG_FILE")"
|
||||
pkill -f "$THEME_MENU_SERVICE_CMD" 2>/dev/null || true
|
||||
nohup "$THEME_MENU_SERVICE_CMD" >>"$THEME_MENU_LOG_FILE" 2>&1 </dev/null &
|
||||
}
|
||||
|
||||
load_theme_runtime_config() {
|
||||
if [ -f "$THEME_RUNTIME_ENV_FILE" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$THEME_RUNTIME_ENV_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
init() {
|
||||
if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then
|
||||
echo "Missing required configuration."
|
||||
@@ -51,6 +78,7 @@ init() {
|
||||
initctl stop webreader >/dev/null 2>&1
|
||||
echo powersave >/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
|
||||
lipc-set-prop com.lab126.powerd preventScreenSaver 1
|
||||
start_theme_menu_service
|
||||
}
|
||||
|
||||
stop_framework() {
|
||||
@@ -82,6 +110,8 @@ now_epoch() {
|
||||
}
|
||||
|
||||
background_refresh_due() {
|
||||
load_theme_runtime_config
|
||||
|
||||
if [ ! -f "$BACKGROUND_PNG" ] || [ ! -f "$BACKGROUND_TIMESTAMP_FILE" ]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -101,6 +131,16 @@ fetch_background() {
|
||||
echo "Refreshing background"
|
||||
"$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP"
|
||||
|
||||
if "$THEME_SYNC_CMD" >/dev/null; then
|
||||
load_theme_runtime_config
|
||||
elif [ ! -f "$THEME_RUNTIME_ENV_FILE" ]; then
|
||||
echo "Theme sync failed and no runtime theme config is available."
|
||||
return 1
|
||||
else
|
||||
echo "Theme sync failed, using cached runtime theme config."
|
||||
load_theme_runtime_config
|
||||
fi
|
||||
|
||||
"$FETCH_DASHBOARD_CMD" "$BACKGROUND_PNG"
|
||||
fetch_status=$?
|
||||
|
||||
@@ -228,6 +268,39 @@ rtc_sleep() {
|
||||
fi
|
||||
}
|
||||
|
||||
manual_wake_detected() {
|
||||
requested_duration=$1
|
||||
actual_duration=$2
|
||||
|
||||
if [ "$DEBUG" = true ] || [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$requested_duration" -le "$MANUAL_WAKE_EARLY_TOLERANCE_SECONDS" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
[ "$actual_duration" -lt $((requested_duration - MANUAL_WAKE_EARLY_TOLERANCE_SECONDS)) ]
|
||||
}
|
||||
|
||||
hold_after_manual_wake() {
|
||||
requested_duration=$1
|
||||
sleep_started_at=$2
|
||||
sleep_finished_at=$3
|
||||
actual_duration=$((sleep_finished_at - sleep_started_at))
|
||||
|
||||
if ! manual_wake_detected "$requested_duration" "$actual_duration"; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Manual wake detected after ${actual_duration}s, keeping awake for ${MANUAL_WAKE_KEEP_AWAKE_SECONDS}s"
|
||||
|
||||
# 短按电源键提前唤醒后,先把 dashboard 内容恢复回来,
|
||||
# 再给出一段明确的可交互窗口,避免 2~3 秒内再次休眠。
|
||||
refresh_dashboard || true
|
||||
sleep "$MANUAL_WAKE_KEEP_AWAKE_SECONDS"
|
||||
}
|
||||
|
||||
main_loop() {
|
||||
while true; do
|
||||
log_battery_stats
|
||||
@@ -258,7 +331,10 @@ main_loop() {
|
||||
|
||||
echo "Going to $action, next wakeup in ${next_wakeup_secs}s"
|
||||
|
||||
sleep_started_at=$(now_epoch)
|
||||
rtc_sleep "$actual_sleep_secs"
|
||||
sleep_finished_at=$(now_epoch)
|
||||
hold_after_manual_wake "$actual_sleep_secs" "$sleep_started_at" "$sleep_finished_at"
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ mv "$TMP_FILE" "$ENV_FILE"
|
||||
# 已运行的 dashboard 进程不会重新读取 env.sh,切换后先停掉它,
|
||||
# 然后立刻拉起新的 dashboard,避免用户还要再次手动启动。
|
||||
pkill -f "$DIR/dash.sh" 2>/dev/null || true
|
||||
pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true
|
||||
sleep 1
|
||||
"$DIR/start.sh"
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ mv "$TMP_FILE" "$ENV_FILE"
|
||||
# 已运行的 dashboard 进程不会重新读取 env.sh,切换后先停掉它,
|
||||
# 然后立刻拉起新的 dashboard,避免用户还要在短时间内再点一次菜单。
|
||||
pkill -f "$DIR/dash.sh" 2>/dev/null || true
|
||||
pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true
|
||||
sleep 1
|
||||
"$DIR/start.sh"
|
||||
|
||||
|
||||
@@ -11,14 +11,44 @@ to_decimal() {
|
||||
}'
|
||||
}
|
||||
|
||||
clock_values_from_offset() {
|
||||
epoch_seconds=$(date '+%s')
|
||||
offset_minutes=$1
|
||||
|
||||
# 这里用 Lua 直接按 Unix 时间戳加偏移换算时分,
|
||||
# 避免 Kindle 上的 BusyBox date 对 Asia/Shanghai 这类时区名支持不完整。
|
||||
lua - "$epoch_seconds" "$offset_minutes" <<'LUA'
|
||||
local epoch_seconds = assert(tonumber(arg[1]), "missing epoch seconds")
|
||||
local offset_minutes = assert(tonumber(arg[2]), "missing offset minutes")
|
||||
local seconds_per_day = 24 * 60 * 60
|
||||
local local_seconds = epoch_seconds + offset_minutes * 60
|
||||
local seconds_of_day = ((local_seconds % seconds_per_day) + seconds_per_day) % seconds_per_day
|
||||
local hour = math.floor(seconds_of_day / 3600)
|
||||
local minute = math.floor((seconds_of_day % 3600) / 60)
|
||||
|
||||
io.write(string.format("%02d %02d\n", hour, minute))
|
||||
LUA
|
||||
}
|
||||
|
||||
current_clock_values() {
|
||||
# 时钟渲染要和 dashboard 配置的时区保持一致。
|
||||
# 否则即使 next-wakeup 按 TIMEZONE 唤醒,指针仍会按系统默认时区取值。
|
||||
if [ -n "${TIMEZONE:-}" ]; then
|
||||
TZ="$TIMEZONE" date '+%H %M'
|
||||
else
|
||||
date '+%H %M'
|
||||
# 表盘显示优先走固定 UTC 偏移,避免依赖设备对时区字符串的支持。
|
||||
# 这样 next-wakeup 仍可继续使用 TIMEZONE=Asia/Shanghai 做 cron 计算,
|
||||
# 表盘则稳定显示北京时间。
|
||||
if [ -n "${CLOCK_TIME_OFFSET_MINUTES:-}" ]; then
|
||||
clock_values_from_offset "$CLOCK_TIME_OFFSET_MINUTES"
|
||||
return
|
||||
fi
|
||||
|
||||
# 如果没有单独配置偏移,再回退到传统的 TZ=date 方案,兼容历史配置。
|
||||
if [ -n "${TIMEZONE:-}" ]; then
|
||||
if timezone_clock=$(TZ="$TIMEZONE" date '+%H %M' 2>/dev/null); then
|
||||
printf '%s\n' "$timezone_clock"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# 最后才回退到系统默认时区,至少保证脚本仍能继续工作。
|
||||
date '+%H %M'
|
||||
}
|
||||
|
||||
if [ "$#" -ge 2 ]; then
|
||||
|
||||
@@ -4,10 +4,20 @@
|
||||
export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1}
|
||||
# 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。
|
||||
export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"}
|
||||
# 调度计算依赖 next-wakeup 这个 Rust 程序,它要求使用 IANA 时区名。
|
||||
# 这里必须保留 Asia/Shanghai,才能正确计算下一次唤醒时间。
|
||||
export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
|
||||
# Kindle 上的 BusyBox date 对 IANA 时区名支持不稳定,直接拿 TIMEZONE 画表盘会退回 UTC。
|
||||
# 北京时间全年固定为 UTC+8,没有夏令时,所以表盘单独走固定偏移,避免依赖系统时区解析。
|
||||
export CLOCK_TIME_OFFSET_MINUTES=${CLOCK_TIME_OFFSET_MINUTES:-480}
|
||||
export BACKGROUND_URL=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
|
||||
export THEMES_INDEX_URL=${THEMES_INDEX_URL:-"https://shell.biboer.cn:20001/themes.json"}
|
||||
export THEMES_INDEX_REFRESH_INTERVAL_MINUTES=${THEMES_INDEX_REFRESH_INTERVAL_MINUTES:-1440}
|
||||
export THEME_CONFIG_REFRESH_INTERVAL_MINUTES=${THEME_CONFIG_REFRESH_INTERVAL_MINUTES:-1440}
|
||||
export THEME_ID=${THEME_ID:-"default"}
|
||||
export ORIENTATION=${ORIENTATION:-"portrait"}
|
||||
export BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
|
||||
export CLOCK_REGION_X=${CLOCK_REGION_X:-262}
|
||||
export CLOCK_REGION_X=${CLOCK_REGION_X:-347}
|
||||
export CLOCK_REGION_Y=${CLOCK_REGION_Y:-55}
|
||||
export CLOCK_REGION_WIDTH=${CLOCK_REGION_WIDTH:-220}
|
||||
export CLOCK_REGION_HEIGHT=${CLOCK_REGION_HEIGHT:-220}
|
||||
@@ -33,6 +43,11 @@ export CLOCK_CENTER_RADIUS=${CLOCK_CENTER_RADIUS:-7}
|
||||
# 进入 rtc suspend 前预留的可中断窗口,方便在调试时及时停止进程。
|
||||
# 这段时间会从真正的休眠时长里扣掉,避免分钟刷新慢一拍。
|
||||
export PRE_SLEEP_GRACE_SECONDS=${PRE_SLEEP_GRACE_SECONDS:-10}
|
||||
# 手动短按电源键把 Kindle 提前唤醒后,额外保持前台显示的秒数。
|
||||
# 这样用户有足够时间看屏、切主题或继续交互,而不会立刻再次休眠。
|
||||
export MANUAL_WAKE_KEEP_AWAKE_SECONDS=${MANUAL_WAKE_KEEP_AWAKE_SECONDS:-60}
|
||||
# 如果实际休眠时长比计划值至少少这么多秒,就认为是被用户手动提前唤醒。
|
||||
export MANUAL_WAKE_EARLY_TOLERANCE_SECONDS=${MANUAL_WAKE_EARLY_TOLERANCE_SECONDS:-5}
|
||||
|
||||
# Voyage 顶部状态栏遮罩:用于压住系统偶尔重画出来的时间、Wi-Fi、电池图标。
|
||||
# 当前坐标只覆盖页面顶部空白带,不会擦到天气卡上边框。
|
||||
@@ -44,6 +59,12 @@ export STATUS_MASK_HEIGHT=${STATUS_MASK_HEIGHT:-24}
|
||||
export STATUS_MASK_PASSES=${STATUS_MASK_PASSES:-3}
|
||||
export STATUS_MASK_DELAY_SECONDS=${STATUS_MASK_DELAY_SECONDS:-1}
|
||||
|
||||
# 左右翻页键同时按下时,呼出主题菜单;
|
||||
# 菜单本身仍复用当前 dashboard 的运行方向,只切换 theme id。
|
||||
export THEME_MENU_ENABLED=${THEME_MENU_ENABLED:-true}
|
||||
export THEME_MENU_EVENT_DEVICE=${THEME_MENU_EVENT_DEVICE:-"/dev/input/event2"}
|
||||
export THEME_MENU_COMBO_WINDOW_SECONDS=${THEME_MENU_COMBO_WINDOW_SECONDS:-0.35}
|
||||
|
||||
# By default, partial screen updates are used to update the screen,
|
||||
# to prevent the screen from flashing. After a few partial updates,
|
||||
# the screen will start to look a bit distorted (due to e-ink ghosting).
|
||||
|
||||
@@ -3,6 +3,30 @@ set -eu
|
||||
|
||||
# 拉取低频背景图,调用方负责传入输出路径。
|
||||
output_path=${1:?"missing output path"}
|
||||
background_url=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
|
||||
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="$DIR/env.sh"
|
||||
THEME_RUNTIME_ENV_FILE="$DIR/state/theme-runtime.env"
|
||||
|
||||
"$(dirname "$0")/../xh" -d -q -o "$output_path" get "$background_url"
|
||||
# fetch-dashboard 既会被 dash.sh 调,也会被 switch-theme.sh 单独调。
|
||||
# 因此这里每次都重新读取一次运行时主题配置,确保拿到当前背景地址。
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$THEME_RUNTIME_ENV_FILE" ] && . "$THEME_RUNTIME_ENV_FILE"
|
||||
|
||||
background_path=${BACKGROUND_PATH:-""}
|
||||
background_url=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
|
||||
local_background_path=""
|
||||
|
||||
if [ -n "$background_path" ]; then
|
||||
local_background_path="$DIR/../$background_path"
|
||||
fi
|
||||
|
||||
# 主题背景如果已经随本地部署同步到 Kindle,优先直接拷贝本地文件,
|
||||
# 这样切换主题时不依赖远端图片资源是否已经发布完成。
|
||||
if [ -n "$local_background_path" ] && [ -f "$local_background_path" ]; then
|
||||
cp "$local_background_path" "$output_path"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"$DIR/../xh" -d -q -o "$output_path" get "$background_url"
|
||||
|
||||
@@ -25,18 +25,25 @@ local WHITE = 255
|
||||
local BLACK = 0
|
||||
local cx = (width - 1) / 2
|
||||
local cy = (height - 1) / 2
|
||||
local face_radius = math.floor(math.min(width, height) * number_arg(6, 0.47))
|
||||
-- 偶数尺寸下如果半径直接顶到边界,右侧和下侧会更容易出现裁切感。
|
||||
-- 这里把描边厚度的一半让给外圈,保持四边可视留白更均匀。
|
||||
local face_stroke = number_arg(7, 3)
|
||||
local tick_outer_inset = number_arg(8, 6)
|
||||
local major_tick_length = number_arg(9, 14)
|
||||
local minor_tick_length = number_arg(10, 7)
|
||||
local major_tick_thickness = number_arg(11, 4)
|
||||
local minor_tick_thickness = number_arg(12, 2)
|
||||
local hour_length_ratio = number_arg(13, 0.48)
|
||||
local minute_length_ratio = number_arg(14, 0.72)
|
||||
local hour_thickness = number_arg(15, 9)
|
||||
local minute_thickness = number_arg(16, 5)
|
||||
local center_radius = number_arg(17, 7)
|
||||
local face_radius = math.max(1, math.min(width, height) * number_arg(6, 0.47) - face_stroke / 2)
|
||||
local major_tick_outer_inset = number_arg(8, 6)
|
||||
local minor_tick_outer_inset = number_arg(9, major_tick_outer_inset)
|
||||
local major_tick_length = number_arg(10, 14)
|
||||
local minor_tick_length = number_arg(11, 7)
|
||||
local major_tick_thickness = number_arg(12, 4)
|
||||
local minor_tick_thickness = number_arg(13, 2)
|
||||
local hour_length_ratio = number_arg(14, 0.48)
|
||||
local hour_back_length_ratio = number_arg(15, 0)
|
||||
local minute_length_ratio = number_arg(16, 0.72)
|
||||
local minute_back_length_ratio = number_arg(17, 0)
|
||||
local hour_thickness = number_arg(18, 9)
|
||||
local minute_thickness = number_arg(19, 5)
|
||||
local center_radius = number_arg(20, 7)
|
||||
local rotation_degrees = number_arg(21, 0)
|
||||
local rotation_radians = math.rad(rotation_degrees)
|
||||
|
||||
local pixels = {}
|
||||
for index = 1, width * height do
|
||||
@@ -73,25 +80,6 @@ local function fill_disk(x, y, radius, value)
|
||||
end
|
||||
end
|
||||
|
||||
local function draw_segment(x1, y1, x2, y2, thickness, value)
|
||||
local dx = x2 - x1
|
||||
local dy = y2 - y1
|
||||
local steps = math.max(math.abs(dx), math.abs(dy))
|
||||
|
||||
if steps == 0 then
|
||||
fill_disk(x1, y1, math.max(1, thickness / 2), value)
|
||||
return
|
||||
end
|
||||
|
||||
local radius = math.max(1, thickness / 2)
|
||||
for step = 0, steps do
|
||||
local t = step / steps
|
||||
local x = x1 + dx * t
|
||||
local y = y1 + dy * t
|
||||
fill_disk(x, y, radius, value)
|
||||
end
|
||||
end
|
||||
|
||||
local function draw_circle(radius, thickness, value)
|
||||
local samples = 720
|
||||
for step = 0, samples - 1 do
|
||||
@@ -102,34 +90,80 @@ local function draw_circle(radius, thickness, value)
|
||||
end
|
||||
end
|
||||
|
||||
local function draw_ticks()
|
||||
for tick = 0, 59 do
|
||||
local is_major = tick % 5 == 0
|
||||
local angle = (tick / 60) * math.pi * 2 - math.pi / 2
|
||||
local outer = face_radius - tick_outer_inset
|
||||
local inner = outer - (is_major and major_tick_length or minor_tick_length)
|
||||
local x1 = cx + math.cos(angle) * inner
|
||||
local y1 = cy + math.sin(angle) * inner
|
||||
local x2 = cx + math.cos(angle) * outer
|
||||
local y2 = cy + math.sin(angle) * outer
|
||||
draw_segment(x1, y1, x2, y2, is_major and major_tick_thickness or minor_tick_thickness, BLACK)
|
||||
local function fill_bar(origin_x, origin_y, angle, start_length, end_length, thickness, value)
|
||||
local start_pos = math.min(start_length, end_length)
|
||||
local end_pos = math.max(start_length, end_length)
|
||||
local ux = math.cos(angle)
|
||||
local uy = math.sin(angle)
|
||||
local vx = -uy
|
||||
local vy = ux
|
||||
local half_thickness = thickness / 2
|
||||
local corners = {
|
||||
{
|
||||
x = origin_x + ux * start_pos + vx * half_thickness,
|
||||
y = origin_y + uy * start_pos + vy * half_thickness,
|
||||
},
|
||||
{
|
||||
x = origin_x + ux * start_pos - vx * half_thickness,
|
||||
y = origin_y + uy * start_pos - vy * half_thickness,
|
||||
},
|
||||
{
|
||||
x = origin_x + ux * end_pos + vx * half_thickness,
|
||||
y = origin_y + uy * end_pos + vy * half_thickness,
|
||||
},
|
||||
{
|
||||
x = origin_x + ux * end_pos - vx * half_thickness,
|
||||
y = origin_y + uy * end_pos - vy * half_thickness,
|
||||
},
|
||||
}
|
||||
|
||||
local min_x = math.floor(math.min(corners[1].x, corners[2].x, corners[3].x, corners[4].x))
|
||||
local max_x = math.ceil(math.max(corners[1].x, corners[2].x, corners[3].x, corners[4].x))
|
||||
local min_y = math.floor(math.min(corners[1].y, corners[2].y, corners[3].y, corners[4].y))
|
||||
local max_y = math.ceil(math.max(corners[1].y, corners[2].y, corners[3].y, corners[4].y))
|
||||
|
||||
for py = min_y, max_y do
|
||||
for px = min_x, max_x do
|
||||
local sample_x = px + 0.5
|
||||
local sample_y = py + 0.5
|
||||
local dx = sample_x - origin_x
|
||||
local dy = sample_y - origin_y
|
||||
local longitudinal = dx * ux + dy * uy
|
||||
local lateral = dx * vx + dy * vy
|
||||
|
||||
if longitudinal >= start_pos and longitudinal <= end_pos and math.abs(lateral) <= half_thickness then
|
||||
set_pixel(px, py, value)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function hand_endpoint(angle_deg, length)
|
||||
local angle = math.rad(angle_deg - 90)
|
||||
return cx + math.cos(angle) * length, cy + math.sin(angle) * length
|
||||
local function draw_ticks()
|
||||
for tick = 0, 59 do
|
||||
local is_major = tick % 5 == 0
|
||||
local angle = (tick / 60) * math.pi * 2 - math.pi / 2 + rotation_radians
|
||||
local outer = face_radius - (is_major and major_tick_outer_inset or minor_tick_outer_inset)
|
||||
local inner = outer - (is_major and major_tick_length or minor_tick_length)
|
||||
fill_bar(cx, cy, angle, inner, outer, is_major and major_tick_thickness or minor_tick_thickness, BLACK)
|
||||
end
|
||||
end
|
||||
|
||||
local function draw_hands()
|
||||
local hour_angle = (hour_value % 12) * 30 + minute_value * 0.5
|
||||
local minute_angle = minute_value * 6
|
||||
local hour_radians = math.rad(hour_angle + rotation_degrees - 90)
|
||||
local minute_radians = math.rad(minute_angle + rotation_degrees - 90)
|
||||
|
||||
local hour_x, hour_y = hand_endpoint(hour_angle, face_radius * hour_length_ratio)
|
||||
local minute_x, minute_y = hand_endpoint(minute_angle, face_radius * minute_length_ratio)
|
||||
|
||||
draw_segment(cx, cy, hour_x, hour_y, hour_thickness, BLACK)
|
||||
draw_segment(cx, cy, minute_x, minute_y, minute_thickness, BLACK)
|
||||
fill_bar(cx, cy, hour_radians, -face_radius * hour_back_length_ratio, face_radius * hour_length_ratio, hour_thickness, BLACK)
|
||||
fill_bar(
|
||||
cx,
|
||||
cy,
|
||||
minute_radians,
|
||||
-face_radius * minute_back_length_ratio,
|
||||
face_radius * minute_length_ratio,
|
||||
minute_thickness,
|
||||
BLACK
|
||||
)
|
||||
fill_disk(cx, cy, center_radius, BLACK)
|
||||
end
|
||||
|
||||
|
||||
@@ -3,11 +3,16 @@ set -eu
|
||||
|
||||
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="$DIR/env.sh"
|
||||
THEME_RUNTIME_ENV_FILE="$DIR/state/theme-runtime.env"
|
||||
|
||||
# 单独执行本脚本时,也需要读取同一份坐标配置。
|
||||
# 否则会退回到脚本内默认值,导致手工调试与主循环绘制位置不一致。
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
|
||||
# 主题切换后,时钟区域坐标和绘制参数会落在运行时 env 里。
|
||||
# 这里额外覆盖一次,保证分钟级重绘与最近一次主题配置保持一致。
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$THEME_RUNTIME_ENV_FILE" ] && . "$THEME_RUNTIME_ENV_FILE"
|
||||
|
||||
clock_region_x=${CLOCK_REGION_X:-262}
|
||||
clock_region_y=${CLOCK_REGION_Y:-55}
|
||||
@@ -16,15 +21,20 @@ clock_region_height=${CLOCK_REGION_HEIGHT:-220}
|
||||
clock_face_radius_ratio=${CLOCK_FACE_RADIUS_RATIO:-0.47}
|
||||
clock_face_stroke=${CLOCK_FACE_STROKE:-3}
|
||||
clock_tick_outer_inset=${CLOCK_TICK_OUTER_INSET:-6}
|
||||
clock_major_tick_outer_inset=${CLOCK_MAJOR_TICK_OUTER_INSET:-$clock_tick_outer_inset}
|
||||
clock_minor_tick_outer_inset=${CLOCK_MINOR_TICK_OUTER_INSET:-$clock_tick_outer_inset}
|
||||
clock_major_tick_length=${CLOCK_MAJOR_TICK_LENGTH:-14}
|
||||
clock_minor_tick_length=${CLOCK_MINOR_TICK_LENGTH:-7}
|
||||
clock_major_tick_thickness=${CLOCK_MAJOR_TICK_THICKNESS:-4}
|
||||
clock_minor_tick_thickness=${CLOCK_MINOR_TICK_THICKNESS:-2}
|
||||
clock_hour_length_ratio=${CLOCK_HOUR_LENGTH_RATIO:-0.48}
|
||||
clock_hour_back_length_ratio=${CLOCK_HOUR_BACK_LENGTH_RATIO:-0}
|
||||
clock_minute_length_ratio=${CLOCK_MINUTE_LENGTH_RATIO:-0.72}
|
||||
clock_minute_back_length_ratio=${CLOCK_MINUTE_BACK_LENGTH_RATIO:-0}
|
||||
clock_hour_thickness=${CLOCK_HOUR_THICKNESS:-9}
|
||||
clock_minute_thickness=${CLOCK_MINUTE_THICKNESS:-5}
|
||||
clock_center_radius=${CLOCK_CENTER_RADIUS:-7}
|
||||
clock_rotation_degrees=${CLOCK_ROTATION_DEGREES:-0}
|
||||
force_full_refresh=${1:-false}
|
||||
output_path="$DIR/state/clock-render.pgm"
|
||||
|
||||
@@ -40,16 +50,20 @@ lua "$DIR/render-clock.lua" \
|
||||
"$minute" \
|
||||
"$clock_face_radius_ratio" \
|
||||
"$clock_face_stroke" \
|
||||
"$clock_tick_outer_inset" \
|
||||
"$clock_major_tick_outer_inset" \
|
||||
"$clock_minor_tick_outer_inset" \
|
||||
"$clock_major_tick_length" \
|
||||
"$clock_minor_tick_length" \
|
||||
"$clock_major_tick_thickness" \
|
||||
"$clock_minor_tick_thickness" \
|
||||
"$clock_hour_length_ratio" \
|
||||
"$clock_hour_back_length_ratio" \
|
||||
"$clock_minute_length_ratio" \
|
||||
"$clock_minute_back_length_ratio" \
|
||||
"$clock_hour_thickness" \
|
||||
"$clock_minute_thickness" \
|
||||
"$clock_center_radius"
|
||||
"$clock_center_radius" \
|
||||
"$clock_rotation_degrees"
|
||||
|
||||
if [ "$force_full_refresh" = true ]; then
|
||||
# Kindle Voyage 当前这条链路里,fbink 默认会叠加 viewport 修正,
|
||||
|
||||
343
dash/src/local/theme-json.lua
Normal file
343
dash/src/local/theme-json.lua
Normal file
@@ -0,0 +1,343 @@
|
||||
-- 读取 calendar 产出的主题 JSON,并把 Kindle 侧真正需要的字段提取出来。
|
||||
-- 这里实现一个精简 JSON 解析器,避免在设备上额外依赖 jq / python。
|
||||
|
||||
local command = assert(arg[1], "missing command")
|
||||
|
||||
local function read_file(path)
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local content = file:read("*a")
|
||||
file:close()
|
||||
return content
|
||||
end
|
||||
|
||||
local function decode(json)
|
||||
local position = 1
|
||||
local length = #json
|
||||
|
||||
local function skip_whitespace()
|
||||
while position <= length do
|
||||
local ch = json:sub(position, position)
|
||||
if ch ~= " " and ch ~= "\n" and ch ~= "\r" and ch ~= "\t" then
|
||||
break
|
||||
end
|
||||
position = position + 1
|
||||
end
|
||||
end
|
||||
|
||||
local parse_value
|
||||
|
||||
local function parse_string()
|
||||
position = position + 1
|
||||
local parts = {}
|
||||
|
||||
while position <= length do
|
||||
local ch = json:sub(position, position)
|
||||
|
||||
if ch == '"' then
|
||||
position = position + 1
|
||||
return table.concat(parts)
|
||||
end
|
||||
|
||||
if ch == "\\" then
|
||||
local escape = json:sub(position + 1, position + 1)
|
||||
local replacements = {
|
||||
['"'] = '"',
|
||||
["\\"] = "\\",
|
||||
["/"] = "/",
|
||||
["b"] = "\b",
|
||||
["f"] = "\f",
|
||||
["n"] = "\n",
|
||||
["r"] = "\r",
|
||||
["t"] = "\t",
|
||||
}
|
||||
|
||||
if escape == "u" then
|
||||
error("unsupported unicode escape")
|
||||
end
|
||||
|
||||
local replacement = replacements[escape]
|
||||
if not replacement then
|
||||
error("invalid string escape")
|
||||
end
|
||||
|
||||
parts[#parts + 1] = replacement
|
||||
position = position + 2
|
||||
else
|
||||
parts[#parts + 1] = ch
|
||||
position = position + 1
|
||||
end
|
||||
end
|
||||
|
||||
error("unterminated string")
|
||||
end
|
||||
|
||||
local function parse_number()
|
||||
local start_pos = position
|
||||
|
||||
while position <= length do
|
||||
local ch = json:sub(position, position)
|
||||
if not ch:match("[%d%+%-%.eE]") then
|
||||
break
|
||||
end
|
||||
position = position + 1
|
||||
end
|
||||
|
||||
local value = tonumber(json:sub(start_pos, position - 1))
|
||||
if value == nil then
|
||||
error("invalid number")
|
||||
end
|
||||
|
||||
return value
|
||||
end
|
||||
|
||||
local function parse_array()
|
||||
position = position + 1
|
||||
skip_whitespace()
|
||||
|
||||
local result = {}
|
||||
if json:sub(position, position) == "]" then
|
||||
position = position + 1
|
||||
return result
|
||||
end
|
||||
|
||||
while true do
|
||||
result[#result + 1] = parse_value()
|
||||
skip_whitespace()
|
||||
|
||||
local ch = json:sub(position, position)
|
||||
if ch == "]" then
|
||||
position = position + 1
|
||||
return result
|
||||
end
|
||||
|
||||
if ch ~= "," then
|
||||
error("invalid array separator")
|
||||
end
|
||||
|
||||
position = position + 1
|
||||
skip_whitespace()
|
||||
end
|
||||
end
|
||||
|
||||
local function parse_object()
|
||||
position = position + 1
|
||||
skip_whitespace()
|
||||
|
||||
local result = {}
|
||||
if json:sub(position, position) == "}" then
|
||||
position = position + 1
|
||||
return result
|
||||
end
|
||||
|
||||
while true do
|
||||
if json:sub(position, position) ~= '"' then
|
||||
error("object key must be string")
|
||||
end
|
||||
|
||||
local key = parse_string()
|
||||
skip_whitespace()
|
||||
|
||||
if json:sub(position, position) ~= ":" then
|
||||
error("missing object colon")
|
||||
end
|
||||
|
||||
position = position + 1
|
||||
result[key] = parse_value()
|
||||
skip_whitespace()
|
||||
|
||||
local ch = json:sub(position, position)
|
||||
if ch == "}" then
|
||||
position = position + 1
|
||||
return result
|
||||
end
|
||||
|
||||
if ch ~= "," then
|
||||
error("invalid object separator")
|
||||
end
|
||||
|
||||
position = position + 1
|
||||
skip_whitespace()
|
||||
end
|
||||
end
|
||||
|
||||
function parse_value()
|
||||
skip_whitespace()
|
||||
|
||||
local ch = json:sub(position, position)
|
||||
if ch == "{" then
|
||||
return parse_object()
|
||||
end
|
||||
|
||||
if ch == "[" then
|
||||
return parse_array()
|
||||
end
|
||||
|
||||
if ch == '"' then
|
||||
return parse_string()
|
||||
end
|
||||
|
||||
if ch == "-" or ch:match("%d") then
|
||||
return parse_number()
|
||||
end
|
||||
|
||||
if json:sub(position, position + 3) == "true" then
|
||||
position = position + 4
|
||||
return true
|
||||
end
|
||||
|
||||
if json:sub(position, position + 4) == "false" then
|
||||
position = position + 5
|
||||
return false
|
||||
end
|
||||
|
||||
if json:sub(position, position + 3) == "null" then
|
||||
position = position + 4
|
||||
return nil
|
||||
end
|
||||
|
||||
error("unexpected token")
|
||||
end
|
||||
|
||||
local result = parse_value()
|
||||
skip_whitespace()
|
||||
|
||||
if position <= length then
|
||||
error("unexpected trailing content")
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
local function find_theme(index_data, theme_id)
|
||||
for _, theme in ipairs(index_data.themes or {}) do
|
||||
if theme.id == theme_id then
|
||||
return theme
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function orientation_exists(theme, orientation)
|
||||
for _, value in ipairs(theme.orientations or {}) do
|
||||
if value == orientation then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function first_orientation(theme, fallback)
|
||||
if orientation_exists(theme, fallback) then
|
||||
return fallback
|
||||
end
|
||||
|
||||
return (theme.orientations or {})[1] or fallback
|
||||
end
|
||||
|
||||
local function shell_quote(value)
|
||||
return "'" .. tostring(value):gsub("'", [['"'"']]) .. "'"
|
||||
end
|
||||
|
||||
local function write_runtime_env(path, theme_id, orientation, variant)
|
||||
local file = assert(io.open(path, "wb"))
|
||||
|
||||
local lines = {
|
||||
"export THEME_ID=" .. shell_quote(theme_id),
|
||||
"export ORIENTATION=" .. shell_quote(orientation),
|
||||
"export THEME_DEVICE_PLACEMENT=" .. shell_quote(variant.devicePlacement or ""),
|
||||
"export BACKGROUND_PATH=" .. shell_quote((variant.background and variant.background.path) or ""),
|
||||
"export BACKGROUND_URL=" .. shell_quote(assert(variant.background.url, "missing background url")),
|
||||
"export BACKGROUND_REFRESH_INTERVAL_MINUTES=" .. tostring(variant.background.refreshIntervalMinutes or 120),
|
||||
"export CLOCK_REGION_X=" .. tostring(assert(variant.clock.x, "missing clock x")),
|
||||
"export CLOCK_REGION_Y=" .. tostring(assert(variant.clock.y, "missing clock y")),
|
||||
"export CLOCK_REGION_WIDTH=" .. tostring(assert(variant.clock.width, "missing clock width")),
|
||||
"export CLOCK_REGION_HEIGHT=" .. tostring(assert(variant.clock.height, "missing clock height")),
|
||||
"export CLOCK_FACE_RADIUS_RATIO=" .. tostring(variant.clock.faceRadiusRatio or 0.47),
|
||||
"export CLOCK_FACE_STROKE=" .. tostring(variant.clock.faceStroke or 3),
|
||||
"export CLOCK_TICK_OUTER_INSET=" .. tostring(variant.clock.tickOuterInset or 6),
|
||||
"export CLOCK_MAJOR_TICK_OUTER_INSET=" .. tostring(variant.clock.majorTickOuterInset or variant.clock.tickOuterInset or 6),
|
||||
"export CLOCK_MINOR_TICK_OUTER_INSET=" .. tostring(variant.clock.minorTickOuterInset or variant.clock.tickOuterInset or 6),
|
||||
"export CLOCK_MAJOR_TICK_LENGTH=" .. tostring(variant.clock.majorTickLength or 14),
|
||||
"export CLOCK_MINOR_TICK_LENGTH=" .. tostring(variant.clock.minorTickLength or 7),
|
||||
"export CLOCK_MAJOR_TICK_THICKNESS=" .. tostring(variant.clock.majorTickThickness or 4),
|
||||
"export CLOCK_MINOR_TICK_THICKNESS=" .. tostring(variant.clock.minorTickThickness or 2),
|
||||
"export CLOCK_HOUR_LENGTH_RATIO=" .. tostring(variant.clock.hourLengthRatio or 0.48),
|
||||
"export CLOCK_HOUR_BACK_LENGTH_RATIO=" .. tostring(variant.clock.hourBackLengthRatio or 0),
|
||||
"export CLOCK_MINUTE_LENGTH_RATIO=" .. tostring(variant.clock.minuteLengthRatio or 0.72),
|
||||
"export CLOCK_MINUTE_BACK_LENGTH_RATIO=" .. tostring(variant.clock.minuteBackLengthRatio or 0),
|
||||
"export CLOCK_HOUR_THICKNESS=" .. tostring(variant.clock.hourThickness or 9),
|
||||
"export CLOCK_MINUTE_THICKNESS=" .. tostring(variant.clock.minuteThickness or 5),
|
||||
"export CLOCK_CENTER_RADIUS=" .. tostring(variant.clock.centerRadius or 7),
|
||||
"export CLOCK_ROTATION_DEGREES=" .. tostring(variant.clock.rotationDegrees or 0),
|
||||
}
|
||||
|
||||
file:write(table.concat(lines, "\n"))
|
||||
file:write("\n")
|
||||
file:close()
|
||||
end
|
||||
|
||||
if command == "resolve" then
|
||||
local index_path = assert(arg[2], "missing themes index path")
|
||||
local requested_theme_id = arg[3] or ""
|
||||
local requested_orientation = arg[4] or ""
|
||||
local index_data = decode(read_file(index_path))
|
||||
|
||||
local theme = find_theme(index_data, requested_theme_id)
|
||||
if not theme then
|
||||
theme = find_theme(index_data, index_data.defaultThemeId) or assert((index_data.themes or {})[1], "themes index empty")
|
||||
end
|
||||
|
||||
local orientation = requested_orientation
|
||||
if orientation == "" then
|
||||
orientation = index_data.defaultOrientation or "portrait"
|
||||
end
|
||||
orientation = first_orientation(theme, orientation)
|
||||
|
||||
io.write("THEME_ID=", theme.id, "\n")
|
||||
io.write("ORIENTATION=", orientation, "\n")
|
||||
io.write("CONFIG_URL=", assert(theme.configUrl, "missing config url"), "\n")
|
||||
return
|
||||
end
|
||||
|
||||
if command == "write-runtime" then
|
||||
local theme_path = assert(arg[2], "missing theme config path")
|
||||
local theme_id = assert(arg[3], "missing theme id")
|
||||
local requested_orientation = assert(arg[4], "missing orientation")
|
||||
local output_path = assert(arg[5], "missing output path")
|
||||
local theme_data = decode(read_file(theme_path))
|
||||
local variants = assert(theme_data.variants, "missing variants")
|
||||
local variant = variants[requested_orientation] or variants.portrait or variants.landscape
|
||||
|
||||
if not variant then
|
||||
error("missing usable variant")
|
||||
end
|
||||
|
||||
local resolved_orientation = requested_orientation
|
||||
if variants[requested_orientation] == nil then
|
||||
if variants.portrait ~= nil then
|
||||
resolved_orientation = "portrait"
|
||||
elseif variants.landscape ~= nil then
|
||||
resolved_orientation = "landscape"
|
||||
end
|
||||
end
|
||||
|
||||
write_runtime_env(output_path, theme_id, resolved_orientation, variant)
|
||||
io.write("THEME_ID=", theme_id, "\n")
|
||||
io.write("ORIENTATION=", resolved_orientation, "\n")
|
||||
return
|
||||
end
|
||||
|
||||
if command == "list" then
|
||||
local index_path = assert(arg[2], "missing themes index path")
|
||||
local index_data = decode(read_file(index_path))
|
||||
|
||||
for _, theme in ipairs(index_data.themes or {}) do
|
||||
io.write(theme.id or "", "\t")
|
||||
io.write(theme.label or theme.id or "", "\t")
|
||||
io.write(table.concat(theme.orientations or {}, ","), "\n")
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
error("unknown command")
|
||||
271
dash/src/local/theme-menu-service.sh
Normal file
271
dash/src/local/theme-menu-service.sh
Normal file
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="$DIR/env.sh"
|
||||
THEME_FILE="$DIR/theme.env"
|
||||
THEME_RUNTIME_ENV_FILE="$DIR/state/theme-runtime.env"
|
||||
THEMES_INDEX_PATH="$DIR/../themes.json"
|
||||
THEME_JSON_LUA="$DIR/theme-json.lua"
|
||||
SWITCH_THEME_CMD="$DIR/../switch-theme.sh"
|
||||
STATE_DIR="$DIR/state"
|
||||
MENU_ITEMS_FILE="$STATE_DIR/theme-menu-items.tsv"
|
||||
ACTION_FIFO="$STATE_DIR/theme-menu-actions.fifo"
|
||||
EVENT_DEVICE_DEFAULT="/dev/input/event2"
|
||||
THEME_MENU_COMBO_WINDOW_SECONDS_DEFAULT="0.35"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$THEME_RUNTIME_ENV_FILE" ] && . "$THEME_RUNTIME_ENV_FILE"
|
||||
|
||||
EVENT_DEVICE=${THEME_MENU_EVENT_DEVICE:-$EVENT_DEVICE_DEFAULT}
|
||||
THEME_MENU_COMBO_WINDOW_SECONDS=${THEME_MENU_COMBO_WINDOW_SECONDS:-$THEME_MENU_COMBO_WINDOW_SECONDS_DEFAULT}
|
||||
|
||||
menu_open=false
|
||||
selected_index=1
|
||||
current_theme_id=${THEME_ID:-default}
|
||||
current_orientation=${ORIENTATION:-portrait}
|
||||
stream_pid=""
|
||||
|
||||
load_runtime() {
|
||||
# 每次打开菜单前都重新读取当前主题和方向,避免显示过期状态。
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$THEME_RUNTIME_ENV_FILE" ] && . "$THEME_RUNTIME_ENV_FILE"
|
||||
|
||||
current_theme_id=${THEME_ID:-default}
|
||||
current_orientation=${ORIENTATION:-portrait}
|
||||
}
|
||||
|
||||
load_menu_items() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
lua "$THEME_JSON_LUA" list "$THEMES_INDEX_PATH" >"$MENU_ITEMS_FILE"
|
||||
}
|
||||
|
||||
theme_count() {
|
||||
awk 'END { print NR + 0 }' "$MENU_ITEMS_FILE"
|
||||
}
|
||||
|
||||
theme_field() {
|
||||
row=$1
|
||||
column=$2
|
||||
awk -F '\t' -v target_row="$row" -v target_column="$column" '
|
||||
NR == target_row {
|
||||
print $target_column
|
||||
exit
|
||||
}
|
||||
' "$MENU_ITEMS_FILE"
|
||||
}
|
||||
|
||||
current_theme_index() {
|
||||
awk -F '\t' -v current_theme="$current_theme_id" '
|
||||
$1 == current_theme {
|
||||
print NR
|
||||
found = 1
|
||||
exit
|
||||
}
|
||||
END {
|
||||
if (!found) {
|
||||
print 1
|
||||
}
|
||||
}
|
||||
' "$MENU_ITEMS_FILE"
|
||||
}
|
||||
|
||||
print_line() {
|
||||
col=$1
|
||||
row=$2
|
||||
text=$3
|
||||
/usr/sbin/eips "$col" "$row" "$text"
|
||||
}
|
||||
|
||||
render_menu() {
|
||||
total_themes=$(theme_count)
|
||||
current_label=$(theme_field "$selected_index" 2)
|
||||
|
||||
/usr/sbin/eips -c
|
||||
print_line 3 1 "Kindle Dashboard"
|
||||
print_line 3 3 "Theme Menu"
|
||||
print_line 3 5 "Orientation: $current_orientation"
|
||||
print_line 3 6 "Selected: $current_label"
|
||||
print_line 3 7 "--------------------------------"
|
||||
|
||||
row=9
|
||||
index=1
|
||||
while [ "$index" -le "$total_themes" ]; do
|
||||
theme_label=$(theme_field "$index" 2)
|
||||
theme_id=$(theme_field "$index" 1)
|
||||
prefix=" "
|
||||
|
||||
if [ "$index" -eq "$selected_index" ]; then
|
||||
prefix="> "
|
||||
fi
|
||||
|
||||
print_line 3 "$row" "${prefix}${theme_label} (${theme_id})"
|
||||
row=$((row + 2))
|
||||
index=$((index + 1))
|
||||
done
|
||||
|
||||
print_line 3 18 "PageUp/PageDown: move"
|
||||
print_line 3 20 "Press both keys: apply"
|
||||
}
|
||||
|
||||
wrap_index() {
|
||||
next_index=$1
|
||||
total_themes=$(theme_count)
|
||||
|
||||
if [ "$next_index" -lt 1 ]; then
|
||||
printf '%s\n' "$total_themes"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$next_index" -gt "$total_themes" ]; then
|
||||
printf '1\n'
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s\n' "$next_index"
|
||||
}
|
||||
|
||||
move_selection() {
|
||||
delta=$1
|
||||
selected_index=$(wrap_index $((selected_index + delta)))
|
||||
render_menu
|
||||
}
|
||||
|
||||
apply_selection() {
|
||||
selected_theme_id=$(theme_field "$selected_index" 1)
|
||||
|
||||
/usr/sbin/eips -c
|
||||
print_line 3 5 "Applying theme..."
|
||||
print_line 3 7 "$selected_theme_id / $current_orientation"
|
||||
|
||||
"$SWITCH_THEME_CMD" "$selected_theme_id" "$current_orientation"
|
||||
menu_open=false
|
||||
}
|
||||
|
||||
open_menu() {
|
||||
load_runtime
|
||||
load_menu_items
|
||||
selected_index=$(current_theme_index)
|
||||
menu_open=true
|
||||
render_menu
|
||||
}
|
||||
|
||||
handle_action() {
|
||||
action=$1
|
||||
|
||||
case "$action" in
|
||||
combo)
|
||||
if [ "$menu_open" = true ]; then
|
||||
apply_selection
|
||||
else
|
||||
open_menu
|
||||
fi
|
||||
;;
|
||||
pageup)
|
||||
if [ "$menu_open" = true ]; then
|
||||
move_selection -1
|
||||
fi
|
||||
;;
|
||||
pagedown)
|
||||
if [ "$menu_open" = true ]; then
|
||||
move_selection 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
action_stream() {
|
||||
evtest --grab "$EVENT_DEVICE" 2>/dev/null | awk -v combo_window="$THEME_MENU_COMBO_WINDOW_SECONDS" '
|
||||
function extract_time(line, match_count, time_value) {
|
||||
match_count = match(line, /time [0-9]+\.[0-9]+/)
|
||||
if (match_count == 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
time_value = substr(line, RSTART + 5, RLENGTH - 5)
|
||||
return time_value + 0
|
||||
}
|
||||
|
||||
function emit(name) {
|
||||
print name
|
||||
fflush()
|
||||
}
|
||||
|
||||
{
|
||||
current_time = extract_time($0)
|
||||
|
||||
if ($0 ~ /code 104 \(PageUp\), value 1/) {
|
||||
if (pending_key == "down" && current_time - pending_time <= combo_window) {
|
||||
pending_key = ""
|
||||
emit("combo")
|
||||
next
|
||||
}
|
||||
|
||||
pending_key = "up"
|
||||
pending_time = current_time
|
||||
next
|
||||
}
|
||||
|
||||
if ($0 ~ /code 109 \(PageDown\), value 1/) {
|
||||
if (pending_key == "up" && current_time - pending_time <= combo_window) {
|
||||
pending_key = ""
|
||||
emit("combo")
|
||||
next
|
||||
}
|
||||
|
||||
pending_key = "down"
|
||||
pending_time = current_time
|
||||
next
|
||||
}
|
||||
|
||||
if ($0 ~ /code 104 \(PageUp\), value 0/) {
|
||||
if (pending_key == "up") {
|
||||
pending_key = ""
|
||||
emit("pageup")
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
if ($0 ~ /code 109 \(PageDown\), value 0/) {
|
||||
if (pending_key == "down") {
|
||||
pending_key = ""
|
||||
emit("pagedown")
|
||||
}
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$stream_pid" ]; then
|
||||
kill "$stream_pid" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -f "$ACTION_FIFO"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
while true; do
|
||||
rm -f "$ACTION_FIFO"
|
||||
mkfifo "$ACTION_FIFO"
|
||||
|
||||
action_stream >"$ACTION_FIFO" &
|
||||
stream_pid=$!
|
||||
|
||||
while IFS= read -r action; do
|
||||
handle_action "$action"
|
||||
done <"$ACTION_FIFO"
|
||||
|
||||
wait "$stream_pid" 2>/dev/null || true
|
||||
stream_pid=""
|
||||
rm -f "$ACTION_FIFO"
|
||||
sleep 1
|
||||
done
|
||||
234
dash/src/local/theme-sync.sh
Normal file
234
dash/src/local/theme-sync.sh
Normal file
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="$DIR/env.sh"
|
||||
THEME_FILE="$DIR/theme.env"
|
||||
STATE_DIR="$DIR/state"
|
||||
LOCAL_THEMES_INDEX="$DIR/../themes.json"
|
||||
LOCAL_THEMES_DIR="$DIR/../themes"
|
||||
THEMES_INDEX_CACHE="$STATE_DIR/themes.json"
|
||||
THEMES_INDEX_TIMESTAMP_FILE="$STATE_DIR/themes-updated-at"
|
||||
CURRENT_THEME_CACHE="$STATE_DIR/current-theme.json"
|
||||
CURRENT_THEME_ID_FILE="$STATE_DIR/current-theme-id"
|
||||
CURRENT_THEME_TIMESTAMP_FILE="$STATE_DIR/current-theme-updated-at"
|
||||
THEME_RUNTIME_ENV_FILE="$STATE_DIR/theme-runtime.env"
|
||||
THEME_JSON_LUA="$DIR/theme-json.lua"
|
||||
FETCH_CMD="$DIR/../xh"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
force_index=false
|
||||
force_theme=false
|
||||
requested_theme_id=${THEME_ID:-default}
|
||||
requested_orientation=${ORIENTATION:-portrait}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--force-index)
|
||||
force_index=true
|
||||
;;
|
||||
--force-theme)
|
||||
force_theme=true
|
||||
;;
|
||||
--theme)
|
||||
shift
|
||||
requested_theme_id=${1:?"missing theme id"}
|
||||
;;
|
||||
--orientation)
|
||||
shift
|
||||
requested_orientation=${1:?"missing orientation"}
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
THEMES_INDEX_URL=${THEMES_INDEX_URL:-"https://shell.biboer.cn:20001/themes.json"}
|
||||
THEMES_INDEX_REFRESH_INTERVAL_MINUTES=${THEMES_INDEX_REFRESH_INTERVAL_MINUTES:-1440}
|
||||
THEME_CONFIG_REFRESH_INTERVAL_MINUTES=${THEME_CONFIG_REFRESH_INTERVAL_MINUTES:-1440}
|
||||
|
||||
now_epoch() {
|
||||
date '+%s'
|
||||
}
|
||||
|
||||
write_timestamp() {
|
||||
date '+%s' >"$1"
|
||||
}
|
||||
|
||||
refresh_due() {
|
||||
timestamp_file=$1
|
||||
interval_minutes=$2
|
||||
|
||||
if [ ! -f "$timestamp_file" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
current_epoch=$(now_epoch)
|
||||
last_epoch=$(cat "$timestamp_file" 2>/dev/null || echo 0)
|
||||
[ $((current_epoch - last_epoch)) -ge $((interval_minutes * 60)) ]
|
||||
}
|
||||
|
||||
fetch_to_path() {
|
||||
url=$1
|
||||
output_path=$2
|
||||
tmp_path="${output_path}.tmp.$$"
|
||||
|
||||
rm -f "$tmp_path"
|
||||
|
||||
if ! "$FETCH_CMD" -d -q -o "$tmp_path" get "$url"; then
|
||||
rm -f "$tmp_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
mv "$tmp_path" "$output_path"
|
||||
return 0
|
||||
}
|
||||
|
||||
parse_kv_file() {
|
||||
file_path=$1
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
THEME_ID)
|
||||
resolved_theme_id=$value
|
||||
;;
|
||||
ORIENTATION)
|
||||
resolved_orientation=$value
|
||||
;;
|
||||
CONFIG_URL)
|
||||
resolved_config_url=$value
|
||||
;;
|
||||
esac
|
||||
done <"$file_path"
|
||||
}
|
||||
|
||||
sync_themes_index() {
|
||||
if [ -f "$LOCAL_THEMES_INDEX" ]; then
|
||||
cp "$LOCAL_THEMES_INDEX" "$THEMES_INDEX_CACHE"
|
||||
write_timestamp "$THEMES_INDEX_TIMESTAMP_FILE"
|
||||
echo "Themes index refreshed from local bundle"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 主题清单是全局入口,平时按天同步一次即可。
|
||||
# 真正切换主题时会走 --force-index,确保马上拿到最新列表。
|
||||
if [ "$force_index" = true ] || [ ! -f "$THEMES_INDEX_CACHE" ] || refresh_due "$THEMES_INDEX_TIMESTAMP_FILE" "$THEMES_INDEX_REFRESH_INTERVAL_MINUTES"; then
|
||||
if fetch_to_path "$THEMES_INDEX_URL" "$THEMES_INDEX_CACHE"; then
|
||||
write_timestamp "$THEMES_INDEX_TIMESTAMP_FILE"
|
||||
echo "Themes index refreshed"
|
||||
elif [ ! -f "$THEMES_INDEX_CACHE" ]; then
|
||||
echo "Themes index fetch failed and no cache is available." >&2
|
||||
return 1
|
||||
else
|
||||
echo "Themes index fetch failed, using cached copy."
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_selected_theme() {
|
||||
# 这里统一做一次主题和方向的兜底解析:
|
||||
# 如果本地请求的 theme / orientation 已失效,就按 themes.json 的默认值回退。
|
||||
resolve_output_file="$STATE_DIR/theme-resolve.$$"
|
||||
lua "$THEME_JSON_LUA" resolve "$THEMES_INDEX_CACHE" "$requested_theme_id" "$requested_orientation" >"$resolve_output_file"
|
||||
resolved_theme_id=""
|
||||
resolved_orientation=""
|
||||
resolved_config_url=""
|
||||
parse_kv_file "$resolve_output_file"
|
||||
rm -f "$resolve_output_file"
|
||||
|
||||
if [ -z "$resolved_theme_id" ] || [ -z "$resolved_orientation" ] || [ -z "$resolved_config_url" ]; then
|
||||
echo "Unable to resolve theme selection." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
sync_theme_config() {
|
||||
local_theme_config="$LOCAL_THEMES_DIR/${resolved_theme_id}.json"
|
||||
|
||||
if [ -f "$local_theme_config" ]; then
|
||||
cp "$local_theme_config" "$CURRENT_THEME_CACHE"
|
||||
printf '%s\n' "$resolved_theme_id" >"$CURRENT_THEME_ID_FILE"
|
||||
write_timestamp "$CURRENT_THEME_TIMESTAMP_FILE"
|
||||
echo "Theme config refreshed from local bundle: $resolved_theme_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 主题配置按 theme 维度缓存;
|
||||
# orientation 只是同一个主题 JSON 里的 variant,切换方向不需要重新拉整份配置。
|
||||
needs_theme_fetch=$force_theme
|
||||
cached_theme_id=$(cat "$CURRENT_THEME_ID_FILE" 2>/dev/null || echo "")
|
||||
|
||||
if [ ! -f "$CURRENT_THEME_CACHE" ] || [ ! -f "$CURRENT_THEME_ID_FILE" ]; then
|
||||
needs_theme_fetch=true
|
||||
elif [ "$cached_theme_id" != "$resolved_theme_id" ]; then
|
||||
needs_theme_fetch=true
|
||||
elif refresh_due "$CURRENT_THEME_TIMESTAMP_FILE" "$THEME_CONFIG_REFRESH_INTERVAL_MINUTES"; then
|
||||
needs_theme_fetch=true
|
||||
fi
|
||||
|
||||
if [ "$needs_theme_fetch" = true ]; then
|
||||
if fetch_to_path "$resolved_config_url" "$CURRENT_THEME_CACHE"; then
|
||||
printf '%s\n' "$resolved_theme_id" >"$CURRENT_THEME_ID_FILE"
|
||||
write_timestamp "$CURRENT_THEME_TIMESTAMP_FILE"
|
||||
echo "Theme config refreshed: $resolved_theme_id"
|
||||
elif [ ! -f "$CURRENT_THEME_CACHE" ] || [ "$cached_theme_id" != "$resolved_theme_id" ]; then
|
||||
echo "Theme config fetch failed and no matching cache is available." >&2
|
||||
return 1
|
||||
else
|
||||
echo "Theme config fetch failed, using cached copy."
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
write_runtime_env() {
|
||||
# runtime env 是 Kindle 真正消费的运行时配置快照。
|
||||
# fetch-dashboard / render-clock / dash.sh 都只读这一份,避免各自重复解析 JSON。
|
||||
runtime_output_file="$STATE_DIR/theme-runtime-result.$$"
|
||||
lua "$THEME_JSON_LUA" write-runtime \
|
||||
"$CURRENT_THEME_CACHE" \
|
||||
"$resolved_theme_id" \
|
||||
"$resolved_orientation" \
|
||||
"$THEME_RUNTIME_ENV_FILE" >"$runtime_output_file"
|
||||
|
||||
resolved_theme_id=""
|
||||
resolved_orientation=""
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
THEME_ID)
|
||||
resolved_theme_id=$value
|
||||
;;
|
||||
ORIENTATION)
|
||||
resolved_orientation=$value
|
||||
;;
|
||||
esac
|
||||
done <"$runtime_output_file"
|
||||
rm -f "$runtime_output_file"
|
||||
|
||||
if [ -z "$resolved_theme_id" ] || [ -z "$resolved_orientation" ]; then
|
||||
echo "Unable to write runtime theme env." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf 'THEME_ID=%s\n' "$resolved_theme_id"
|
||||
printf 'ORIENTATION=%s\n' "$resolved_orientation"
|
||||
return 0
|
||||
}
|
||||
|
||||
sync_themes_index
|
||||
resolve_selected_theme
|
||||
sync_theme_config
|
||||
write_runtime_env
|
||||
@@ -4,12 +4,15 @@ DEBUG=${DEBUG:-false}
|
||||
|
||||
DIR="$(dirname "$0")"
|
||||
ENV_FILE="$DIR/local/env.sh"
|
||||
THEME_FILE="$DIR/local/theme.env"
|
||||
LOG_FILE="$DIR/logs/dash.log"
|
||||
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
|
||||
|
||||
if [ "$DEBUG" = true ]; then
|
||||
"$DIR/dash.sh"
|
||||
|
||||
@@ -32,6 +32,7 @@ wait_for_cvm() {
|
||||
|
||||
pkill -f dash.sh 2>/dev/null || true
|
||||
pkill -f start.sh 2>/dev/null || true
|
||||
pkill -f theme-menu-service.sh 2>/dev/null || true
|
||||
|
||||
lipc-set-prop com.lab126.powerd preventScreenSaver 0 2>/dev/null || true
|
||||
|
||||
|
||||
46
dash/src/switch-theme.sh
Normal file
46
dash/src/switch-theme.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="$DIR/local/env.sh"
|
||||
THEME_FILE="$DIR/local/theme.env"
|
||||
THEME_RUNTIME_ENV_FILE="$DIR/local/state/theme-runtime.env"
|
||||
BACKGROUND_TIMESTAMP_FILE="$DIR/local/state/background-updated-at"
|
||||
BACKGROUND_PNG="$DIR/kindlebg.png"
|
||||
WAIT_FOR_WIFI_CMD="$DIR/wait-for-wifi.sh"
|
||||
THEME_SYNC_CMD="$DIR/local/theme-sync.sh"
|
||||
FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh"
|
||||
CLOCK_RENDER_CMD="$DIR/local/render-clock.sh"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
|
||||
|
||||
requested_theme_id=${1:?"usage: switch-theme.sh <theme-id> [orientation]"}
|
||||
requested_orientation=${2:-${ORIENTATION:-portrait}}
|
||||
|
||||
mkdir -p "$DIR/local/state"
|
||||
|
||||
# 切换主题时必须立刻联网拉到最新配置和背景,
|
||||
# 否则用户会看到 theme.env 已更新,但屏幕内容仍停留在旧主题。
|
||||
echo "Switching theme to $requested_theme_id / $requested_orientation"
|
||||
"$WAIT_FOR_WIFI_CMD" "$WIFI_TEST_IP"
|
||||
"$THEME_SYNC_CMD" --force-index --force-theme --theme "$requested_theme_id" --orientation "$requested_orientation" >/dev/null
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
. "$THEME_RUNTIME_ENV_FILE"
|
||||
|
||||
"$FETCH_DASHBOARD_CMD" "$BACKGROUND_PNG"
|
||||
date '+%s' >"$BACKGROUND_TIMESTAMP_FILE"
|
||||
|
||||
# 只有在主题配置和背景都成功拉取后,才把当前选择持久化到 theme.env。
|
||||
cat >"$THEME_FILE" <<EOF
|
||||
export THEME_ID='${THEME_ID}'
|
||||
export ORIENTATION='${ORIENTATION}'
|
||||
EOF
|
||||
|
||||
/usr/sbin/eips -f -g "$BACKGROUND_PNG"
|
||||
"$CLOCK_RENDER_CMD" true
|
||||
|
||||
echo "Theme switched to ${THEME_ID} / ${ORIENTATION}"
|
||||
Reference in New Issue
Block a user