update at 2026-03-18 13:35:19

This commit is contained in:
douboer@gmail.com
2026-03-18 13:35:19 +08:00
parent 192eb1b8d1
commit f9d715157f
72 changed files with 4035 additions and 972 deletions

273
README.md Normal file
View File

@@ -0,0 +1,273 @@
# kindle-dash
一个面向 Kindle Voyage 的低功耗电子墨水仪表盘项目。
当前仓库把整条链路都放在一起:
- `calendar/`:在电脑上生成背景图、主题包和前端预览
- `dash/`:运行在 Kindle 上,负责拉图、渲染时钟、休眠唤醒、主题切换
- `scripts/`同步、抓图、SSH 恢复等运维脚本
- `bootstrap-new-kindle.sh`:新机预置与 SSH 打通后的自动化入口
- `snapshot.sh`:同步主题、切换主题、抓取 Kindle 实机画面的统一入口
## 当前状态
- `simple` / `default` 等主题已经接入当前运行链路
- Kindle 侧采用“低频背景 + 本机时钟重绘”的分层渲染方案
- 新机 bootstrap 方案已实现
- 新机 bootstrap 当前仍是“方案已实现,真机恢复出厂闭环未验证”
- `launch-from-kual.sh``setsid` 脱离方案已经落地到代码
- 主题入口当前只保留在 `KUAL`;运行态双翻页键菜单与右下角长按默认关闭
-`KUAL -> Dashboard``Dashboard -> 原生 UI/KUAL` 的边界切换在 `Kindle Voyage 5.13.6` 上仍不稳定
## 项目目标
这个仓库不是单纯做一个网页,而是把下面几件事收成一套:
1. 在电脑上生成适合 Kindle 分辨率的背景图和主题资源
2. 把这些资源同步到已越狱的 Kindle
3. 让 Kindle 在低功耗模式下周期性刷新背景
4. 在设备本机按分钟重绘时钟,不依赖网络
5. 提供主题切换、实机截图、SSH 恢复和新机 bootstrap 工具
## 架构概览
### 1. 前端层 `calendar/`
作用:
- 使用 Vue 生成 Kindle 仪表盘布局
- 导出 `kindlebg.png`
- 导出多主题背景包和 `themes.json`
- 预览不同主题与方向
关键文件:
- [App.vue](/Users/gavin/kindle-dash/calendar/src/App.vue)
- [SimpleDashboard.vue](/Users/gavin/kindle-dash/calendar/src/components/SimpleDashboard.vue)
- [themes.json](/Users/gavin/kindle-dash/calendar/config/themes.json)
### 2. 设备运行层 `dash/`
作用:
- 在 Kindle 上拉取或读取背景图
- 根据调度决定刷新还是休眠
- 在本机用 Lua + FBInk 渲染时钟
- 切换主题、维护运行时主题状态
- 在需要时拉起主题菜单服务
关键文件:
- [dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh)
- [switch-theme.sh](/Users/gavin/kindle-dash/dash/src/switch-theme.sh)
- [render-clock.lua](/Users/gavin/kindle-dash/dash/src/local/render-clock.lua)
- [theme-sync.sh](/Users/gavin/kindle-dash/dash/src/local/theme-sync.sh)
### 3. 运维层 `scripts/`
作用:
- 把当前 runtime 和主题包同步到 Kindle
- 抓取 Kindle 实机屏幕
- 恢复 Kindle 侧 SSH
- 协助 USBNetwork 调试
关键入口:
- [sync-layered-clock-to-kindle.sh](/Users/gavin/kindle-dash/scripts/sync-layered-clock-to-kindle.sh)
- [capture-kindle-screen.sh](/Users/gavin/kindle-dash/scripts/capture-kindle-screen.sh)
- [ssh-force-dropbear-22.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-force-dropbear-22.sh)
## 目录结构
```text
kindle-dash/
├── assets/ 前端静态资源
├── bootstrap-new-kindle.sh 新机预置/后半段自动化入口
├── snapshot.sh 同步 + 切主题 + 抓图统一入口
├── calendar/ Vue 前端与背景导出
├── dash/ Kindle 侧 runtime、KUAL、文档与 staging
├── scripts/ 同步、抓图、SSH 与网络辅助脚本
└── tmp/ 本地实机截图和临时产物
```
## 部署模型
### 已越狱 Kindle
这条路径适合设备已经有 `KUAL``USBNetwork`、SSH
1. 在电脑上构建前端:
```sh
cd calendar
npm install
npm run typecheck
npm run build
```
2. 同步到 Kindle
```sh
cd /Users/gavin/kindle-dash
sh scripts/sync-layered-clock-to-kindle.sh kindle
```
3. 切主题并立即出图:
```sh
ssh kindle '/mnt/us/dashboard/switch-theme.sh simple portrait'
```
4. 如需启动 dashboard 主循环:
```sh
ssh kindle 'cd /mnt/us/dashboard && ./start.sh'
```
调试或排障时,当前更推荐:
```sh
ssh kindle 'cd /mnt/us/dashboard && DEBUG=true ./start.sh'
```
原因:
- 这条路径已实机验证可用
- 不依赖 `KUAL` 的 UI 切换链路
- 出问题时可以直接看前台日志
### 新机 / 恢复出厂后 Kindle
这条路径适合 `Kindle Voyage 5.13.6`
1. USB 挂载后执行预置:
```sh
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version latest
```
2. 在 Kindle 上完成:
- `WatchThis`
- `;log mrpi`
- `Rename OTA Binaries -> Rename`
- 接回 WiFi
- `KTerm` 中执行 `sh /mnt/us/ssh-force-dropbear-22.sh`
3. 回到 Mac执行后半段
```sh
sh bootstrap-new-kindle.sh post-ssh -t simple -o portrait
```
注意:这条新机 bootstrap 方案当前仍未做“真机恢复出厂闭环验证”。
## 常用命令
### 前端开发
```sh
cd calendar
npm install
npm run dev
npm run typecheck
npm run build
npm run export:themes
```
### 主题与截图
```sh
sh snapshot.sh --list
sh snapshot.sh -t simple
sh snapshot.sh -t simple -o portrait
sh snapshot.sh --capture-only -b current-screen
sh snapshot.sh --date
```
### 新机 bootstrap
```sh
sh bootstrap-new-kindle.sh -h
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version latest
sh bootstrap-new-kindle.sh post-ssh -t simple -o portrait
```
### SSH 恢复
如果 Kindle 上已经有 `KTerm`,但外部 SSH 不通:
```sh
sh /mnt/us/ssh-force-dropbear-22.sh
```
然后在 Mac 上:
```sh
ssh kindle
```
## 使用说明
### 日常使用
- Kindle 正常联网时,背景和主题配置可以更新
- 本机时钟按分钟重绘,不依赖网络
- 即使远端刷新失败,只要本地还有缓存背景,设备通常还能继续显示旧背景和本机时钟
### 换 WiFi 后
- 不影响 USB 预置
- 可能影响 `ssh kindle`
- 可能影响主题强制同步和远端背景刷新
- 如果新网络能正常出外网,日常显示通常不受影响
## 验证与验收
当前仓库里可直接运行的校验主要是:
```sh
cd calendar
npm run typecheck
npm run build
```
以及脚本级别语法检查,例如:
```sh
sh -n bootstrap-new-kindle.sh
sh -n snapshot.sh
```
说明:
- 当前仓库没有可用的 `lint` 脚本
- 当前仓库没有可用的 `test` 脚本
## 文档索引
推荐按下面顺序看:
1. 根仓库说明:
[README.md](/Users/gavin/kindle-dash/README.md)
2. Kindle runtime 英文原始说明:
[dash/README.md](/Users/gavin/kindle-dash/dash/README.md)
3. Voyage 5.13.6 越狱路径:
[kindle-voyage-5.13.6-watchthis-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-watchthis-zh.md)
4. 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)
5. Bootstrap 总说明:
[kindle-voyage-5.13.6-bootstrap-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-bootstrap-zh.md)
6. Bootstrap 闭环验证清单:
[kindle-voyage-5.13.6-bootstrap-validation-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-bootstrap-validation-zh.md)
7. 白屏/KUAL/SSH 交接:
[kindle-voyage-5.13.6-white-screen-handoff-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-white-screen-handoff-zh.md)
## 已知限制
1. `WatchThis` / demo / `;log mrpi` 仍然不能完全自动化
2. `bootstrap-new-kindle.sh` 当前还没有做一次真机恢复出厂闭环验收
3. `ssh kindle` 依赖当前网络和 IP换 WiFi 后可能要重新确认地址或 SSH 配置
4. `KUAL -> Dashboard``Dashboard -> 原生 UI/KUAL` 的边界切换在 `Kindle Voyage 5.13.6` 上仍不稳定
5. `stop.sh` 当前必须走保守恢复路径,实验性的“快速切换”方案已在 `2026-03-17` 撤回

View File

@@ -4,15 +4,20 @@ set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
WATCHTHIS_DIR="$ROOT_DIR/dash/staging/watchthis" WATCHTHIS_DIR="$ROOT_DIR/dash/staging/watchthis"
POST_JAILBREAK_ROOT="$ROOT_DIR/dash/staging/post-jailbreak-root" POST_JAILBREAK_ROOT="$ROOT_DIR/dash/staging/post-jailbreak-root"
KTERM_STAGING_DIR="$ROOT_DIR/dash/staging/kterm"
SSH_HELPERS_DIR="$ROOT_DIR/scripts/kindle" SSH_HELPERS_DIR="$ROOT_DIR/scripts/kindle"
SYNC_SCRIPT="$ROOT_DIR/scripts/sync-layered-clock-to-kindle.sh" SYNC_SCRIPT="$ROOT_DIR/scripts/sync-layered-clock-to-kindle.sh"
THEMES_JSON="$ROOT_DIR/calendar/config/themes.json" THEMES_JSON="$ROOT_DIR/calendar/config/themes.json"
KTERM_GITHUB_REPO="bfabiszewski/kterm"
MODE="all" MODE="all"
VOLUME_PATH="/Volumes/Kindle" VOLUME_PATH="/Volumes/Kindle"
HOST_TARGET="kindle" HOST_TARGET="kindle"
THEME_ID="" THEME_ID=""
ORIENTATION="" ORIENTATION=""
KTERM_PACKAGE=""
DOWNLOAD_KTERM=false
KTERM_VERSION="latest"
SHOW_BACKGROUND=true SHOW_BACKGROUND=true
START_DASHBOARD=false START_DASHBOARD=false
@@ -31,6 +36,9 @@ print_usage() {
-k, --kindle <host> Kindle SSH 主机名,默认 kindle -k, --kindle <host> Kindle SSH 主机名,默认 kindle
-t, --theme <theme-id> SSH 阶段切换到指定主题;默认使用 themes.json 的默认主题 -t, --theme <theme-id> SSH 阶段切换到指定主题;默认使用 themes.json 的默认主题
-o, --orientation <value> SSH 阶段切换到指定方向;默认使用 themes.json 的默认方向 -o, --orientation <value> SSH 阶段切换到指定方向;默认使用 themes.json 的默认方向
--kterm-package <path> 指定 KTerm 安装包;官方 release 用 .zip也兼容外部 .bin
--download-kterm 在 Mac 侧联网下载 KTerm 到 dash/staging/kterm/,再预置到 Kindle
--kterm-version <tag> 下载指定 KTerm 版本;默认 latest
--no-background SSH 阶段不同步后立即切主题出图 --no-background SSH 阶段不同步后立即切主题出图
--start-dashboard SSH 阶段额外后台启动 dashboard 主循环 --start-dashboard SSH 阶段额外后台启动 dashboard 主循环
-h, --help 查看帮助 -h, --help 查看帮助
@@ -44,6 +52,20 @@ print_usage() {
4. 搜索 ;log mrpi 安装 KUAL / MRPI / USBNetwork 4. 搜索 ;log mrpi 安装 KUAL / MRPI / USBNetwork
5. 首次进 KTerm 执行 sh /mnt/us/ssh-force-dropbear-22.sh 5. 首次进 KTerm 执行 sh /mnt/us/ssh-force-dropbear-22.sh
关于 KTerm
1. 当前仓库默认不自带 KTerm 安装包。
2. 官方 KTerm release 使用 .zip解压后是 kterm/ 扩展目录。
3. 如果你手头已经有 KTerm 安装包,可以:
- 放到 dash/staging/kterm/ 目录下,脚本会自动尝试拾取
- 或执行时显式传:--kterm-package /绝对路径/kterm-kindle-*.zip
4. 如果你希望脚本直接从网上拉,可以执行:
--download-kterm --kterm-version latest
或:
--download-kterm --kterm-version v2.6
5. 下载发生在 Mac 侧,文件会缓存到 dash/staging/kterm/,然后再解压或复制到 Kindle。
6. 对这台 Voyage 5.13.6,脚本默认优先选择“不带 armhf 后缀”的 zip。
7. 如果脚本没有检测到 KTerm 包,会明确提示“这一步仍需手工补装 KTerm”。
推荐操作顺序: 推荐操作顺序:
阶段 A先做 USB 预置 阶段 A先做 USB 预置
@@ -56,6 +78,8 @@ print_usage() {
- Kindle 根目录出现 ssh-force-dropbear-22.sh 等脚本 - Kindle 根目录出现 ssh-force-dropbear-22.sh 等脚本
- Kindle 根目录出现 dashboard/、extensions/、mrpackages/ - Kindle 根目录出现 dashboard/、extensions/、mrpackages/
- Kindle 根目录出现 .demo/KV-5.13.6.zip、.demo/demo.json、.demo/goodreads/ - Kindle 根目录出现 .demo/KV-5.13.6.zip、.demo/demo.json、.demo/goodreads/
- 如果提供了 KTerm zipextensions/ 里会被解压出 kterm/
- 如果提供了外部 KTerm binmrpackages/ 里会出现对应文件
5. 安全弹出 Kindle。 5. 安全弹出 Kindle。
阶段 B在 Kindle 上完成 WatchThis 和越狱 阶段 B在 Kindle 上完成 WatchThis 和越狱
@@ -77,6 +101,7 @@ print_usage() {
- 首页出现 KUAL - 首页出现 KUAL
- KUAL 菜单里有 Rename OTA Binaries - KUAL 菜单里有 Rename OTA Binaries
- KUAL 菜单里有 kindle-dash - KUAL 菜单里有 kindle-dash
- 如果本次预置了 KTerm 安装包,首页或搜索里应能找到 KTerm
4. 先在 KUAL 中执行: 4. 先在 KUAL 中执行:
Rename OTA Binaries -> Rename Rename OTA Binaries -> Rename
@@ -114,9 +139,12 @@ print_usage() {
2. 真正导 payload 的时机,是隐藏手势返回后,再次进入 ;demo -> Sideload Content。 2. 真正导 payload 的时机,是隐藏手势返回后,再次进入 ;demo -> Sideload Content。
3. 首次 SSH 最稳的入口是 KTerm不是 USB 直连盲试。 3. 首次 SSH 最稳的入口是 KTerm不是 USB 直连盲试。
4. post-ssh 阶段如果不带 -t/-o会退回 themes.json 里的默认主题和方向。 4. post-ssh 阶段如果不带 -t/-o会退回 themes.json 里的默认主题和方向。
5. 如果本轮没有预置 KTerm 包,阶段 C 结束后仍需你手工补装 KTerm。
示例: 示例:
sh bootstrap-new-kindle.sh prepare-storage sh bootstrap-new-kindle.sh prepare-storage
sh bootstrap-new-kindle.sh prepare-storage --kterm-package ~/Downloads/kterm-kindle-2.6.zip
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version latest
sh bootstrap-new-kindle.sh all -t simple -o portrait sh bootstrap-new-kindle.sh all -t simple -o portrait
sh bootstrap-new-kindle.sh post-ssh -k kindle --start-dashboard sh bootstrap-new-kindle.sh post-ssh -k kindle --start-dashboard
EOF EOF
@@ -135,6 +163,115 @@ require_path() {
fi fi
} }
detect_kterm_package() {
if [ -n "$KTERM_PACKAGE" ]; then
if [ ! -f "$KTERM_PACKAGE" ]; then
echo "指定的 KTerm 安装包不存在: $KTERM_PACKAGE" >&2
exit 1
fi
printf '%s\n' "$KTERM_PACKAGE"
return 0
fi
if [ ! -d "$KTERM_STAGING_DIR" ]; then
return 1
fi
set +e
set -- "$KTERM_STAGING_DIR"/*.zip "$KTERM_STAGING_DIR"/*.bin
status=$?
set -e
if [ "$status" -ne 0 ] || [ ! -e "$1" ]; then
return 1
fi
printf '%s\n' "$1"
}
download_kterm_if_requested() {
if [ "$DOWNLOAD_KTERM" != true ]; then
return
fi
mkdir -p "$KTERM_STAGING_DIR"
metadata_json="$KTERM_STAGING_DIR/kterm-release-${KTERM_VERSION}.json"
case "$KTERM_VERSION" in
latest)
release_api_url="https://api.github.com/repos/$KTERM_GITHUB_REPO/releases/latest"
;;
*)
release_api_url="https://api.github.com/repos/$KTERM_GITHUB_REPO/releases/tags/$KTERM_VERSION"
;;
esac
echo "正在从 GitHub 获取 KTerm release 元数据: $release_api_url"
curl -fsSL "$release_api_url" -o "$metadata_json"
release_info="$(python3 - "$metadata_json" <<'PY'
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
data = json.loads(path.read_text())
assets = data.get("assets", [])
preferred = None
fallback = None
for asset in assets:
name = asset.get("name", "")
url = asset.get("browser_download_url", "")
if not name.endswith(".zip"):
continue
if fallback is None:
fallback = (name, url)
if "armhf" not in name.lower():
preferred = (name, url)
break
selected = preferred or fallback
if not selected:
raise SystemExit("未找到可用的 KTerm zip 资产")
tag = data.get("tag_name", "unknown")
print(f"TAG={tag}")
print(f"NAME={selected[0]}")
print(f"URL={selected[1]}")
PY
)"
downloaded_tag=""
downloaded_name=""
downloaded_url=""
while IFS='=' read -r key value; do
case "$key" in
TAG)
downloaded_tag=$value
;;
NAME)
downloaded_name=$value
;;
URL)
downloaded_url=$value
;;
esac
done <<EOF
$release_info
EOF
if [ -z "$downloaded_name" ] || [ -z "$downloaded_url" ]; then
echo "无法解析 KTerm 下载地址。" >&2
exit 1
fi
downloaded_path="$KTERM_STAGING_DIR/$downloaded_name"
echo "下载 KTerm: tag=$downloaded_tag asset=$downloaded_name"
curl -fL "$downloaded_url" -o "$downloaded_path"
KTERM_PACKAGE="$downloaded_path"
}
kindle_volume_available() { kindle_volume_available() {
[ -d "$VOLUME_PATH" ] [ -d "$VOLUME_PATH" ]
} }
@@ -219,6 +356,31 @@ copy_post_jailbreak_bundle() {
rsync -av --no-o --no-g "$POST_JAILBREAK_ROOT/dashboard/" "$VOLUME_PATH/dashboard/" rsync -av --no-o --no-g "$POST_JAILBREAK_ROOT/dashboard/" "$VOLUME_PATH/dashboard/"
} }
install_kterm_package_if_available() {
detected_kterm_package=$(detect_kterm_package || true)
if [ -z "$detected_kterm_package" ]; then
echo "未检测到 KTerm 安装包;本轮只预置 SSH 恢复脚本KTerm 仍需手工补装。"
return
fi
case "$detected_kterm_package" in
*.zip)
mkdir -p "$VOLUME_PATH/extensions"
unzip -oq "$detected_kterm_package" -d "$VOLUME_PATH/extensions"
echo "已预置 KTerm 扩展包并解压到 extensions/: $(basename "$detected_kterm_package")"
;;
*.bin)
mkdir -p "$VOLUME_PATH/mrpackages"
cp "$detected_kterm_package" "$VOLUME_PATH/mrpackages/"
echo "已预置 KTerm bin 到 mrpackages/: $(basename "$detected_kterm_package")"
;;
*)
echo "不支持的 KTerm 安装包格式: $detected_kterm_package" >&2
exit 1
;;
esac
}
copy_ssh_helpers() { copy_ssh_helpers() {
require_path "$SSH_HELPERS_DIR/ssh-force-dropbear-22.sh" "SSH helper scripts" require_path "$SSH_HELPERS_DIR/ssh-force-dropbear-22.sh" "SSH helper scripts"
@@ -251,12 +413,18 @@ prepare_storage() {
exit 1 exit 1
fi fi
log_step "USB" "检查并下载 KTerm 安装包"
download_kterm_if_requested
log_step "USB" "预置 WatchThis payload" log_step "USB" "预置 WatchThis payload"
copy_watchthis_payload copy_watchthis_payload
log_step "USB" "预置越狱后安装包" log_step "USB" "预置越狱后安装包"
copy_post_jailbreak_bundle copy_post_jailbreak_bundle
log_step "USB" "检查并预置 KTerm 安装包"
install_kterm_package_if_available
log_step "USB" "预置 SSH 恢复脚本" log_step "USB" "预置 SSH 恢复脚本"
copy_ssh_helpers copy_ssh_helpers
@@ -265,7 +433,7 @@ prepare_storage() {
} }
prepare_remote_helpers() { prepare_remote_helpers() {
ssh "$HOST_TARGET" "chmod +x /mnt/us/ssh-collect.sh /mnt/us/ssh-fix-all-keys.sh /mnt/us/ssh-force-dropbear-22.sh /mnt/us/ssh-force-openssh-22.sh /mnt/us/ssh-stop-all.sh 2>/dev/null || true" ssh "$HOST_TARGET" "chmod +x /mnt/us/ssh-collect.sh /mnt/us/ssh-fix-all-keys.sh /mnt/us/ssh-force-dropbear-22.sh /mnt/us/ssh-stop-all.sh 2>/dev/null || true"
ssh "$HOST_TARGET" "if [ -f /mnt/us/ssh-fix-all-keys.sh ]; then sh /mnt/us/ssh-fix-all-keys.sh; fi" ssh "$HOST_TARGET" "if [ -f /mnt/us/ssh-fix-all-keys.sh ]; then sh /mnt/us/ssh-fix-all-keys.sh; fi"
} }
@@ -355,6 +523,17 @@ while [ "$#" -gt 0 ]; do
shift shift
ORIENTATION=${1:?"missing orientation"} ORIENTATION=${1:?"missing orientation"}
;; ;;
--kterm-package)
shift
KTERM_PACKAGE=${1:?"missing KTerm package path"}
;;
--download-kterm)
DOWNLOAD_KTERM=true
;;
--kterm-version)
shift
KTERM_VERSION=${1:?"missing KTerm version"}
;;
--no-background) --no-background)
SHOW_BACKGROUND=false SHOW_BACKGROUND=false
;; ;;

View File

@@ -1,6 +1,14 @@
{ {
"items": [ "items": [
{"name": "Kindle Dashboard", "action": "/mnt/us/dashboard/start.sh"}, {
"name": "Kindle Dashboard",
"priority": -998,
"items": [
{"name": "Default", "priority": 1, "action": "/mnt/us/dashboard/launch-theme-from-kual.sh", "params": "default", "exitmenu": true},
{"name": "Paper", "priority": 2, "action": "/mnt/us/dashboard/launch-theme-from-kual.sh", "params": "paper", "exitmenu": true},
{"name": "Classic", "priority": 3, "action": "/mnt/us/dashboard/launch-theme-from-kual.sh", "params": "classic", "exitmenu": true}
]
},
{"name": "Dashboard Debug On", "action": "/mnt/us/dashboard/debug-on.sh"}, {"name": "Dashboard Debug On", "action": "/mnt/us/dashboard/debug-on.sh"},
{"name": "Dashboard Debug Off", "action": "/mnt/us/dashboard/debug-off.sh"} {"name": "Dashboard Debug Off", "action": "/mnt/us/dashboard/debug-off.sh"}
] ]

View File

@@ -28,7 +28,7 @@ In my case I use a [dashbling](https://github.com/pascalw/dashbling) dashboard t
4. Start dashboard with `/mnt/us/dashboard/start.sh`. 4. Start dashboard with `/mnt/us/dashboard/start.sh`.
Note that the device will go into suspend about 10-15 seconds after you start the dashboard. Note that the device will go into suspend about 10-15 seconds after you start the dashboard.
5. To leave dashboard mode and get back to the normal Kindle UI/KUAL, run `/mnt/us/dashboard/stop.sh`. 5. To leave dashboard mode and get back to the normal Kindle UI/KUAL, run `/mnt/us/dashboard/stop.sh`.
This now stops `dash.sh` and restores the Kindle framework. On Voyage 5.13.6 this intentionally uses a conservative UI-stack restore instead of a fast handoff, because the faster paths were not stable.
## Upgrading ## Upgrading
@@ -41,12 +41,18 @@ If you're running kindle-dash already and want to update to the latest version f
5. Start dashboard with `/mnt/us/dashboard/start.sh`. 5. Start dashboard with `/mnt/us/dashboard/start.sh`.
Note that the device will go into suspend about 10-15 seconds after you start the dashboard. Note that the device will go into suspend about 10-15 seconds after you start the dashboard.
6. Run `/mnt/us/dashboard/stop.sh` when you want to restore the normal Kindle UI/KUAL. 6. Run `/mnt/us/dashboard/stop.sh` when you want to restore the normal Kindle UI/KUAL.
This uses the same conservative restore path on Voyage 5.13.6.
## KUAL ## KUAL
If you're using KUAL you can use simple extension to start this Dashboard If you're using KUAL you can use simple extension to start this Dashboard
1. Copy folder `kindle-dash` from `KUAL` folder to the kual `extensions` folder. (located in `/mnt/us/extensions`) 1. Copy folder `kindle-dash` from `KUAL` folder to the kual `extensions` folder. (located in `/mnt/us/extensions`)
2. The `Kindle Dashboard` entry is now a theme submenu in KUAL.
3. Each theme item calls `/mnt/us/dashboard/launch-theme-from-kual.sh`, which switches the theme first and then delegates to `/mnt/us/dashboard/launch-from-kual.sh`.
4. `launch-from-kual.sh` still quits KUAL first and uses a detached session, but by default it no longer inserts an extra visible handoff delay before starting the dashboard.
5. The runtime page-key menu and the bottom-right touch hotspot are now disabled by default; theme entry is intentionally kept only in KUAL.
6. On Kindle Voyage 5.13.6, this wrapper chain is in place but the KUAL/native-UI handoff is still not considered fully stable. For debugging, prefer `ssh kindle 'cd /mnt/us/dashboard && DEBUG=true ./start.sh'`.
## Debugging ## Debugging
@@ -57,6 +63,7 @@ If you're connected over SSH you can also run `DEBUG=true ./start.sh` to keep th
If you're launching from KUAL, `Dashboard Debug On` now persists `DISABLE_SYSTEM_SUSPEND=true` and immediately restarts the dashboard in one tap. `Dashboard Debug Off` restores the normal low-power behavior and also restarts the dashboard immediately. If you're launching from KUAL, `Dashboard Debug On` now persists `DISABLE_SYSTEM_SUSPEND=true` and immediately restarts the dashboard in one tap. `Dashboard Debug Off` restores the normal low-power behavior and also restarts the dashboard immediately.
If you're connected over SSH and only want a one-off foreground session, you can still run `/mnt/us/dashboard/start-debug.sh`. If you're connected over SSH and only want a one-off foreground session, you can still run `/mnt/us/dashboard/start-debug.sh`.
Each dashboard loop now also writes `isCharging` and `battStateInfo` from `com.lab126.powerd` into `logs/dash.log`, which makes it easier to confirm whether the Kindle actually detected external power while debugging. Each dashboard loop now also writes `isCharging` and `battStateInfo` from `com.lab126.powerd` into `logs/dash.log`, which makes it easier to confirm whether the Kindle actually detected external power while debugging.
On Voyage 5.13.6, if `stop.sh` finishes but the home UI is still missing, the current fallback is still to start `webreader` manually with `/sbin/start webreader`.
## How this works ## How this works

View File

@@ -0,0 +1,220 @@
# Kindle 市级位置方案
## 背景
当前仓库里的天气位置来源仍在 `calendar/` 前端:
- [calendar/src/lib/weather.ts](/Users/gavin/kindle-dash/calendar/src/lib/weather.ts) 会优先尝试 `navigator.geolocation`
- 失败时回退到固定默认值 `杭州`
这条路径不适合当前 Kindle 运行架构,原因有两个:
1. Kindle 实机显示的不是一个在设备上实时运行的天气网页,而是先在别处渲染,再同步为背景图。
2. 如果由 Web 服务器代查 IP 位置,拿到的是服务器公网 IP对应的是服务器所在城市不是 Kindle 所在城市。
本方案的目标是:
- 位置精度只要求到“市”颗粒度
- 位置来源应尽量接近 Kindle 当前网络出口
- 在 SSH 暂时不可用的情况下,先把方案文档明确下来,后续恢复 SSH 后再实现
## 结论
浏览器网页定位不应继续作为主路径。
推荐改成:
1. Kindle 侧自己请求 GeoIP 接口,获取当前公网 IP 对应的城市、经纬度和时区
2. 把结果缓存到本地
3. 天气与相关展示统一读取这份缓存
4. 当 GeoIP 不可用或结果异常时,回退到 Kindle 侧固定配置的 `city/lat/lon/timezone`
这是一个“市级近似定位”方案,不是 GPS 精确定位方案。
## 为什么 Kindle 侧 GeoIP 可行
GeoIP 的前提是“由目标设备自己发请求”。
对当前项目,正确的请求路径应该是:
```text
Kindle -> GeoIP 服务 -> 返回 city / lat / lon / timezone
```
而不是:
```text
Kindle -> 你的 Web 服务 -> Web 服务代查 GeoIP
```
后一种方式只会得到 Web 服务所在机房或服务器出口的位置。
如果 Kindle 走家庭 Wi-Fi且只要求“市级”颗粒度GeoIP 通常够用。
如果 Kindle 走手机热点、VPN、代理、企业网络或运营商 NAT结果可能偏到邻近城市必须接受这种误差。
## 目标能力
实现后需要具备以下能力:
1. Kindle 联网后,能够自己获取当前网络出口对应的城市信息
2. 获取结果会写入本地缓存
3. 断网时仍可继续使用上次缓存
4. GeoIP 失败时可回退到固定配置
5. 只需要市级位置,不追求街道级精度
## 推荐数据模型
建议在 Kindle 侧维护一份位置缓存文件。
路径建议:
```text
/mnt/us/dashboard/local/state/location.env
```
字段建议:
```text
LOCATION_SOURCE=geoip
LOCATION_CITY=杭州
LOCATION_LAT=30.274084
LOCATION_LON=120.155070
LOCATION_TIMEZONE=Asia/Shanghai
LOCATION_UPDATED_AT=2026-03-17T10:00:00+08:00
```
同时保留一份固定兜底配置,例如:
```text
LOCATION_FALLBACK_CITY=杭州
LOCATION_FALLBACK_LAT=30.274084
LOCATION_FALLBACK_LON=120.155070
LOCATION_FALLBACK_TIMEZONE=Asia/Shanghai
```
## 运行时脚本设计
建议新增 Kindle 侧脚本:
```text
dash/src/local/location-sync.sh
```
职责:
1. 检查网络是否可用
2. 由 Kindle 自己请求 GeoIP 接口
3. 解析响应中的城市、经纬度、时区
4. 校验字段是否完整
5. 写入本地缓存
6. 失败时保留旧缓存,不要清空已有结果
建议同时新增一个只负责读取并导出环境变量的脚本,例如:
```text
dash/src/local/location-env.sh
```
职责:
1. 优先读取新鲜的 GeoIP 缓存
2. 没有新鲜缓存时读取旧缓存
3. 缓存不存在或无效时回退到固定配置
4. 对外输出统一的 `LOCATION_CITY/LAT/LON/TIMEZONE`
## 刷新策略
位置不需要高频刷新。
建议策略:
1. 启动 dashboard 且确认联网后,首次同步一次位置
2. 每 12 小时或 24 小时刷新一次
3. 手动切换主题时不强制刷新位置
4. 天气刷新与位置刷新解耦
5. 位置同步失败时继续使用现有缓存
## 与现有架构的衔接方式
当前项目是“背景图 + Kindle 本地时钟覆盖”的架构不是“Kindle 上实时跑完整天气网页”的架构。
因此接入位置数据时,有两条可选路径。
### 路径 A最小改动路径
保留当前背景图生成方式,但让背景图生成时显式读取 Kindle 侧缓存导出的城市与经纬度。
优点:
- 改动面相对小
- 仍可复用现有天气卡片布局与导出流程
缺点:
- 需要重新梳理“背景图生成端”如何拿到 Kindle 缓存
- 如果背景仍在 Mac 侧导出,就必须把 Kindle 缓存同步回 Mac 或转成请求参数
### 路径 B更符合现状的运行时路径
把“城市文本、天气数据”也逐步下沉到 Kindle 运行时,和本地时钟一样由设备端控制。
优点:
- 位置与天气都由 Kindle 自己决定
- 不会再混入 Mac 浏览器位置或服务器 IP 位置
缺点:
- 改动更大
- 需要重新设计天气卡的本地渲染方式
## 当前推荐
短期推荐先做:
1. Kindle 侧 GeoIP 获取
2. 本地缓存
3. 固定配置兜底
等 SSH 恢复后,再决定位置数据如何喂给天气展示层。
也就是说,先把“位置来源”稳定下来,再改“展示方式”。
## 实施顺序
建议按下面顺序落地:
1. 新增 `location-sync.sh`
2. 新增 `location-env.sh`
3. 在 Kindle 运行时目录中引入固定兜底配置
4. 在主循环启动阶段接入位置缓存刷新
5. 让天气读取逻辑改为优先使用 Kindle 侧位置缓存
6. 最后补文档和手动刷新命令
## 验收标准
实现完成后,至少应满足以下验收条件:
1. Kindle 联网后可生成位置缓存文件
2. 缓存中至少包含 `city/lat/lon/timezone`
3. 断网后仍能继续使用旧缓存
4. GeoIP 失败时会稳定回退到固定城市
5. 显示出的城市与 Kindle 当前所在城市大体一致
## 注意事项
1. GeoIP 只能提供近似位置,不要当作精确定位
2. 手机热点、VPN、代理、企业网络都会降低城市准确率
3. 城市级结果可以用于天气展示,但不适合做严格地理判断
4. 任何实现都必须保证“失败不影响 dashboard 主循环”
## 现阶段状态
当前仅完成方案设计,尚未开始代码实现。
直接原因是:
- 目前 SSH 还未恢复
- 设备侧脚本还不能方便地下发、调试和验证
后续等 SSH 恢复后,再按本方案分步实施。

View File

@@ -0,0 +1,262 @@
# Kindle Voyage 5.13.6 Bootstrap 闭环验证清单
## 目标
用一台已经越狱过、同型号同固件的 `Kindle Voyage 5.13.6` 做一次“恢复出厂 -> 重新越狱 -> 安装 KUAL/MRPI/USBNetwork/KTerm -> 打通 SSH -> 显示 dashboard”的闭环验证确认
- [bootstrap-new-kindle.sh](/Users/gavin/kindle-dash/bootstrap-new-kindle.sh) 现在的方案是否足够完整
- 哪些步骤已经可以自动化
- 哪些步骤仍然必须人工完成
## 重要前提
执行前先确认:
1. 设备型号仍然是 `Kindle Voyage`
2. 固件版本仍然是 `5.13.6`
3. 你接受这是破坏性验证
4. 设备里当前的 `KUAL`、SSH、dashboard、主题配置、日志都会被清掉
如果固件版本不是 `5.13.6`,不要按这份清单执行。
## 重置前准备
### 1. 先备份当前设备侧用户存储
把 Kindle 通过 USB 挂载到 Mac 后,至少备份这些目录:
```text
/Volumes/Kindle/dashboard
/Volumes/Kindle/extensions
/Volumes/Kindle/mrpackages
/Volumes/Kindle/documents
```
如果你只想做最小备份,也至少把下面这些拷走:
- `dashboard/`
- `extensions/`
- `documents/` 里你关心的 crash 包和日志
### 2. 在 Mac 侧准备 bootstrap 预置
推荐直接用带 KTerm 下载的版本:
```sh
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version latest
```
如果你不想拉 latest也可以固定版本
```sh
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version v2.6
```
这一步的预期结果:
- Kindle 根目录出现 `Update_hotfix_watchthis_custom.bin`
- Kindle 根目录出现 `dashboard/`
- Kindle 根目录出现 `extensions/`
- Kindle 根目录出现 `mrpackages/`
- Kindle 根目录出现 `ssh-force-dropbear-22.sh`
- Kindle 根目录出现 `.demo/KV-5.13.6.zip`
- Kindle 根目录出现 `.demo/demo.json`
- Kindle 根目录出现 `.demo/goodreads/`
- 如果下载了 `KTerm``extensions/` 里会直接出现 `kterm/`
做完后安全弹出 Kindle。
## 设备侧执行顺序
### 3. 恢复出厂
在 Kindle 上执行恢复出厂。
恢复后开始首启流程时:
1. 语言只选 `English (United Kingdom)`
2. 到 WiFi 页面时,不要真的联网
3. 进入 demo mode按 [kindle-voyage-5.13.6-watchthis-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-watchthis-zh.md#L61) 的流程操作
### 4. WatchThis 导入点
必须特别注意:
1. 第一次 `Add Content / Sideload Content` 只能点 `Done`
2. 不要在第一次导入点接 USB
3. 真正的 payload 导入点,是隐藏手势返回后,再次进入 `;demo -> Sideload Content`
这里的详细说明看:
- [kindle-voyage-5.13.6-watchthis-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-watchthis-zh.md#L80)
- [kindle-voyage-5.13.6-watchthis-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-watchthis-zh.md#L100)
因为 bootstrap 已经把 `.demo` payload 预置好了,这一轮不需要你在导入点再手工从 Mac 拷 `KV-5.13.6.zip`
### 5. 触发越狱并验收
完成 `Get Started` 后,设备会重启并执行越狱脚本。
验收标准:
- Kindle 用户存储根目录出现 `mkk`
- Kindle 用户存储根目录出现 `libkh`
- Kindle 用户存储根目录出现 `rp`
如果没有这三个目录,说明这轮 `WatchThis` 没真正落地,不要继续往后做。
### 6. 安装 KUAL / MRPI / USBNetwork / dashboard / KTerm
回到首页后:
1. 搜索 `;log mrpi`
2. 等 MRPI 跑完
3. 回首页确认:
-`KUAL`
- 如果本轮预置了 `KTerm`,应该已经能看到 `KTerm`
然后进入 `KUAL`,先执行:
```text
Rename OTA Binaries -> Rename
```
不要先跑 `Kindle Dashboard`
## WiFi 和 SSH 验证
### 7. 接回 WiFi
让 Kindle 连到和 Mac 同一个主 WiFi。
不要用:
- Guest WiFi
- 开了客户端隔离的网络
### 8. 在 KTerm 里拉起 DropBear
打开 `KTerm`,执行:
```sh
sh /mnt/us/ssh-force-dropbear-22.sh
/mnt/us/usbnet/bin/lsof -n -P -iTCP:22
```
验收标准:
应该看到类似:
```text
dropbear... TCP *:22 (LISTEN)
```
### 9. 在 Mac 上确认 SSH
回到 Mac
```sh
ssh kindle
```
登录后执行:
```sh
id
uname -a
ps -ef | grep -E 'sshd|dropbear|telnetd' | grep -v grep
```
验收标准:
- `uid=0(root)`
- 存在 `dropbearmulti dropbear ... -p 22 ...`
如果这一步失败,按:
- [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#L210)
继续排障,不要直接判断 bootstrap 失败。
## Dashboard 闭环验证
### 10. 让 bootstrap 跑后半段
在 Mac 上执行:
```sh
sh bootstrap-new-kindle.sh post-ssh -t simple -o portrait
```
预期结果:
- 同步当前 dashboard 运行时
- 同步主题包
- 设备立即切到 `simple / portrait`
- 屏幕出现背景与时钟
如果你还要验证后台常驻主循环,再执行:
```sh
sh bootstrap-new-kindle.sh post-ssh -t simple -o portrait --start-dashboard
```
### 11. 抓首张验收图
在 Mac 上执行:
```sh
sh snapshot.sh -t simple -o portrait
```
或者如果你不想再切主题,只抓当前画面:
```sh
sh snapshot.sh --capture-only -b bootstrap-validation
```
验收标准:
- 本地成功生成 `physical.png`
- Kindle 上显示 `simple / portrait`
- 时钟、天气、日历、鸡汤都正常
## 这轮验证要回答的问题
执行完后,明确记录下面 5 个结论:
1. `prepare-storage --download-kterm` 是否足够把 USB 预置做完整
2. `WatchThis` 是否能在“已有一轮越狱历史”的同机上再次稳定跑通
3. `;log mrpi` 后,`KUAL / USBNetwork / KTerm / dashboard` 是否都能落地
4. `KTerm -> ssh-force-dropbear-22.sh -> ssh kindle` 是否仍是最稳入口
5. `post-ssh` 是否已经能把 dashboard 自动拉到“可见、可抓图”的状态
## 失败时怎么回退
如果中途失败,按这个顺序收敛:
1. 先不要继续改 dashboard 页面代码
2. 先判断失败落在哪一层:
- `WatchThis`
- `MRPI`
- `KTerm`
- `SSH`
- `post-ssh`
3. 如果已经拿到 `KTerm`,优先在本机看进程和端口
4. 如果已经拿到 `ssh kindle`,优先保留日志和 `/mnt/us/ssh-debug/`
5. 不要把 `KUAL -> Dashboard -> 再返回 KUAL` 当成验收项
## 推荐实际执行命令
如果你要按最少分支走,直接按这个顺序:
```sh
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version latest
```
设备侧完成恢复出厂、WatchThis、`;log mrpi`、WiFi、KTerm 拉起 SSH 后,再执行:
```sh
sh bootstrap-new-kindle.sh post-ssh -t simple -o portrait --start-dashboard
sh snapshot.sh --capture-only -b bootstrap-validation
```

View File

@@ -6,6 +6,7 @@
- 预置 `WatchThis` payload - 预置 `WatchThis` payload
- 预置 `KUAL / MRPI / USBNetwork / kindle-dash` - 预置 `KUAL / MRPI / USBNetwork / kindle-dash`
- 可选预置 `KTerm`
- 预置 SSH 恢复脚本 - 预置 SSH 恢复脚本
- SSH 打通后自动同步 dashboard、切主题、立即出图 - SSH 打通后自动同步 dashboard、切主题、立即出图
@@ -32,6 +33,48 @@ sh /mnt/us/ssh-force-dropbear-22.sh
- 尽量把 Mac 侧和文件预置自动化 - 尽量把 Mac 侧和文件预置自动化
- 把设备侧必须手工的动作压到最少 - 把设备侧必须手工的动作压到最少
## KTerm 说明
当前仓库默认不自带 `KTerm` 安装包。
脚本支持两种方式把 `KTerm` 纳入预置:
1. 直接把官方 `KTerm` release 的 `.zip` 放到:
```text
dash/staging/kterm/kterm-kindle-*.zip
```
2. 或执行脚本时显式传入:
```sh
sh bootstrap-new-kindle.sh prepare-storage --kterm-package /绝对路径/kterm-kindle-*.zip
```
3. 或直接让脚本在 Mac 侧联网下载:
```sh
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version latest
```
也可以固定版本:
```sh
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version v2.6
```
如果脚本没有找到 `KTerm` 安装包,它不会报错退出,但会明确提示:
- 本轮只预置 SSH 恢复脚本
- `KTerm` 仍需手工补装
下载逻辑说明:
- 下载发生在 Mac 侧,不在 Kindle 设备侧进行
- 下载后的 `.zip` 会缓存到 `dash/staging/kterm/`
-`Kindle Voyage 5.13.6`,脚本默认优先选择不带 `armhf` 后缀的 `.zip`
- 预置时会直接解压到 Kindle 的 `extensions/`
## 脚本模式 ## 脚本模式
### 1. `prepare-storage` ### 1. `prepare-storage`
@@ -49,6 +92,7 @@ sh bootstrap-new-kindle.sh prepare-storage
-`Update_hotfix_watchthis_custom.bin` 放到 Kindle 根目录 -`Update_hotfix_watchthis_custom.bin` 放到 Kindle 根目录
-`extensions/``mrpackages/``dashboard/` 预置到 Kindle -`extensions/``mrpackages/``dashboard/` 预置到 Kindle
-`scripts/kindle/*.sh` 拷到 Kindle 根目录,供 `KTerm` 使用 -`scripts/kindle/*.sh` 拷到 Kindle 根目录,供 `KTerm` 使用
- 如果检测到 `KTerm` zip也会一并解压到 `extensions/`
### 2. `post-ssh` ### 2. `post-ssh`
@@ -94,14 +138,15 @@ sh bootstrap-new-kindle.sh
4. 通过 `Get Started` 完成越狱 4. 通过 `Get Started` 完成越狱
5. 搜索 `;log mrpi` 5. 搜索 `;log mrpi`
6.`KUAL` 中先执行 `Rename OTA Binaries -> Rename` 6.`KUAL` 中先执行 `Rename OTA Binaries -> Rename`
7. 连上 WiFi 7. 如果本轮没有预置 `KTerm`,这里先手工补装 `KTerm`
8. 打开 `KTerm`,执行: 8. 连上 WiFi
9. 打开 `KTerm`,执行:
```sh ```sh
sh /mnt/us/ssh-force-dropbear-22.sh sh /mnt/us/ssh-force-dropbear-22.sh
``` ```
9. 回到 Mac执行 10. 回到 Mac执行
```sh ```sh
sh bootstrap-new-kindle.sh post-ssh sh bootstrap-new-kindle.sh post-ssh
@@ -113,3 +158,5 @@ sh bootstrap-new-kindle.sh post-ssh
[kindle-voyage-5.13.6-watchthis-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-watchthis-zh.md) [kindle-voyage-5.13.6-watchthis-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-watchthis-zh.md)
- SSH 打通与 KTerm 兜底: - 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) [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)
- 恢复出厂后的 bootstrap 闭环验证:
[kindle-voyage-5.13.6-bootstrap-validation-zh.md](/Users/gavin/kindle-dash/dash/docs/kindle-voyage-5.13.6-bootstrap-validation-zh.md)

View File

@@ -345,7 +345,6 @@ cat /mnt/us/usbnet/etc/config
- [scripts/kindle/ssh-collect.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-collect.sh) - [scripts/kindle/ssh-collect.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-collect.sh)
- [scripts/kindle/ssh-fix-all-keys.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-fix-all-keys.sh) - [scripts/kindle/ssh-fix-all-keys.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-fix-all-keys.sh)
- [scripts/kindle/ssh-force-openssh-22.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-force-openssh-22.sh)
- [scripts/kindle/ssh-force-dropbear-22.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-force-dropbear-22.sh) - [scripts/kindle/ssh-force-dropbear-22.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-force-dropbear-22.sh)
- [scripts/kindle/ssh-stop-all.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-stop-all.sh) - [scripts/kindle/ssh-stop-all.sh](/Users/gavin/kindle-dash/scripts/kindle/ssh-stop-all.sh)

View File

@@ -1,22 +1,25 @@
# Kindle Voyage 5.13.6 白屏/KUAL/SSH 交接文档 # Kindle Voyage 5.13.6 白屏/KUAL/SSH 交接文档
本文记录 2026-03-15 这轮 dashboard 调试在后半段进入的异常状态,目标是给下一次接手排障的人一个明确起点,避免继续沿着已经证伪或高风险的路径重复试错。 本文记录 2026-03-15 到 2026-03-17 这轮 dashboard 调试在后半段进入的异常状态,目标是给下一次接手排障的人一个明确起点,避免继续沿着已经证伪或高风险的路径重复试错。
## 当前交接状态 ## 当前交接状态
截至本次更新时,设备不再处于“完全卡死且无法 SSH”的状态而是进入了一个更窄的失败场景 截至本次更新时,设备已经不再停留在“完全卡死且无法 SSH”的状态结论也分成了两部分
- Kindle 已能回到主页 - 已修住的:
- `calendar -> 主页 -> KUAL -> 回主页` 已能正常工作
- `ssh kindle` 已恢复可用 - `ssh kindle` 已恢复可用
- `KUAL -> Kindle Dashboard` 进入 dashboard 时,仍会复现白屏 - 当前默认架构已切到“保留原生 UI 栈”的 overlay 模式
- 仍未完全收口的:
-`KUAL -> Kindle Dashboard` 进入 dashboard 时,仍出现过白屏
- 白屏出现时dashboard 本身往往没有真正接管成功,更像是 `framework/KUAL` 启动链在中途被打断 - 白屏出现时dashboard 本身往往没有真正接管成功,更像是 `framework/KUAL` 启动链在中途被打断
- 当前最稳定的恢复路径,仍然是通过 SSH 执行 `./stop.sh`
补充记录一个当前仍未修住、但边界已经比较清楚的问题: 补充记录一个当前仍未修住、但边界已经比较清楚的问题:
-`KUAL` 进入 dashboard 后,再尝试回到 dashboard / KUAL 的原生 UI 路径,仍可能落入白屏 -`KUAL` 进入 dashboard 后,再尝试回到 dashboard / KUAL 的原生 UI 路径,仍可能落入白屏
- 这个问题不应再继续归因到背景图、时钟或页面布局 - 这个问题不应再继续归因到背景图、时钟或页面布局
- 当前更合理的判断,仍然是 `KUAL -> start.sh -> dash.sh` 的切换链路不稳定 - 当前更合理的判断,仍然是 `KUAL -> start.sh -> dash.sh` 的切换链路不稳定
- `2026-03-17` 这轮新增验证还能确认:直接碰 `blanket` 或强拉 `booklet.home`,会进一步触发 `blanket / cvm` 崩溃
这份文档只记录当前交接结论,不再继续尝试修复。 这份文档只记录当前交接结论,不再继续尝试修复。
@@ -37,9 +40,9 @@ Skipping system suspend, sleeping for 40s instead
所以“点 `Dashboard Debug On` 之后 3 秒就休眠”这个问题,本轮已经修住。 所以“点 `Dashboard Debug On` 之后 3 秒就休眠”这个问题,本轮已经修住。
### 2. `dashboard` 模式不是可交互界面 ### 2. 旧架构里,`dashboard` 不是可交互界面
当前实现里dashboard 启动后会主动停掉 Kindle 的前台 UI 实现里dashboard 启动后会主动停掉 Kindle 的前台 UI
- [dash/src/dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh#L50) 调用 `stop_framework` - [dash/src/dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh#L50) 调用 `stop_framework`
- [dash/src/dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh#L51) 停掉 `webreader` - [dash/src/dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh#L51) 停掉 `webreader`
@@ -49,6 +52,12 @@ Skipping system suspend, sleeping for 40s instead
- 进入 dashboard 之后,不应再期望当前屏幕仍然像普通 Kindle 页面那样可点击 - 进入 dashboard 之后,不应再期望当前屏幕仍然像普通 Kindle 页面那样可点击
- 也不应再期待“从 dashboard 直接返回刚才那个 KUAL 页面” - 也不应再期待“从 dashboard 直接返回刚才那个 KUAL 页面”
补充:
- 截至 `2026-03-17`,默认实现已经改成 `KEEP_NATIVE_UI_STACK_RUNNING=true`
- 也就是保留 `framework / webreader` 存活,把 dashboard 当成覆盖显示层
- 在这条新路径上,`calendar -> 主页 -> KUAL -> 回主页` 已完成实机验证
### 3. 顶栏遮罩不处理触摸 ### 3. 顶栏遮罩不处理触摸
右上角状态栏遮罩逻辑在: 右上角状态栏遮罩逻辑在:
@@ -60,45 +69,65 @@ Skipping system suspend, sleeping for 40s instead
- “点不到 KUAL” 不是顶栏遮罩造成的 - “点不到 KUAL” 不是顶栏遮罩造成的
- 真正相关的是 `framework/webreader` 被停掉 - 真正相关的是 `framework/webreader` 被停掉
### 4. `stop.sh` 现在只负责恢复 UI 栈,不负责直接打开 KUAL ### 4. `stop.sh` 现在分成两种退出路径
当前 [dash/src/stop.sh](/Users/gavin/kindle-dash/dash/src/stop.sh) 已改成: 当前 [dash/src/stop.sh](/Users/gavin/kindle-dash/dash/src/stop.sh) 已改成:
- 默认 overlay 模式:
- 停掉 `dash.sh` - 停掉 `dash.sh`
- 清掉 `preventScreenSaver` - 清掉 `preventScreenSaver`
- 启动 `framework` - 停掉主题菜单和右下角长按监听
- 启动 `webreader` - 如果底层当前是 `KUAL`,再通过 `appmgrd stop` 把它正常退回首页
- 旧架构兼容模式:
- 停掉 `dash.sh`
- 清掉 `preventScreenSaver`
- 停干净 `framework / webreader / cvm`
- 再按顺序拉起 `framework`
-`cvm` 回来后启动 `webreader`
也就是说它的职责是: 也就是说它的职责是:
- 让 Kindle 回到“应该可以恢复正常 UI”的状态 - 让 Kindle 从 dashboard 安全退回原生 UI
不是: 不是:
- 直接把 KUAL booklet 弹出来 - 直接把 KUAL booklet 弹出来
- 也不是走实验性的“快切换”
补充:
- 本轮新试过的“快路径”已经撤回
- 直接碰 `blanket` 或尝试 shell 里强拉 `booklet.home`,都可能把 `blanket / cvm` 打崩
- 当前 Voyage 5.13.6 仍以稳定恢复优先,快速切换需要改入口架构,不能继续堆在 `stop.sh`
补充一点:在白屏恢复过程中,`stop.sh` 已经比旧版稳定很多,但仍存在一种残留状态: 补充一点:在白屏恢复过程中,`stop.sh` 已经比旧版稳定很多,但仍存在一种残留状态:
- `framework``cvm` 已回来了 - `framework``cvm` 已回来了
- `webreader` 可能还停在 `stop/waiting` - `webreader` 可能还停在 `stop/waiting`
这时手工再执行一次 `start webreader`,主页通常就能回来 针对这个问题,`stop.sh` 现在已经补成“循环检查并补拉起 `webreader`,直到连续几次都保持 `start/running`
如果未来仍然遇到个别恢复失败,再把 `/sbin/start webreader` 当作人工兜底,不再作为默认恢复步骤。
### 5. 直接 `booklet run` 的试探命令不安全 ### 5. 直接 `booklet run` 或手动 `blanket unload` 的试探命令不安全
本轮为了验证能否从 shell 里直接拉起主页KUAL试过类命令: 本轮为了验证能否从 shell 里直接拉起主页KUAL或者手动剥掉前台遮罩,试过类命令:
```sh ```sh
lipc-set-prop com.lab126.booklet run "app://com.lab126.booklet.home" lipc-set-prop com.lab126.booklet run "app://com.lab126.booklet.home"
lipc-set-prop com.lab126.booklet run "com.mobileread.ixtab.kindlelauncher.KualBooklet" lipc-set-prop com.lab126.booklet run "com.mobileread.ixtab.kindlelauncher.KualBooklet"
lipc-set-prop com.lab126.blanket unload splash
lipc-set-prop com.lab126.blanket unload screensaver
``` ```
这条路不稳定,已经触发过 `cvm` 崩溃打包。设备上看到过: 这条路不稳定,已经触发过 `cvm` 崩溃打包。设备上看到过:
- `/mnt/us/documents/cvm_2886_..._crash_Mar_15_14.14.19_2026.tgz` - `/mnt/us/documents/cvm_2886_..._crash_Mar_15_14.14.19_2026.tgz`
- `/mnt/us/documents/cvm_5551_..._crash_Mar_15_14.18.54_2026.tgz` - `/mnt/us/documents/cvm_5551_..._crash_Mar_15_14.18.54_2026.tgz`
- `/mnt/us/documents/cvm_18914_..._crash_Mar_17_09.16.13_2026.tgz`
- `/mnt/us/documents/cvm_22294_..._crash_Mar_17_09.21.03_2026.tgz`
- `/mnt/us/documents/blanket_13032_..._crash_Mar_17_09.10.13_2026.tgz`
因此下次接手时,不要再直接复用这两条命令 因此下次接手时,不要再直接复用这些命令,也不要把“先手动卸掉 `blanket` 再看前台会不会回来”当成安全恢复手段
### 6. dashboard 本身可以工作,失败更像发生在 KUAL 启动路径 ### 6. dashboard 本身可以工作,失败更像发生在 KUAL 启动路径
@@ -277,7 +306,7 @@ ssh kindle 'cd /mnt/us/dashboard && ./stop.sh'
如果执行完 `./stop.sh` 后主页仍然没有回来,再补: 如果执行完 `./stop.sh` 后主页仍然没有回来,再补:
```sh ```sh
ssh kindle 'start webreader' ssh kindle '/sbin/start webreader'
``` ```
不要从 dashboard 页面直接尝试回 KUAL。 不要从 dashboard 页面直接尝试回 KUAL。
@@ -307,11 +336,45 @@ ssh kindle 'start webreader'
3. 继续保留 `ssh kindle 'cd /mnt/us/dashboard && DEBUG=true ./start.sh'` 作为唯一已验证稳定的进入方式 3. 继续保留 `ssh kindle 'cd /mnt/us/dashboard && DEBUG=true ./start.sh'` 作为唯一已验证稳定的进入方式
4. 继续保留 `./stop.sh` 作为唯一已验证稳定的退出恢复方式 4. 继续保留 `./stop.sh` 作为唯一已验证稳定的退出恢复方式
截至 2026-03-18这个修复方向已经按最小改动落地到仓库
1. KUAL 顶层菜单改成 `Kindle Dashboard -> 主题列表`
2. 具体主题项调用 `/mnt/us/dashboard/launch-theme-from-kual.sh`
3. `launch-theme-from-kual.sh` 会先切主题,再委托 `/mnt/us/dashboard/launch-from-kual.sh`
4. `launch-from-kual.sh` 仍会先请求 KUAL 正常退出,并通过 `setsid` 脱离当前会话后启动 `start.sh`
5. 默认 `KUAL_QUIT_GRACE_SECONDS=0``KUAL_LAUNCH_DELAY_SECONDS=0`,不再人为停留在原生首页,尽量做到点击后直接进入 calendar overlay
6. `start.sh` 的非调试后台启动也额外加上了 `setsid`
### E. 2026-03-17 新架构:保留原生 UI 栈
为解决“长期显示 calendar同时仍能切回首页/KUAL”仓库里新增并默认启用了
- `KEEP_NATIVE_UI_STACK_RUNNING=true`
当前行为是:
- `dash.sh` 启动时不再主动停止 `framework / webreader`
- `stop.sh` 在该开关打开时,只停止 dashboard 自己,不再重建 `framework / webreader / cvm`
- dashboard 作为“覆盖显示层 + RTC suspend 调度”运行,而不是“替代原生 UI 的前台”
- 主题切换入口当前只保留在 KUAL运行态双翻页键菜单与右下角长按默认关闭
- 实机已验证:`calendar -> 主页 -> KUAL -> 回主页` 全链路正常
这条路线已经证明比“启动时停掉 framework/webreader”更符合当前需求。
7. `Dashboard Debug On/Off` 也统一改走同一条 wrapper 启动链
这只能说明“修复方向已经进入代码”,还不能替代真实设备上的交互验收。
更具体地说,截至 `2026-03-17` 的真机实验结果是:
1. wrapper 已经落地
2. `stop.sh` 的实验性快切换已经撤回
3. 当前真正安全的进入方式仍然只有 SSH 前台调试
4. 当前真正安全的退出方式仍然只有保守恢复版 `./stop.sh`
等 SSH 恢复后,再围绕下面三点做实机验证: 等 SSH 恢复后,再围绕下面三点做实机验证:
1. KUAL wrapper 是否还能触发 `framework` 被 TERM 1. KUAL wrapper 是否还能触发 `framework` 被 TERM
2. `start.sh` 的后台脱离方式是否足够彻底 2. `start.sh` 的后台脱离方式是否足够彻底
3. `stop.sh` 后是否还需要补 `start webreader` 3. `stop.sh` 后是否还需要补 `/sbin/start webreader`
## 这轮涉及的关键文件 ## 这轮涉及的关键文件
@@ -331,10 +394,10 @@ ssh kindle 'start webreader'
- dashboard 与 Kindle 原生 `framework/KUAL` 的边界切换不稳定 - dashboard 与 Kindle 原生 `framework/KUAL` 的边界切换不稳定
- `KUAL -> Kindle Dashboard` 这条启动链仍会白屏 - `KUAL -> Kindle Dashboard` 这条启动链仍会白屏
- 直接用 shell 强拉 booklet 会触发前台 Java 崩溃 - 直接用 shell 强拉 booklet,或手动碰 `blanket`,都会触发前台 `blanket / cvm` 崩溃
因此,当前最重要的不是继续调页面,而是: 因此,当前最重要的不是继续调页面,而是:
1. 保留当前已经可用的 SSH 启动/停止路径 1. 保留当前已经可用的 SSH 启动/停止路径
2. 修住 `KUAL -> Kindle Dashboard` 白屏 2. 修住 `KUAL -> Kindle Dashboard` 白屏
3. 在不再触发 `cvm` 崩溃的前提下,把“进入 dashboard”和“退出 dashboard”都收敛成稳定流程 3. 在不再触发 `blanket / cvm` 崩溃的前提下,把“进入 dashboard”和“退出 dashboard”都收敛成稳定流程

View File

@@ -4,6 +4,9 @@
本文是评审方案,不是实机结论。 本文是评审方案,不是实机结论。
补充说明:当前仓库默认已经不启用这套运行态菜单,主题切换入口只保留在 KUAL。
本文保留为备选方案与历史设计记录,不代表当前默认交互。
当前 Kindle 已掉出 Wi-FiSSH 中断,因此这份文档的目标是: 当前 Kindle 已掉出 Wi-FiSSH 中断,因此这份文档的目标是:
- 先把“左右同时按下翻页键,呼出主题选择页面”的方案固定下来 - 先把“左右同时按下翻页键,呼出主题选择页面”的方案固定下来
@@ -14,7 +17,15 @@
- Kindle Voyage - Kindle Voyage
- 固件 5.13.6 - 固件 5.13.6
- dashboard 运行时会停掉 framework / webreader - 当前默认实现为“保留 framework / webreader 的 overlay 模式”
补充:
- 本文很多菜单设计最初建立在“dashboard 会停掉原生 UI 栈”的旧架构上
- 截至 `2026-03-17`,默认实现已经切到“保留原生 UI 栈”
- 因此文中提到的 `Return Home`,应以 `stop.sh` 的当前行为为准:
- overlay 模式下:停掉 dashboard并在底层是 KUAL 时把它正常退回首页
- 旧架构兼容模式下:保守恢复 `framework / cvm / webreader`
## 1. 已确认事实 ## 1. 已确认事实
@@ -85,6 +96,7 @@ echo "mem" >/sys/power/state
3. 用户通过翻页键在主题列表中移动选中项 3. 用户通过翻页键在主题列表中移动选中项
4. 用户再次同时按下左右翻页键,确认并切换主题 4. 用户再次同时按下左右翻页键,确认并切换主题
5. 切换完成后,立即刷新背景和时钟 5. 切换完成后,立即刷新背景和时钟
6. 菜单列表末尾可附带一个“返回首页”动作项,安全退出 dashboard 并恢复 Kindle 首页
本阶段非目标: 本阶段非目标:
@@ -113,7 +125,16 @@ echo "mem" >/sys/power/state
- `PageUp`:向上移动 - `PageUp`:向上移动
- `PageDown`:向下移动 - `PageDown`:向下移动
- 再次双键同时按下:确认当前主题 - 再次双键同时按下:确认当前
同时保留一个触摸兜底入口:
- 右下角长按:直接呼出同一份运行态主题菜单
其中当前项可以是:
- 某个主题:执行主题切换
- `Return Home`:退出 dashboard恢复 framework / webreader回到 Kindle 首页
这样做的原因: 这样做的原因:
@@ -221,12 +242,19 @@ Press both keys: apply
3. 识别到组合键后,本机绘制菜单 3. 识别到组合键后,本机绘制菜单
4. 用户确认后,调用现有: 4. 用户确认后,调用现有:
- `/mnt/us/dashboard/switch-theme.sh <theme-id> [orientation]` - `/mnt/us/dashboard/switch-theme.sh <theme-id> [orientation]`
-`/mnt/us/dashboard/stop.sh`
5. `switch-theme.sh` 继续负责: 5. `switch-theme.sh` 继续负责:
- 同步主题配置 - 同步主题配置
- 拉最新背景 - 拉最新背景
- 刷新屏幕 - 刷新屏幕
- 重绘时钟 - 重绘时钟
如果选中的是 `Return Home`,则不走主题切换,而是调用 `stop.sh`
- 停掉 dashboard 和菜单监听器
- 恢复 `framework / cvm / webreader`
- 回到 Kindle 首页
也就是说,菜单服务只负责: 也就是说,菜单服务只负责:
- 触发 - 触发
@@ -309,6 +337,7 @@ Press both keys: apply
export THEME_MENU_ENABLED=true export THEME_MENU_ENABLED=true
export THEME_MENU_EVENT_DEVICE="/dev/input/event2" export THEME_MENU_EVENT_DEVICE="/dev/input/event2"
export THEME_MENU_COMBO_WINDOW_SECONDS=0.35 export THEME_MENU_COMBO_WINDOW_SECONDS=0.35
export THEME_MENU_RUNTIME_DIR="/tmp/kindle-dash-theme-menu"
``` ```
说明: 说明:
@@ -320,6 +349,9 @@ export THEME_MENU_COMBO_WINDOW_SECONDS=0.35
- 后续换机型时可以只改这个 - 后续换机型时可以只改这个
- `THEME_MENU_COMBO_WINDOW_SECONDS` - `THEME_MENU_COMBO_WINDOW_SECONDS`
- 组合键判定窗口 - 组合键判定窗口
- `THEME_MENU_RUNTIME_DIR`
- 菜单服务的临时运行目录
- 需要放在支持 FIFO 的文件系统上Voyage 上应使用 `/tmp`
## 9. 风险与限制 ## 9. 风险与限制
@@ -408,8 +440,12 @@ export THEME_MENU_COMBO_WINDOW_SECONDS=0.35
- 监听 `/dev/input/event2` - 监听 `/dev/input/event2`
- 用短时间窗口识别 `PageUp + PageDown` 组合键 - 用短时间窗口识别 `PageUp + PageDown` 组合键
- 在运行态下绘制一个 KUAL 风格的文本主题菜单 - 在运行态下绘制一个 KUAL 风格的文本主题菜单
- 右下角长按也复用同一份菜单,而不是另起一套触摸 UI
-`PageUp / PageDown` 导航 -`PageUp / PageDown` 导航
- 再次双键确认 - 再次双键确认
- 最终复用现有 `switch-theme.sh` 完成主题切换 - 最终复用现有 `switch-theme.sh` 完成主题切换
这是当前成本最低、最容易恢复、也最适合在 SSH 不稳定阶段先落地评审的方案。 这是当前成本最低、最容易恢复、也最适合在 SSH 不稳定阶段先落地评审的方案。
如果菜单需要提供稳定退出入口,也可以在列表末尾追加一个 `Return Home` 动作项。
实现上应复用 `stop.sh` 的保守恢复链路,而不要直接强拉 `booklet.home`

View File

@@ -13,6 +13,8 @@ THEME_RUNTIME_ENV_FILE="$STATE_DIR/theme-runtime.env"
THEME_SYNC_CMD="$DIR/local/theme-sync.sh" THEME_SYNC_CMD="$DIR/local/theme-sync.sh"
THEME_MENU_SERVICE_CMD="$DIR/local/theme-menu-service.sh" THEME_MENU_SERVICE_CMD="$DIR/local/theme-menu-service.sh"
THEME_MENU_LOG_FILE="$DIR/logs/theme-menu.log" THEME_MENU_LOG_FILE="$DIR/logs/theme-menu.log"
TOUCH_HOME_SERVICE_CMD="$DIR/local/touch-home-service.sh"
TOUCH_HOME_LOG_FILE="$DIR/logs/touch-home.log"
REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"} REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"}
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0} FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
@@ -31,6 +33,7 @@ STATUS_MASK_HEIGHT=${STATUS_MASK_HEIGHT:-24}
STATUS_MASK_PASSES=${STATUS_MASK_PASSES:-3} STATUS_MASK_PASSES=${STATUS_MASK_PASSES:-3}
STATUS_MASK_DELAY_SECONDS=${STATUS_MASK_DELAY_SECONDS:-1} STATUS_MASK_DELAY_SECONDS=${STATUS_MASK_DELAY_SECONDS:-1}
RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable
KEEP_NATIVE_UI_STACK_RUNNING=${KEEP_NATIVE_UI_STACK_RUNNING:-false}
LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false} LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
LOW_BATTERY_THRESHOLD_PERCENT=${LOW_BATTERY_THRESHOLD_PERCENT:-10} LOW_BATTERY_THRESHOLD_PERCENT=${LOW_BATTERY_THRESHOLD_PERCENT:-10}
@@ -39,7 +42,7 @@ num_refresh=0
background_needs_redraw=true background_needs_redraw=true
start_theme_menu_service() { start_theme_menu_service() {
if [ "${THEME_MENU_ENABLED:-true}" != true ]; then if [ "${THEME_MENU_ENABLED:-false}" != true ]; then
return return
fi fi
@@ -52,6 +55,20 @@ start_theme_menu_service() {
nohup "$THEME_MENU_SERVICE_CMD" >>"$THEME_MENU_LOG_FILE" 2>&1 </dev/null & nohup "$THEME_MENU_SERVICE_CMD" >>"$THEME_MENU_LOG_FILE" 2>&1 </dev/null &
} }
start_touch_home_service() {
if [ "${TOUCH_HOME_ENABLED:-false}" != true ]; then
return
fi
if [ ! -x "$TOUCH_HOME_SERVICE_CMD" ]; then
return
fi
mkdir -p "$(dirname "$TOUCH_HOME_LOG_FILE")"
pkill -f "$TOUCH_HOME_SERVICE_CMD" 2>/dev/null || true
nohup "$TOUCH_HOME_SERVICE_CMD" >>"$TOUCH_HOME_LOG_FILE" 2>&1 </dev/null &
}
load_theme_runtime_config() { load_theme_runtime_config() {
if [ -f "$THEME_RUNTIME_ENV_FILE" ]; then if [ -f "$THEME_RUNTIME_ENV_FILE" ]; then
# shellcheck disable=SC1090 # shellcheck disable=SC1090
@@ -74,11 +91,17 @@ init() {
echo "System suspend disabled, using normal sleep between refreshes." echo "System suspend disabled, using normal sleep between refreshes."
fi fi
if [ "$KEEP_NATIVE_UI_STACK_RUNNING" = true ]; then
echo "Keeping framework/webreader running for native UI overlay mode."
else
stop_framework stop_framework
initctl stop webreader >/dev/null 2>&1 initctl stop webreader >/dev/null 2>&1
fi
echo powersave >/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor echo powersave >/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
lipc-set-prop com.lab126.powerd preventScreenSaver 1 lipc-set-prop com.lab126.powerd preventScreenSaver 1
start_theme_menu_service start_theme_menu_service
start_touch_home_service
} }
stop_framework() { stop_framework() {

View File

@@ -37,8 +37,10 @@ mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它 # 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 然后立刻拉起新的 dashboard避免用户还要再次手动启动。 # 然后立刻拉起新的 dashboard避免用户还要再次手动启动。
pkill -f "$DIR/dash.sh" 2>/dev/null || true pkill -f "$DIR/dash.sh" 2>/dev/null || true
pkill -f "$DIR/start.sh" 2>/dev/null || true
pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true
pkill -f "$DIR/local/touch-home-service.sh" 2>/dev/null || true
sleep 1 sleep 1
"$DIR/start.sh" "$DIR/launch-from-kual.sh"
echo "已关闭 Dashboard 调试模式,并自动重启 Kindle Dashboard。" echo "已关闭 Dashboard 调试模式,并自动重启 Kindle Dashboard。"

View File

@@ -37,8 +37,10 @@ mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它 # 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 然后立刻拉起新的 dashboard避免用户还要在短时间内再点一次菜单。 # 然后立刻拉起新的 dashboard避免用户还要在短时间内再点一次菜单。
pkill -f "$DIR/dash.sh" 2>/dev/null || true pkill -f "$DIR/dash.sh" 2>/dev/null || true
pkill -f "$DIR/start.sh" 2>/dev/null || true
pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true
pkill -f "$DIR/local/touch-home-service.sh" 2>/dev/null || true
sleep 1 sleep 1
"$DIR/start.sh" "$DIR/launch-from-kual.sh"
echo "已开启 Dashboard 调试模式,并自动重启 Kindle Dashboard。" echo "已开启 Dashboard 调试模式,并自动重启 Kindle Dashboard。"

87
dash/src/launch-from-kual.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env sh
set -eu
DIR="$(dirname "$0")"
LOG_FILE="$DIR/logs/kual-launch.log"
PID_FILE="$DIR/logs/kual-launch.pid"
# overlay 模式下,用户期望从 KUAL 点击后几乎直接进入 dashboard。
# 这里仍然要求 KUAL 先正常退出,但默认不再额外人为等待,
# 只在需要回退到更保守的切换节奏时,再通过环境变量显式加回延迟。
LAUNCH_DELAY_SECONDS="${KUAL_LAUNCH_DELAY_SECONDS:-0}"
KUAL_QUIT_GRACE_SECONDS="${KUAL_QUIT_GRACE_SECONDS:-0}"
KUAL_APP_ID="${KUAL_APP_ID:-app://com.mobileread.ixtab.kindlelauncher}"
mkdir -p "$(dirname "$LOG_FILE")"
case "$LAUNCH_DELAY_SECONDS" in
''|*[!0-9]*)
LAUNCH_DELAY_SECONDS=0
;;
esac
case "$KUAL_QUIT_GRACE_SECONDS" in
''|*[!0-9]*)
KUAL_QUIT_GRACE_SECONDS=0
;;
esac
if [ -f "$PID_FILE" ]; then
old_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "${old_pid:-}" ] && kill -0 "$old_pid" 2>/dev/null; then
kill "$old_pid" 2>/dev/null || true
sleep 1
fi
fi
# KUAL 直接同步执行 start.sh 时framework/KUAL 正在切换的那一瞬间,
# 后台 dash 进程仍可能留在同一个 session 里被一起 TERM 掉。
# 这里仍然先让独立 session 请求 KUAL 正常退出,但默认不再故意停留在原生首页,
# 而是把启动等待压到 0尽量直接切到 dashboard。
if command -v setsid >/dev/null 2>&1; then
nohup setsid /bin/sh -c '
delay=$1
target_dir=$2
kual_app_id=$3
quit_grace=$4
# 复用 KUAL 自己的 appmgrd stop 路径,尽量让它先干净退回原生 UI。
if command -v lipc-set-prop >/dev/null 2>&1; then
lipc-set-prop com.lab126.appmgrd stop "$kual_app_id" >/dev/null 2>&1 || true
fi
if [ "$quit_grace" -gt 0 ] 2>/dev/null; then
sleep "$quit_grace"
fi
if [ "$delay" -gt 0 ] 2>/dev/null; then
sleep "$delay"
fi
exec "$target_dir/start.sh"
' sh "$LAUNCH_DELAY_SECONDS" "$DIR" "$KUAL_APP_ID" "$KUAL_QUIT_GRACE_SECONDS" >>"$LOG_FILE" 2>&1 </dev/null &
else
nohup /bin/sh -c '
delay=$1
target_dir=$2
kual_app_id=$3
quit_grace=$4
# 复用 KUAL 自己的 appmgrd stop 路径,尽量让它先干净退回原生 UI。
if command -v lipc-set-prop >/dev/null 2>&1; then
lipc-set-prop com.lab126.appmgrd stop "$kual_app_id" >/dev/null 2>&1 || true
fi
if [ "$quit_grace" -gt 0 ] 2>/dev/null; then
sleep "$quit_grace"
fi
if [ "$delay" -gt 0 ] 2>/dev/null; then
sleep "$delay"
fi
exec "$target_dir/start.sh"
' sh "$LAUNCH_DELAY_SECONDS" "$DIR" "$KUAL_APP_ID" "$KUAL_QUIT_GRACE_SECONDS" >>"$LOG_FILE" 2>&1 </dev/null &
fi
echo "$!" >"$PID_FILE"
echo "$(date 2>/dev/null || true) requested KUAL quit, scheduled dashboard launch with quit_grace=${KUAL_QUIT_GRACE_SECONDS}s launch_delay=${LAUNCH_DELAY_SECONDS}s" >>"$LOG_FILE"

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env sh
set -eu
DIR="$(dirname "$0")"
SWITCH_THEME_CMD="$DIR/switch-theme.sh"
LAUNCH_FROM_KUAL_CMD="$DIR/launch-from-kual.sh"
requested_theme_id=${1:?"usage: launch-theme-from-kual.sh <theme-id> [orientation]"}
requested_orientation=${2:-}
# KUAL 里的主题入口先切主题,再复用现有的 launch-from-kual 启动链。
# 这样可以保留当前已经收敛过的 KUAL 退出与 detached 启动逻辑,
# 同时把“选主题”前移到进入 dashboard 之前。
if [ -n "$requested_orientation" ]; then
"$SWITCH_THEME_CMD" "$requested_theme_id" "$requested_orientation"
else
"$SWITCH_THEME_CMD" "$requested_theme_id"
fi
exec "$LAUNCH_FROM_KUAL_CMD"

View File

@@ -59,11 +59,34 @@ export STATUS_MASK_HEIGHT=${STATUS_MASK_HEIGHT:-24}
export STATUS_MASK_PASSES=${STATUS_MASK_PASSES:-3} export STATUS_MASK_PASSES=${STATUS_MASK_PASSES:-3}
export STATUS_MASK_DELAY_SECONDS=${STATUS_MASK_DELAY_SECONDS:-1} export STATUS_MASK_DELAY_SECONDS=${STATUS_MASK_DELAY_SECONDS:-1}
# 左右翻页键同时按下时,呼出主题菜单; # 运行态主题菜单当前默认关闭,主题切换统一收口到 KUAL 入口。
# 菜单本身仍复用当前 dashboard 的运行方向,只切换 theme id # 如需恢复双翻页键菜单,可临时改回 true 做专项验证
export THEME_MENU_ENABLED=${THEME_MENU_ENABLED:-true} export THEME_MENU_ENABLED=${THEME_MENU_ENABLED:-false}
export THEME_MENU_EVENT_DEVICE=${THEME_MENU_EVENT_DEVICE:-"/dev/input/event2"} 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} export THEME_MENU_COMBO_WINDOW_SECONDS=${THEME_MENU_COMBO_WINDOW_SECONDS:-0.35}
# `/mnt/us` 是 FAT 分区,不能创建 FIFO菜单监听器的临时管道必须放在 `/tmp`。
export THEME_MENU_RUNTIME_DIR=${THEME_MENU_RUNTIME_DIR:-"/tmp/kindle-dash-theme-menu"}
# 默认模式:保留 Kindle 原生 UI 栈,让 dashboard 只负责画面覆盖与休眠调度。
# 这条路线已经在 Voyage 5.13.6 上实机验证通过:
# calendar -> 主页 -> KUAL -> 回主页 可正常工作。
export KEEP_NATIVE_UI_STACK_RUNNING=${KEEP_NATIVE_UI_STACK_RUNNING:-true}
# 从 KUAL 进入 dashboard 时,默认不再刻意停留在原生首页。
# 如果某台机器仍需要更保守的切换节奏,可把这两个值临时调大后再做回归验证。
export KUAL_QUIT_GRACE_SECONDS=${KUAL_QUIT_GRACE_SECONDS:-0}
export KUAL_LAUNCH_DELAY_SECONDS=${KUAL_LAUNCH_DELAY_SECONDS:-0}
# 右下角长按热区当前默认关闭,避免和底层原生桌面的触摸响应冲突。
# 如需恢复这条实验入口,可临时改回 true但当前默认只保留 KUAL 主题入口。
export TOUCH_HOME_ENABLED=${TOUCH_HOME_ENABLED:-false}
export TOUCH_HOME_EVENT_DEVICE=${TOUCH_HOME_EVENT_DEVICE:-"/dev/input/event1"}
export TOUCH_HOME_RUNTIME_DIR=${TOUCH_HOME_RUNTIME_DIR:-"/tmp/kindle-dash-touch-home"}
export TOUCH_HOME_SCREEN_WIDTH=${TOUCH_HOME_SCREEN_WIDTH:-1072}
export TOUCH_HOME_SCREEN_HEIGHT=${TOUCH_HOME_SCREEN_HEIGHT:-1448}
export TOUCH_HOME_HOTSPOT_WIDTH_RATIO=${TOUCH_HOME_HOTSPOT_WIDTH_RATIO:-0.18}
export TOUCH_HOME_HOTSPOT_HEIGHT_RATIO=${TOUCH_HOME_HOTSPOT_HEIGHT_RATIO:-0.18}
export TOUCH_HOME_LONG_PRESS_SECONDS=${TOUCH_HOME_LONG_PRESS_SECONDS:-1.2}
# By default, partial screen updates are used to update the screen, # By default, partial screen updates are used to update the screen,
# to prevent the screen from flashing. After a few partial updates, # to prevent the screen from flashing. After a few partial updates,

View File

@@ -8,11 +8,14 @@ THEME_RUNTIME_ENV_FILE="$DIR/state/theme-runtime.env"
THEMES_INDEX_PATH="$DIR/../themes.json" THEMES_INDEX_PATH="$DIR/../themes.json"
THEME_JSON_LUA="$DIR/theme-json.lua" THEME_JSON_LUA="$DIR/theme-json.lua"
SWITCH_THEME_CMD="$DIR/../switch-theme.sh" SWITCH_THEME_CMD="$DIR/../switch-theme.sh"
STOP_DASHBOARD_CMD="$DIR/../stop.sh"
STATE_DIR="$DIR/state" STATE_DIR="$DIR/state"
MENU_ITEMS_FILE="$STATE_DIR/theme-menu-items.tsv" MENU_ITEMS_FILE="$STATE_DIR/theme-menu-items.tsv"
ACTION_FIFO="$STATE_DIR/theme-menu-actions.fifo" RUNTIME_DIR_DEFAULT="/tmp/kindle-dash-theme-menu"
EVENT_DEVICE_DEFAULT="/dev/input/event2" EVENT_DEVICE_DEFAULT="/dev/input/event2"
THEME_MENU_COMBO_WINDOW_SECONDS_DEFAULT="0.35" THEME_MENU_COMBO_WINDOW_SECONDS_DEFAULT="0.35"
HOME_MENU_ITEM_ID="__return_home__"
HOME_MENU_ITEM_LABEL="Return Home"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE" [ -f "$ENV_FILE" ] && . "$ENV_FILE"
@@ -23,12 +26,59 @@ THEME_MENU_COMBO_WINDOW_SECONDS_DEFAULT="0.35"
EVENT_DEVICE=${THEME_MENU_EVENT_DEVICE:-$EVENT_DEVICE_DEFAULT} 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} THEME_MENU_COMBO_WINDOW_SECONDS=${THEME_MENU_COMBO_WINDOW_SECONDS:-$THEME_MENU_COMBO_WINDOW_SECONDS_DEFAULT}
RUNTIME_DIR=${THEME_MENU_RUNTIME_DIR:-$RUNTIME_DIR_DEFAULT}
ACTION_FIFO="$RUNTIME_DIR/theme-menu-actions.fifo"
PID_FILE="$RUNTIME_DIR/theme-menu-service.pid"
menu_open=false menu_open=false
selected_index=1 selected_index=1
current_theme_id=${THEME_ID:-default} current_theme_id=${THEME_ID:-default}
current_orientation=${ORIENTATION:-portrait} current_orientation=${ORIENTATION:-portrait}
stream_pid="" stream_pid=""
owns_runtime_state=false
log_event() {
# 调试阶段把状态机动作写进日志,便于确认按键是否真的被识别到。
printf '%s theme-menu: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2
}
service_running() {
if [ ! -f "$PID_FILE" ]; then
return 1
fi
existing_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -z "$existing_pid" ]; then
return 1
fi
kill -0 "$existing_pid" 2>/dev/null
}
send_action_to_service() {
requested_action=$1
attempts=0
if ! service_running; then
printf 'theme-menu-service not running\n' >&2
return 1
fi
# action fifo 由主监听循环持有;正常情况下会一直存在。
# 这里保守重试几次兼容监听器刚重启、fifo 正在重建的瞬间。
while [ "$attempts" -lt 3 ]; do
if [ -p "$ACTION_FIFO" ]; then
printf '%s\n' "$requested_action" >"$ACTION_FIFO"
return 0
fi
attempts=$((attempts + 1))
sleep 1
done
printf 'theme-menu-service action fifo not ready\n' >&2
return 1
}
load_runtime() { load_runtime() {
# 每次打开菜单前都重新读取当前主题和方向,避免显示过期状态。 # 每次打开菜单前都重新读取当前主题和方向,避免显示过期状态。
@@ -44,6 +94,8 @@ load_runtime() {
load_menu_items() { load_menu_items() {
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
lua "$THEME_JSON_LUA" list "$THEMES_INDEX_PATH" >"$MENU_ITEMS_FILE" lua "$THEME_JSON_LUA" list "$THEMES_INDEX_PATH" >"$MENU_ITEMS_FILE"
# 在主题列表末尾追加一个动作项,用于安全退出 dashboard 并恢复 Kindle 首页。
printf '%s\t%s\n' "$HOME_MENU_ITEM_ID" "$HOME_MENU_ITEM_LABEL" >>"$MENU_ITEMS_FILE"
} }
theme_count() { theme_count() {
@@ -100,18 +152,25 @@ render_menu() {
theme_label=$(theme_field "$index" 2) theme_label=$(theme_field "$index" 2)
theme_id=$(theme_field "$index" 1) theme_id=$(theme_field "$index" 1)
prefix=" " prefix=" "
line_text=""
if [ "$index" -eq "$selected_index" ]; then if [ "$index" -eq "$selected_index" ]; then
prefix="> " prefix="> "
fi fi
print_line 3 "$row" "${prefix}${theme_label} (${theme_id})" if [ "$theme_id" = "$HOME_MENU_ITEM_ID" ]; then
line_text="${prefix}${theme_label}"
else
line_text="${prefix}${theme_label} (${theme_id})"
fi
print_line 3 "$row" "$line_text"
row=$((row + 2)) row=$((row + 2))
index=$((index + 1)) index=$((index + 1))
done done
print_line 3 18 "PageUp/PageDown: move" print_line 3 18 "PageUp/PageDown: move"
print_line 3 20 "Press both keys: apply" print_line 3 20 "Press both keys: select"
} }
wrap_index() { wrap_index() {
@@ -141,9 +200,18 @@ apply_selection() {
selected_theme_id=$(theme_field "$selected_index" 1) selected_theme_id=$(theme_field "$selected_index" 1)
/usr/sbin/eips -c /usr/sbin/eips -c
if [ "$selected_theme_id" = "$HOME_MENU_ITEM_ID" ]; then
log_event "apply return_home"
print_line 3 5 "Returning home..."
print_line 3 7 "Restoring framework/webreader"
menu_open=false
"$STOP_DASHBOARD_CMD"
return
fi
log_event "apply theme=$selected_theme_id orientation=$current_orientation"
print_line 3 5 "Applying theme..." print_line 3 5 "Applying theme..."
print_line 3 7 "$selected_theme_id / $current_orientation" print_line 3 7 "$selected_theme_id / $current_orientation"
"$SWITCH_THEME_CMD" "$selected_theme_id" "$current_orientation" "$SWITCH_THEME_CMD" "$selected_theme_id" "$current_orientation"
menu_open=false menu_open=false
} }
@@ -153,13 +221,23 @@ open_menu() {
load_menu_items load_menu_items
selected_index=$(current_theme_index) selected_index=$(current_theme_index)
menu_open=true menu_open=true
log_event "open_menu theme=$current_theme_id orientation=$current_orientation selected_index=$selected_index"
render_menu render_menu
} }
handle_action() { handle_action() {
action=$1 action=$1
log_event "action=$action menu_open=$menu_open"
case "$action" in case "$action" in
open_menu)
if [ "$menu_open" = true ]; then
# 触摸热区重复触发时,不直接确认当前选项,只重画当前菜单。
render_menu
else
open_menu
fi
;;
combo) combo)
if [ "$menu_open" = true ]; then if [ "$menu_open" = true ]; then
apply_selection apply_selection
@@ -247,11 +325,42 @@ cleanup() {
kill "$stream_pid" 2>/dev/null || true kill "$stream_pid" 2>/dev/null || true
fi fi
if [ "$owns_runtime_state" != true ]; then
return
fi
rm -f "$ACTION_FIFO" rm -f "$ACTION_FIFO"
rm -f "$PID_FILE"
} }
ensure_single_instance() {
if [ -f "$PID_FILE" ]; then
existing_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then
log_event "service_already_running pid=$existing_pid"
exit 0
fi
fi
printf '%s\n' "$$" >"$PID_FILE"
owns_runtime_state=true
}
if [ "${1:-}" = "trigger" ]; then
if [ "$#" -ne 2 ]; then
printf 'usage: %s trigger <action>\n' "$0" >&2
exit 64
fi
send_action_to_service "$2"
exit $?
fi
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
mkdir -p "$RUNTIME_DIR"
ensure_single_instance
log_event "service_started event_device=$EVENT_DEVICE runtime_dir=$RUNTIME_DIR combo_window=$THEME_MENU_COMBO_WINDOW_SECONDS"
while true; do while true; do
rm -f "$ACTION_FIFO" rm -f "$ACTION_FIFO"

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env sh
set -eu
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
ENV_FILE="$DIR/env.sh"
THEME_MENU_SERVICE_CMD="$DIR/theme-menu-service.sh"
STOP_DASHBOARD_CMD="$DIR/../stop.sh"
RUNTIME_DIR_DEFAULT="/tmp/kindle-dash-touch-home"
TOUCH_DEVICE_DEFAULT="/dev/input/event1"
SCREEN_WIDTH_DEFAULT="1072"
SCREEN_HEIGHT_DEFAULT="1448"
HOTSPOT_WIDTH_RATIO_DEFAULT="0.18"
HOTSPOT_HEIGHT_RATIO_DEFAULT="0.18"
LONG_PRESS_SECONDS_DEFAULT="1.2"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
RUNTIME_DIR=${TOUCH_HOME_RUNTIME_DIR:-$RUNTIME_DIR_DEFAULT}
PID_FILE="$RUNTIME_DIR/touch-home-service.pid"
ACTION_FIFO="$RUNTIME_DIR/touch-home-actions.fifo"
TOUCH_DEVICE=${TOUCH_HOME_EVENT_DEVICE:-$TOUCH_DEVICE_DEFAULT}
SCREEN_WIDTH=${TOUCH_HOME_SCREEN_WIDTH:-$SCREEN_WIDTH_DEFAULT}
SCREEN_HEIGHT=${TOUCH_HOME_SCREEN_HEIGHT:-$SCREEN_HEIGHT_DEFAULT}
HOTSPOT_WIDTH_RATIO=${TOUCH_HOME_HOTSPOT_WIDTH_RATIO:-$HOTSPOT_WIDTH_RATIO_DEFAULT}
HOTSPOT_HEIGHT_RATIO=${TOUCH_HOME_HOTSPOT_HEIGHT_RATIO:-$HOTSPOT_HEIGHT_RATIO_DEFAULT}
LONG_PRESS_SECONDS=${TOUCH_HOME_LONG_PRESS_SECONDS:-$LONG_PRESS_SECONDS_DEFAULT}
stream_pid=""
owns_runtime_state=false
log_event() {
# 统一把触摸热区服务日志写到 stderr交给上层 nohup 重定向到日志文件。
printf '%s touch-home: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2
}
cleanup() {
if [ -n "$stream_pid" ]; then
kill "$stream_pid" 2>/dev/null || true
fi
if [ "$owns_runtime_state" != true ]; then
return
fi
rm -f "$ACTION_FIFO"
rm -f "$PID_FILE"
}
ensure_single_instance() {
if [ -f "$PID_FILE" ]; then
existing_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then
log_event "service_already_running pid=$existing_pid"
exit 0
fi
fi
printf '%s\n' "$$" >"$PID_FILE"
owns_runtime_state=true
}
trigger_theme_menu() {
log_event "trigger_theme_menu"
# 右下角长按现在优先复用主题菜单服务,让用户先看到主题列表。
# 如果菜单服务意外没起来,再回退到旧的 stop.sh 路径,避免设备失去退出入口。
if "$THEME_MENU_SERVICE_CMD" trigger open_menu; then
return 0
fi
log_event "trigger_theme_menu_failed fallback_home"
"$STOP_DASHBOARD_CMD"
}
event_stream() {
evtest --grab "$TOUCH_DEVICE" 2>/dev/null | awk \
-v screen_width="$SCREEN_WIDTH" \
-v screen_height="$SCREEN_HEIGHT" \
-v hotspot_width_ratio="$HOTSPOT_WIDTH_RATIO" \
-v hotspot_height_ratio="$HOTSPOT_HEIGHT_RATIO" \
-v long_press_seconds="$LONG_PRESS_SECONDS" '
BEGIN {
hotspot_min_x = int(screen_width * (1 - hotspot_width_ratio))
hotspot_min_y = int(screen_height * (1 - hotspot_height_ratio))
touch_active = 0
touch_x = -1
touch_y = -1
touch_start_time = 0
already_triggered = 0
}
function extract_time(line, matched) {
matched = match(line, /time [0-9]+\.[0-9]+/)
if (matched == 0) {
return 0
}
return substr(line, RSTART + 5, RLENGTH - 5) + 0
}
function emit(name) {
print name
fflush()
}
function reset_touch() {
touch_active = 0
touch_x = -1
touch_y = -1
touch_start_time = 0
already_triggered = 0
}
{
current_time = extract_time($0)
# Voyage 上实际输出是 "code 57 (MT Tracking ID)",但不同 evtest 版本
# 也可能写成 ABS_MT_TRACKING_ID这里同时兼容两种格式。
if (($0 ~ /code 57 \(MT Tracking ID\)/ || $0 ~ /ABS_MT_TRACKING_ID/) && $0 ~ /value -1/) {
reset_touch()
next
}
if ($0 ~ /code 57 \(MT Tracking ID\)/ || $0 ~ /ABS_MT_TRACKING_ID/) {
touch_active = 1
touch_start_time = current_time
already_triggered = 0
next
}
if ($0 ~ /code 53 \(MT X\)/ || $0 ~ /ABS_MT_POSITION_X/) {
if (match($0, /value -?[0-9]+/)) {
touch_x = substr($0, RSTART + 6, RLENGTH - 6) + 0
}
next
}
if ($0 ~ /code 54 \(MT Y\)/ || $0 ~ /ABS_MT_POSITION_Y/) {
if (match($0, /value -?[0-9]+/)) {
touch_y = substr($0, RSTART + 6, RLENGTH - 6) + 0
}
next
}
if ($0 ~ /Report Sync/ || $0 ~ /SYN_REPORT/) {
if (touch_active && !already_triggered && touch_x >= hotspot_min_x && touch_y >= hotspot_min_y) {
if (current_time - touch_start_time >= long_press_seconds) {
already_triggered = 1
emit("trigger_theme_menu")
}
}
}
}
'
}
trap cleanup EXIT INT TERM
mkdir -p "$RUNTIME_DIR"
ensure_single_instance
log_event "service_started touch_device=$TOUCH_DEVICE long_press_seconds=$LONG_PRESS_SECONDS hotspot_width_ratio=$HOTSPOT_WIDTH_RATIO hotspot_height_ratio=$HOTSPOT_HEIGHT_RATIO"
while true; do
rm -f "$ACTION_FIFO"
mkfifo "$ACTION_FIFO"
event_stream >"$ACTION_FIFO" &
stream_pid=$!
while IFS= read -r action; do
case "$action" in
trigger_theme_menu)
trigger_theme_menu
;;
esac
done <"$ACTION_FIFO"
wait "$stream_pid" 2>/dev/null || true
stream_pid=""
rm -f "$ACTION_FIFO"
sleep 1
done

View File

@@ -18,6 +18,11 @@ if [ "$DEBUG" = true ]; then
"$DIR/dash.sh" "$DIR/dash.sh"
else else
# 通过 SSH 或 KUAL 触发时,父 shell 很快就会退出。 # 通过 SSH 或 KUAL 触发时,父 shell 很快就会退出。
# 这里必须用 nohup 脱离会话,否则后台的 dash.sh 会跟着收到 HUP 退出。 # 用 nohup 仍可能残留在同一个 session/group 里KUAL/framework 切换时
# 依然可能把后台子进程一起打掉;这里额外用 setsid 彻底脱离会话。
if command -v setsid >/dev/null 2>&1; then
nohup setsid "$DIR/dash.sh" >>"$LOG_FILE" 2>&1 </dev/null &
else
nohup "$DIR/dash.sh" >>"$LOG_FILE" 2>&1 </dev/null & nohup "$DIR/dash.sh" >>"$LOG_FILE" 2>&1 </dev/null &
fi fi
fi

View File

@@ -1,19 +1,78 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -eu set -eu
# 退出 dashboard 时,不能只“启动一下 framework”就结束。 DIR="$(dirname "$0")"
# Voyage 5.13.6 上白屏往往来自 framework / webreader / cvm 半恢复状态: ENV_FILE="$DIR/local/env.sh"
# 进程看起来在,但前台 Java UI 实际没起来。
# 这里统一做一次干净的 UI 栈重启,尽量把设备拉回正常首页/KUAL 可用状态。 # 退出 dashboard 时,统一走一条保守恢复路径:
# 1. 停掉 dashboard 自己的进程
# 2. 干净重启 framework / webreader / cvm
#
# Voyage 5.13.6 上试过的“快切换”路径会把 blanket/cvm 打崩,
# 当前这台机型仍以稳定恢复优先。
START_BIN=/sbin/start
STOP_BIN=/sbin/stop
STATUS_BIN=/sbin/status
INITCTL_BIN=/sbin/initctl
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
KEEP_NATIVE_UI_STACK_RUNNING=${KEEP_NATIVE_UI_STACK_RUNNING:-false}
KUAL_APP_ID=${KUAL_APP_ID:-app://com.mobileread.ixtab.kindlelauncher}
run_job_cmd() {
bin_path=$1
shift
if [ -x "$bin_path" ]; then
"$bin_path" "$@" >/dev/null 2>&1
return $?
fi
return 127
}
job_running() {
job_name=$1
job_state=$(
if [ -x "$STATUS_BIN" ]; then
"$STATUS_BIN" "$job_name" 2>/dev/null || true
elif [ -x "$INITCTL_BIN" ]; then
"$INITCTL_BIN" status "$job_name" 2>/dev/null || true
else
true
fi
)
case "$job_state" in
*"start/running"*)
return 0
;;
esac
return 1
}
stop_job() { stop_job() {
job_name=$1 job_name=$1
stop "$job_name" >/dev/null 2>&1 || initctl stop "$job_name" >/dev/null 2>&1 || true
if ! job_running "$job_name"; then
return 0
fi
run_job_cmd "$STOP_BIN" "$job_name" || run_job_cmd "$INITCTL_BIN" stop "$job_name" || true
} }
start_job() { start_job() {
job_name=$1 job_name=$1
start "$job_name" >/dev/null 2>&1 || initctl start "$job_name" >/dev/null 2>&1 || true
if job_running "$job_name"; then
return 0
fi
run_job_cmd "$START_BIN" "$job_name" || run_job_cmd "$INITCTL_BIN" start "$job_name" || true
} }
wait_for_cvm() { wait_for_cvm() {
@@ -30,12 +89,35 @@ wait_for_cvm() {
return 1 return 1
} }
pkill -f dash.sh 2>/dev/null || true ensure_job_stable_running() {
pkill -f start.sh 2>/dev/null || true job_name=$1
pkill -f theme-menu-service.sh 2>/dev/null || true max_attempts=$2
stable_required=$3
attempts=0
stable_hits=0
lipc-set-prop com.lab126.powerd preventScreenSaver 0 2>/dev/null || true # Voyage 上 webreader 经常会出现“刚被拉起1-2 秒后又掉回 stop/waiting”的状态。
# 这里不再只发一次 start而是循环观察一段时间直到连续多次都看到 start/running
# 才把恢复流程视为成功。
while [ "$attempts" -lt "$max_attempts" ]; do
if job_running "$job_name"; then
stable_hits=$((stable_hits + 1))
if [ "$stable_hits" -ge "$stable_required" ]; then
return 0
fi
else
stable_hits=0
start_job "$job_name"
fi
attempts=$((attempts + 1))
sleep 1
done
return 1
}
full_restore_ui() {
# 先把残留 UI 栈彻底停干净,避免 webreader 存活但 cvm 已崩的白屏状态。 # 先把残留 UI 栈彻底停干净,避免 webreader 存活但 cvm 已崩的白屏状态。
stop_job webreader stop_job webreader
stop_job framework stop_job framework
@@ -53,5 +135,53 @@ if ! wait_for_cvm; then
echo "警告framework 已请求启动,但 cvm 未在预期时间内出现。" echo "警告framework 已请求启动,但 cvm 未在预期时间内出现。"
fi fi
start_job webreader if ! ensure_job_stable_running webreader 12 3; then
sleep 2 echo "警告webreader 未能稳定恢复,可能仍需手工执行 /sbin/start webreader。"
fi
}
stop_dashboard_processes() {
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
pkill -f touch-home-service.sh 2>/dev/null || true
lipc-set-prop com.lab126.powerd preventScreenSaver 0 2>/dev/null || true
}
native_active_app() {
if ! command -v lipc-get-prop >/dev/null 2>&1; then
return 0
fi
lipc-get-prop com.lab126.appmgrd activeApp 2>/dev/null || true
}
return_to_home_from_kual() {
active_app=$(native_active_app)
case "$active_app" in
com.mobileread.ixtab.kindlelauncher|app://com.mobileread.ixtab.kindlelauncher)
# 保留原生 UI 栈时dashboard 底下通常仍是 KUAL。
# 这里复用 launch-from-kual 已验证过的 appmgrd stop 路径,把 KUAL 正常退回首页。
if command -v lipc-set-prop >/dev/null 2>&1; then
lipc-set-prop com.lab126.appmgrd stop "$KUAL_APP_ID" >/dev/null 2>&1 || true
sleep 1
fi
;;
esac
}
soft_stop_for_native_ui_overlay() {
# 原生 UI 栈保持存活时,退出 dashboard 只需要停掉我们自己的覆盖进程,
# 不要再重建 framework/webreader/cvm否则会重新引入旧架构的切换风险。
stop_dashboard_processes
return_to_home_from_kual
}
if [ "$KEEP_NATIVE_UI_STACK_RUNNING" = true ]; then
soft_stop_for_native_ui_overlay
exit 0
fi
stop_dashboard_processes
full_restore_ui

View File

@@ -1,122 +0,0 @@
#!/usr/bin/env sh
DEBUG=${DEBUG:-false}
[ "$DEBUG" = true ] && set -x
DIR="$(dirname "$0")"
DASH_PNG="$DIR/dash.png"
FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh"
LOW_BATTERY_CMD="$DIR/local/low-battery.sh"
REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"}
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
SLEEP_SCREEN_INTERVAL=${SLEEP_SCREEN_INTERVAL:-3600}
RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable
LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
LOW_BATTERY_THRESHOLD_PERCENT=${LOW_BATTERY_THRESHOLD_PERCENT:-10}
num_refresh=0
init() {
if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then
echo "Missing required configuration."
echo "Timezone: ${TIMEZONE:-(not set)}."
echo "Schedule: ${REFRESH_SCHEDULE:-(not set)}."
exit 1
fi
echo "Starting dashboard with $REFRESH_SCHEDULE refresh..."
/etc/init.d/framework stop
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
}
prepare_sleep() {
echo "Preparing sleep"
/usr/sbin/eips -f -g "$DIR/sleeping.png"
# Give screen time to refresh
sleep 2
# Ensure a full screen refresh is triggered after wake from sleep
num_refresh=$FULL_DISPLAY_REFRESH_RATE
}
refresh_dashboard() {
echo "Refreshing dashboard"
"$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP"
"$FETCH_DASHBOARD_CMD" "$DASH_PNG"
fetch_status=$?
if [ "$fetch_status" -ne 0 ]; then
echo "Not updating screen, fetch-dashboard returned $fetch_status"
return 1
fi
if [ "$num_refresh" -eq "$FULL_DISPLAY_REFRESH_RATE" ]; then
num_refresh=0
# trigger a full refresh once in every 4 refreshes, to keep the screen clean
echo "Full screen refresh"
/usr/sbin/eips -f -g "$DASH_PNG"
else
echo "Partial screen refresh"
/usr/sbin/eips -g "$DASH_PNG"
fi
num_refresh=$((num_refresh + 1))
}
log_battery_stats() {
battery_level=$(gasgauge-info -c)
echo "$(date) Battery level: $battery_level."
if [ "$LOW_BATTERY_REPORTING" = true ]; then
battery_level_numeric=${battery_level%?}
if [ "$battery_level_numeric" -le "$LOW_BATTERY_THRESHOLD_PERCENT" ]; then
"$LOW_BATTERY_CMD" "$battery_level_numeric"
fi
fi
}
rtc_sleep() {
duration=$1
if [ "$DEBUG" = true ]; then
sleep "$duration"
else
# shellcheck disable=SC2039
[ "$(cat "$RTC")" -eq 0 ] && echo -n "$duration" >"$RTC"
echo "mem" >/sys/power/state
fi
}
main_loop() {
while true; do
log_battery_stats
next_wakeup_secs=$("$DIR/next-wakeup" --schedule="$REFRESH_SCHEDULE" --timezone="$TIMEZONE")
if [ "$next_wakeup_secs" -gt "$SLEEP_SCREEN_INTERVAL" ]; then
action="sleep"
prepare_sleep
else
action="suspend"
refresh_dashboard
fi
# take a bit of time before going to sleep, so this process can be aborted
sleep 10
echo "Going to $action, next wakeup in ${next_wakeup_secs}s"
rtc_sleep "$next_wakeup_secs"
done
}
init
main_loop

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env sh
# 持久关闭调试模式:修改 env.sh恢复正常的省电挂起行为。
set -eu
DIR="$(dirname "$0")"
ENV_FILE="$DIR/local/env.sh"
TMP_FILE="$DIR/local/env.sh.tmp"
if [ ! -f "$ENV_FILE" ]; then
echo "未找到配置文件:$ENV_FILE"
exit 1
fi
# 只替换目标配置,避免重复追加同一环境变量。
awk '
BEGIN {
updated = 0
}
/^export DISABLE_SYSTEM_SUSPEND=/ {
print "export DISABLE_SYSTEM_SUSPEND=false"
updated = 1
next
}
{
print
}
END {
if (!updated) {
print "export DISABLE_SYSTEM_SUSPEND=false"
}
}
' "$ENV_FILE" > "$TMP_FILE"
mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 避免旧进程继续按旧配置运行。
pkill -f "$DIR/dash.sh" 2>/dev/null || true
echo "已关闭 Dashboard 调试模式。当前 Dashboard 已停止,请重新启动 Kindle Dashboard。"

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env sh
# 持久开启调试模式:修改 env.sh让后续普通启动也不进入系统挂起。
set -eu
DIR="$(dirname "$0")"
ENV_FILE="$DIR/local/env.sh"
TMP_FILE="$DIR/local/env.sh.tmp"
if [ ! -f "$ENV_FILE" ]; then
echo "未找到配置文件:$ENV_FILE"
exit 1
fi
# 只替换目标配置,避免重复追加同一环境变量。
awk '
BEGIN {
updated = 0
}
/^export DISABLE_SYSTEM_SUSPEND=/ {
print "export DISABLE_SYSTEM_SUSPEND=true"
updated = 1
next
}
{
print
}
END {
if (!updated) {
print "export DISABLE_SYSTEM_SUSPEND=true"
}
}
' "$ENV_FILE" > "$TMP_FILE"
mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 避免旧进程继续按旧配置进入系统挂起。
pkill -f "$DIR/dash.sh" 2>/dev/null || true
echo "已开启 Dashboard 调试模式。当前 Dashboard 已停止,请重新启动 Kindle Dashboard。"

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env sh
# Export environment variables here
export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1}
# 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。
export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"}
export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
# 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).
# 测试阶段强制每次都做一次全刷,避免首页残影和局部刷新的旧内容干扰验证。
# 等图片尺寸与刷新逻辑确认无误后,再改回 4 之类的值以节省功耗。
export FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
# When the time until the next wakeup is greater or equal to this number,
# the dashboard will not be refreshed anymore, but instead show a
# 'kindle is sleeping' screen. This can be useful if your schedule only runs
# during the day, for example.
export SLEEP_SCREEN_INTERVAL=3600
export LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
export LOW_BATTERY_THRESHOLD_PERCENT=10

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env sh
# Fetch a new dashboard image, make sure to output it to "$1".
# For example:
"$(dirname "$0")/../xh" -d -q -o "$1" get https://raw.githubusercontent.com/pascalw/kindle-dash/master/example/example.png

View File

@@ -1,17 +0,0 @@
#!/usr/bin/env sh
battery_level_percentage=$1
last_battery_report_state="$(dirname "$0")/state/last_battery_report"
previous_report_timestamp=$(cat "$last_battery_report_state" 2>/dev/null || echo '-1')
now=$(date +%s)
# Implement desired logic here. The example below for example only reports low
# battery every 24 hours.
if [ "$previous_report_timestamp" -eq -1 ] ||
[ $((now - previous_report_timestamp)) -gt 86400 ]; then
# Replace this with for example an HTTP call via curl, or xh
echo "Reporting low battery: $battery_level_percentage%"
echo "$now" >"$last_battery_report_state"
fi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env sh
# 调试启动脚本:强制关闭系统挂起,便于在 Kindle 上持续观察刷新效果。
DIR="$(dirname "$0")"
export DISABLE_SYSTEM_SUSPEND=true
exec "$DIR/start.sh"

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env sh
DEBUG=${DEBUG:-false}
[ "$DEBUG" = true ] && set -x
DIR="$(dirname "$0")"
ENV_FILE="$DIR/local/env.sh"
LOG_FILE="$DIR/logs/dash.log"
mkdir -p "$(dirname "$LOG_FILE")"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
if [ "$DEBUG" = true ]; then
"$DIR/dash.sh"
else
"$DIR/dash.sh" >>"$LOG_FILE" 2>&1 &
fi

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env sh
pkill -f dash.sh

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env sh
test_ip=$1
if [ -z "$test_ip" ]; then
echo "No test ip specified"
exit 1
fi
wait_for_wifi() {
max_retry=30
counter=0
ping -c 1 "$test_ip" >/dev/null 2>&1
# shellcheck disable=SC2181
while [ $? -ne 0 ]; do
[ $counter -eq $max_retry ] && echo "Couldn't connect to Wi-Fi" && exit 1
counter=$((counter + 1))
sleep 1
ping -c 1 "$test_ip" >/dev/null 2>&1
done
}
wait_for_wifi
echo "Wi-Fi connected"

Binary file not shown.

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension>
<information>
<name>Kindle dashboard</name>
<id>pascalw-kindle-dash</id>
</information>
<menus>
<menu type="json" dynamic="true">menu.json</menu>
</menus>
</extension>

View File

@@ -1,7 +0,0 @@
{
"items": [
{"name": "Kindle Dashboard", "action": "/mnt/us/dashboard/start.sh"},
{"name": "Dashboard Debug On", "action": "/mnt/us/dashboard/debug-on.sh"},
{"name": "Dashboard Debug Off", "action": "/mnt/us/dashboard/debug-off.sh"}
]
}

View File

@@ -1,122 +0,0 @@
#!/usr/bin/env sh
DEBUG=${DEBUG:-false}
[ "$DEBUG" = true ] && set -x
DIR="$(dirname "$0")"
DASH_PNG="$DIR/dash.png"
FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh"
LOW_BATTERY_CMD="$DIR/local/low-battery.sh"
REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"}
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
SLEEP_SCREEN_INTERVAL=${SLEEP_SCREEN_INTERVAL:-3600}
RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable
LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
LOW_BATTERY_THRESHOLD_PERCENT=${LOW_BATTERY_THRESHOLD_PERCENT:-10}
num_refresh=0
init() {
if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then
echo "Missing required configuration."
echo "Timezone: ${TIMEZONE:-(not set)}."
echo "Schedule: ${REFRESH_SCHEDULE:-(not set)}."
exit 1
fi
echo "Starting dashboard with $REFRESH_SCHEDULE refresh..."
/etc/init.d/framework stop
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
}
prepare_sleep() {
echo "Preparing sleep"
/usr/sbin/eips -f -g "$DIR/sleeping.png"
# Give screen time to refresh
sleep 2
# Ensure a full screen refresh is triggered after wake from sleep
num_refresh=$FULL_DISPLAY_REFRESH_RATE
}
refresh_dashboard() {
echo "Refreshing dashboard"
"$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP"
"$FETCH_DASHBOARD_CMD" "$DASH_PNG"
fetch_status=$?
if [ "$fetch_status" -ne 0 ]; then
echo "Not updating screen, fetch-dashboard returned $fetch_status"
return 1
fi
if [ "$num_refresh" -eq "$FULL_DISPLAY_REFRESH_RATE" ]; then
num_refresh=0
# trigger a full refresh once in every 4 refreshes, to keep the screen clean
echo "Full screen refresh"
/usr/sbin/eips -f -g "$DASH_PNG"
else
echo "Partial screen refresh"
/usr/sbin/eips -g "$DASH_PNG"
fi
num_refresh=$((num_refresh + 1))
}
log_battery_stats() {
battery_level=$(gasgauge-info -c)
echo "$(date) Battery level: $battery_level."
if [ "$LOW_BATTERY_REPORTING" = true ]; then
battery_level_numeric=${battery_level%?}
if [ "$battery_level_numeric" -le "$LOW_BATTERY_THRESHOLD_PERCENT" ]; then
"$LOW_BATTERY_CMD" "$battery_level_numeric"
fi
fi
}
rtc_sleep() {
duration=$1
if [ "$DEBUG" = true ]; then
sleep "$duration"
else
# shellcheck disable=SC2039
[ "$(cat "$RTC")" -eq 0 ] && echo -n "$duration" >"$RTC"
echo "mem" >/sys/power/state
fi
}
main_loop() {
while true; do
log_battery_stats
next_wakeup_secs=$("$DIR/next-wakeup" --schedule="$REFRESH_SCHEDULE" --timezone="$TIMEZONE")
if [ "$next_wakeup_secs" -gt "$SLEEP_SCREEN_INTERVAL" ]; then
action="sleep"
prepare_sleep
else
action="suspend"
refresh_dashboard
fi
# take a bit of time before going to sleep, so this process can be aborted
sleep 10
echo "Going to $action, next wakeup in ${next_wakeup_secs}s"
rtc_sleep "$next_wakeup_secs"
done
}
init
main_loop

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env sh
# 持久关闭调试模式:修改 env.sh恢复正常的省电挂起行为。
set -eu
DIR="$(dirname "$0")"
ENV_FILE="$DIR/local/env.sh"
TMP_FILE="$DIR/local/env.sh.tmp"
if [ ! -f "$ENV_FILE" ]; then
echo "未找到配置文件:$ENV_FILE"
exit 1
fi
# 只替换目标配置,避免重复追加同一环境变量。
awk '
BEGIN {
updated = 0
}
/^export DISABLE_SYSTEM_SUSPEND=/ {
print "export DISABLE_SYSTEM_SUSPEND=false"
updated = 1
next
}
{
print
}
END {
if (!updated) {
print "export DISABLE_SYSTEM_SUSPEND=false"
}
}
' "$ENV_FILE" > "$TMP_FILE"
mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 避免旧进程继续按旧配置运行。
pkill -f "$DIR/dash.sh" 2>/dev/null || true
echo "已关闭 Dashboard 调试模式。当前 Dashboard 已停止,请重新启动 Kindle Dashboard。"

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env sh
# 持久开启调试模式:修改 env.sh让后续普通启动也不进入系统挂起。
set -eu
DIR="$(dirname "$0")"
ENV_FILE="$DIR/local/env.sh"
TMP_FILE="$DIR/local/env.sh.tmp"
if [ ! -f "$ENV_FILE" ]; then
echo "未找到配置文件:$ENV_FILE"
exit 1
fi
# 只替换目标配置,避免重复追加同一环境变量。
awk '
BEGIN {
updated = 0
}
/^export DISABLE_SYSTEM_SUSPEND=/ {
print "export DISABLE_SYSTEM_SUSPEND=true"
updated = 1
next
}
{
print
}
END {
if (!updated) {
print "export DISABLE_SYSTEM_SUSPEND=true"
}
}
' "$ENV_FILE" > "$TMP_FILE"
mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 避免旧进程继续按旧配置进入系统挂起。
pkill -f "$DIR/dash.sh" 2>/dev/null || true
echo "已开启 Dashboard 调试模式。当前 Dashboard 已停止,请重新启动 Kindle Dashboard。"

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env sh
# Export environment variables here
export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1}
# 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。
export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"}
export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
# 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).
# 测试阶段强制每次都做一次全刷,避免首页残影和局部刷新的旧内容干扰验证。
# 等图片尺寸与刷新逻辑确认无误后,再改回 4 之类的值以节省功耗。
export FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
# When the time until the next wakeup is greater or equal to this number,
# the dashboard will not be refreshed anymore, but instead show a
# 'kindle is sleeping' screen. This can be useful if your schedule only runs
# during the day, for example.
export SLEEP_SCREEN_INTERVAL=3600
export LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
export LOW_BATTERY_THRESHOLD_PERCENT=10

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env sh
# Fetch a new dashboard image, make sure to output it to "$1".
# For example:
"$(dirname "$0")/../xh" -d -q -o "$1" get https://raw.githubusercontent.com/pascalw/kindle-dash/master/example/example.png

View File

@@ -1,17 +0,0 @@
#!/usr/bin/env sh
battery_level_percentage=$1
last_battery_report_state="$(dirname "$0")/state/last_battery_report"
previous_report_timestamp=$(cat "$last_battery_report_state" 2>/dev/null || echo '-1')
now=$(date +%s)
# Implement desired logic here. The example below for example only reports low
# battery every 24 hours.
if [ "$previous_report_timestamp" -eq -1 ] ||
[ $((now - previous_report_timestamp)) -gt 86400 ]; then
# Replace this with for example an HTTP call via curl, or xh
echo "Reporting low battery: $battery_level_percentage%"
echo "$now" >"$last_battery_report_state"
fi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env sh
# 调试启动脚本:强制关闭系统挂起,便于在 Kindle 上持续观察刷新效果。
DIR="$(dirname "$0")"
export DISABLE_SYSTEM_SUSPEND=true
exec "$DIR/start.sh"

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env sh
DEBUG=${DEBUG:-false}
[ "$DEBUG" = true ] && set -x
DIR="$(dirname "$0")"
ENV_FILE="$DIR/local/env.sh"
LOG_FILE="$DIR/logs/dash.log"
mkdir -p "$(dirname "$LOG_FILE")"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
if [ "$DEBUG" = true ]; then
"$DIR/dash.sh"
else
"$DIR/dash.sh" >>"$LOG_FILE" 2>&1 &
fi

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env sh
pkill -f dash.sh

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env sh
test_ip=$1
if [ -z "$test_ip" ]; then
echo "No test ip specified"
exit 1
fi
wait_for_wifi() {
max_retry=30
counter=0
ping -c 1 "$test_ip" >/dev/null 2>&1
# shellcheck disable=SC2181
while [ $? -ne 0 ]; do
[ $counter -eq $max_retry ] && echo "Couldn't connect to Wi-Fi" && exit 1
counter=$((counter + 1))
sleep 1
ping -c 1 "$test_ip" >/dev/null 2>&1
done
}
wait_for_wifi
echo "Wi-Fi connected"

Binary file not shown.

View File

@@ -0,0 +1,31 @@
# KTerm 安装包放置说明
如果你要让 `bootstrap-new-kindle.sh``prepare-storage` 阶段一并预置 `KTerm`,请把官方 `KTerm` release 的安装包 `.zip` 放到这个目录。
例如:
```text
dash/staging/kterm/kterm-kindle-*.zip
```
脚本会自动尝试拾取这个目录下的第一个 `.zip` 文件,并直接解压到 Kindle 的 `extensions/`
如果你不想放在仓库里,也可以执行时显式指定:
```sh
sh bootstrap-new-kindle.sh prepare-storage --kterm-package /绝对路径/kterm-kindle-*.zip
```
如果你希望脚本直接在 Mac 侧联网下载,也可以:
```sh
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version latest
```
或固定版本:
```sh
sh bootstrap-new-kindle.sh prepare-storage --download-kterm --kterm-version v2.6
```
下载后的 `.zip` 也会缓存到这个目录里,后续再次执行时可直接复用。

View File

@@ -3,19 +3,78 @@ DEBUG=${DEBUG:-false}
[ "$DEBUG" = true ] && set -x [ "$DEBUG" = true ] && set -x
DIR="$(dirname "$0")" DIR="$(dirname "$0")"
DASH_PNG="$DIR/dash.png" BACKGROUND_PNG="$DIR/kindlebg.png"
FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh" FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.sh"
LOW_BATTERY_CMD="$DIR/local/low-battery.sh" 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"
TOUCH_HOME_SERVICE_CMD="$DIR/local/touch-home-service.sh"
TOUCH_HOME_LOG_FILE="$DIR/logs/touch-home.log"
REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"} REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"2,32 8-17 * * MON-FRI"}
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0} FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
SLEEP_SCREEN_INTERVAL=${SLEEP_SCREEN_INTERVAL:-3600} SLEEP_SCREEN_INTERVAL=${SLEEP_SCREEN_INTERVAL:-3600}
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}
STATUS_MASK_WIDTH=${STATUS_MASK_WIDTH:-372}
STATUS_MASK_HEIGHT=${STATUS_MASK_HEIGHT:-24}
STATUS_MASK_PASSES=${STATUS_MASK_PASSES:-3}
STATUS_MASK_DELAY_SECONDS=${STATUS_MASK_DELAY_SECONDS:-1}
RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable RTC=/sys/devices/platform/mxc_rtc.0/wakeup_enable
KEEP_NATIVE_UI_STACK_RUNNING=${KEEP_NATIVE_UI_STACK_RUNNING:-false}
LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false} LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
LOW_BATTERY_THRESHOLD_PERCENT=${LOW_BATTERY_THRESHOLD_PERCENT:-10} LOW_BATTERY_THRESHOLD_PERCENT=${LOW_BATTERY_THRESHOLD_PERCENT:-10}
num_refresh=0 num_refresh=0
background_needs_redraw=true
start_theme_menu_service() {
if [ "${THEME_MENU_ENABLED:-false}" != 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 &
}
start_touch_home_service() {
if [ "${TOUCH_HOME_ENABLED:-false}" != true ]; then
return
fi
if [ ! -x "$TOUCH_HOME_SERVICE_CMD" ]; then
return
fi
mkdir -p "$(dirname "$TOUCH_HOME_LOG_FILE")"
pkill -f "$TOUCH_HOME_SERVICE_CMD" 2>/dev/null || true
nohup "$TOUCH_HOME_SERVICE_CMD" >>"$TOUCH_HOME_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() { init() {
if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then
@@ -26,17 +85,41 @@ init() {
fi fi
echo "Starting dashboard with $REFRESH_SCHEDULE refresh..." echo "Starting dashboard with $REFRESH_SCHEDULE refresh..."
mkdir -p "$STATE_DIR"
/etc/init.d/framework stop if [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
echo "System suspend disabled, using normal sleep between refreshes."
fi
if [ "$KEEP_NATIVE_UI_STACK_RUNNING" = true ]; then
echo "Keeping framework/webreader running for native UI overlay mode."
else
stop_framework
initctl stop webreader >/dev/null 2>&1 initctl stop webreader >/dev/null 2>&1
fi
echo powersave >/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor echo powersave >/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
lipc-set-prop com.lab126.powerd preventScreenSaver 1 lipc-set-prop com.lab126.powerd preventScreenSaver 1
start_theme_menu_service
start_touch_home_service
}
stop_framework() {
# 不同 Kindle 固件停止 framework 的入口不完全一致。
# Voyage 5.13.6 上没有 /etc/init.d/framework需要走 upstart 入口。
if [ -x /etc/init.d/framework ]; then
/etc/init.d/framework stop || true
return
fi
stop framework >/dev/null 2>&1 || initctl stop framework >/dev/null 2>&1 || true
} }
prepare_sleep() { prepare_sleep() {
echo "Preparing sleep" echo "Preparing sleep"
/usr/sbin/eips -f -g "$DIR/sleeping.png" /usr/sbin/eips -f -g "$DIR/sleeping.png"
background_needs_redraw=true
# Give screen time to refresh # Give screen time to refresh
sleep 2 sleep 2
@@ -45,41 +128,151 @@ prepare_sleep() {
num_refresh=$FULL_DISPLAY_REFRESH_RATE num_refresh=$FULL_DISPLAY_REFRESH_RATE
} }
refresh_dashboard() { now_epoch() {
echo "Refreshing dashboard" date '+%s'
}
background_refresh_due() {
load_theme_runtime_config
if [ ! -f "$BACKGROUND_PNG" ] || [ ! -f "$BACKGROUND_TIMESTAMP_FILE" ]; then
return 0
fi
current_epoch=$(now_epoch)
last_background_epoch=$(cat "$BACKGROUND_TIMESTAMP_FILE")
refresh_interval_seconds=$((BACKGROUND_REFRESH_INTERVAL_MINUTES * 60))
[ $((current_epoch - last_background_epoch)) -ge "$refresh_interval_seconds" ]
}
store_background_timestamp() {
now_epoch >"$BACKGROUND_TIMESTAMP_FILE"
}
fetch_background() {
echo "Refreshing background"
"$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP" "$DIR/wait-for-wifi.sh" "$WIFI_TEST_IP"
"$FETCH_DASHBOARD_CMD" "$DASH_PNG" 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=$? fetch_status=$?
if [ "$fetch_status" -ne 0 ]; then if [ "$fetch_status" -ne 0 ]; then
echo "Not updating screen, fetch-dashboard returned $fetch_status" echo "Background fetch failed with $fetch_status"
return 1 return 1
fi fi
if [ "$num_refresh" -eq "$FULL_DISPLAY_REFRESH_RATE" ]; then store_background_timestamp
num_refresh=0 return 0
}
# trigger a full refresh once in every 4 refreshes, to keep the screen clean clock_force_full_refresh() {
echo "Full screen refresh" eval "$("$DIR/local/clock-index.sh")"
/usr/sbin/eips -f -g "$DASH_PNG" [ $((minute % CLOCK_FULL_REFRESH_INTERVAL_MINUTES)) -eq 0 ]
else }
echo "Partial screen refresh"
/usr/sbin/eips -g "$DASH_PNG" mask_system_status_overlay() {
if [ "$STATUS_MASK_ENABLED" != true ]; then
return
fi fi
# Voyage 上 framework/appmgrd 偶尔会把右上角时间与状态图标重新画回屏幕。
# 这里在每次 dashboard 刷新后,用顶部空白带的白底把它盖掉。
# 实测需要延迟后再补盖一次,否则系统可能会在我们第一次覆盖后再重画一遍。
pass=1
while [ "$pass" -le "$STATUS_MASK_PASSES" ]; do
fbink -q -V -B WHITE -k \
"top=$STATUS_MASK_TOP,left=$STATUS_MASK_LEFT,width=$STATUS_MASK_WIDTH,height=$STATUS_MASK_HEIGHT"
if [ "$pass" -lt "$STATUS_MASK_PASSES" ] && [ "$STATUS_MASK_DELAY_SECONDS" -gt 0 ]; then
sleep "$STATUS_MASK_DELAY_SECONDS"
fi
pass=$((pass + 1))
done
}
refresh_dashboard() {
background_refreshed=false
if background_refresh_due; then
if fetch_background; then
background_refreshed=true
echo "Full screen refresh"
/usr/sbin/eips -f -g "$BACKGROUND_PNG"
elif [ ! -f "$BACKGROUND_PNG" ]; then
echo "No cached background available."
return 1
fi
fi
if [ "$background_refreshed" = false ] && [ ! -f "$BACKGROUND_PNG" ]; then
echo "No cached background available."
return 1
fi
if [ "$background_refreshed" = false ] && [ "$background_needs_redraw" = true ]; then
echo "Restoring cached background"
/usr/sbin/eips -f -g "$BACKGROUND_PNG"
background_needs_redraw=false
fi
if [ "$background_refreshed" = true ]; then
background_needs_redraw=false
fi
if [ "$background_refreshed" = true ] || clock_force_full_refresh; then
echo "Clock patch full refresh"
"$CLOCK_RENDER_CMD" true
else
echo "Clock patch partial refresh"
"$CLOCK_RENDER_CMD" false
fi
mask_system_status_overlay
num_refresh=$((num_refresh + 1)) num_refresh=$((num_refresh + 1))
} }
powerd_get_prop() {
prop_name=$1
if ! command -v lipc-get-prop >/dev/null 2>&1; then
echo "unavailable"
return 0
fi
lipc-get-prop com.lab126.powerd "$prop_name" 2>/dev/null || echo "unavailable"
}
log_battery_stats() { log_battery_stats() {
battery_level=$(gasgauge-info -c) battery_level=$(gasgauge-info -c 2>/dev/null || echo "unknown")
echo "$(date) Battery level: $battery_level." charging_state=$(powerd_get_prop isCharging)
battery_state_info=$(powerd_get_prop battStateInfo)
# 同时记录 powerd 的充电标志与原始电池状态,便于直接从 dash.log
# 判断 Kindle 是否识别到外部供电,以及是否真的在充电。
echo "$(date) Battery level: $battery_level. isCharging: $charging_state. battStateInfo: $battery_state_info."
if [ "$LOW_BATTERY_REPORTING" = true ]; then if [ "$LOW_BATTERY_REPORTING" = true ]; then
case "$battery_level" in
*%)
battery_level_numeric=${battery_level%?} battery_level_numeric=${battery_level%?}
if [ "$battery_level_numeric" -le "$LOW_BATTERY_THRESHOLD_PERCENT" ]; then if [ "$battery_level_numeric" -le "$LOW_BATTERY_THRESHOLD_PERCENT" ]; then
"$LOW_BATTERY_CMD" "$battery_level_numeric" "$LOW_BATTERY_CMD" "$battery_level_numeric"
fi fi
;;
esac
fi fi
} }
@@ -88,6 +281,9 @@ rtc_sleep() {
if [ "$DEBUG" = true ]; then if [ "$DEBUG" = true ]; then
sleep "$duration" sleep "$duration"
elif [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
echo "Skipping system suspend, sleeping for ${duration}s instead"
sleep "$duration"
else else
# shellcheck disable=SC2039 # shellcheck disable=SC2039
[ "$(cat "$RTC")" -eq 0 ] && echo -n "$duration" >"$RTC" [ "$(cat "$RTC")" -eq 0 ] && echo -n "$duration" >"$RTC"
@@ -95,26 +291,73 @@ rtc_sleep() {
fi 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() { main_loop() {
while true; do while true; do
log_battery_stats log_battery_stats
next_wakeup_secs=$("$DIR/next-wakeup" --schedule="$REFRESH_SCHEDULE" --timezone="$TIMEZONE") next_wakeup_secs=$("$DIR/next-wakeup" --schedule="$REFRESH_SCHEDULE" --timezone="$TIMEZONE")
if [ "$next_wakeup_secs" -gt "$SLEEP_SCREEN_INTERVAL" ]; then if [ "$next_wakeup_secs" -gt "$SLEEP_SCREEN_INTERVAL" ] && [ "$DISABLE_SYSTEM_SUSPEND" != true ]; then
action="sleep" action="sleep"
prepare_sleep prepare_sleep
else else
if [ "$next_wakeup_secs" -gt "$SLEEP_SCREEN_INTERVAL" ] && [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
echo "Debug mode active, skipping sleeping screen."
fi
action="suspend" action="suspend"
refresh_dashboard refresh_dashboard
fi fi
# take a bit of time before going to sleep, so this process can be aborted actual_sleep_secs=$next_wakeup_secs
sleep 10 if [ "$actual_sleep_secs" -gt "$PRE_SLEEP_GRACE_SECONDS" ]; then
actual_sleep_secs=$((actual_sleep_secs - PRE_SLEEP_GRACE_SECONDS))
else
actual_sleep_secs=0
fi
# 预留一小段可中断窗口,便于在 Kindle 本机或 SSH 下手动终止进程。
# 这段时间必须从 rtc_sleep 中扣掉,否则每分钟刷新会长期晚于计划时间。
sleep "$PRE_SLEEP_GRACE_SECONDS"
echo "Going to $action, next wakeup in ${next_wakeup_secs}s" echo "Going to $action, next wakeup in ${next_wakeup_secs}s"
rtc_sleep "$next_wakeup_secs" 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 done
} }

View File

@@ -35,7 +35,12 @@ END {
mv "$TMP_FILE" "$ENV_FILE" mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它 # 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 避免旧进程继续按旧配置运行 # 然后立刻拉起新的 dashboard避免用户还要再次手动启动
pkill -f "$DIR/dash.sh" 2>/dev/null || true pkill -f "$DIR/dash.sh" 2>/dev/null || true
pkill -f "$DIR/start.sh" 2>/dev/null || true
pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true
pkill -f "$DIR/local/touch-home-service.sh" 2>/dev/null || true
sleep 1
"$DIR/launch-from-kual.sh"
echo "已关闭 Dashboard 调试模式。当前 Dashboard 已停止,请重新启动 Kindle Dashboard。" echo "已关闭 Dashboard 调试模式,并自动重启 Kindle Dashboard。"

View File

@@ -35,7 +35,12 @@ END {
mv "$TMP_FILE" "$ENV_FILE" mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它 # 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 避免旧进程继续按旧配置进入系统挂起 # 然后立刻拉起新的 dashboard避免用户还要在短时间内再点一次菜单
pkill -f "$DIR/dash.sh" 2>/dev/null || true pkill -f "$DIR/dash.sh" 2>/dev/null || true
pkill -f "$DIR/start.sh" 2>/dev/null || true
pkill -f "$DIR/local/theme-menu-service.sh" 2>/dev/null || true
pkill -f "$DIR/local/touch-home-service.sh" 2>/dev/null || true
sleep 1
"$DIR/launch-from-kual.sh"
echo "已开启 Dashboard 调试模式。当前 Dashboard 已停止,请重新启动 Kindle Dashboard。" echo "已开启 Dashboard 调试模式,并自动重启 Kindle Dashboard。"

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env sh
set -eu
DIR="$(dirname "$0")"
LOG_FILE="$DIR/logs/kual-launch.log"
PID_FILE="$DIR/logs/kual-launch.pid"
# overlay 模式下,用户期望从 KUAL 点击后几乎直接进入 dashboard。
# 这里仍然要求 KUAL 先正常退出,但默认不再额外人为等待,
# 只在需要回退到更保守的切换节奏时,再通过环境变量显式加回延迟。
LAUNCH_DELAY_SECONDS="${KUAL_LAUNCH_DELAY_SECONDS:-0}"
KUAL_QUIT_GRACE_SECONDS="${KUAL_QUIT_GRACE_SECONDS:-0}"
KUAL_APP_ID="${KUAL_APP_ID:-app://com.mobileread.ixtab.kindlelauncher}"
mkdir -p "$(dirname "$LOG_FILE")"
case "$LAUNCH_DELAY_SECONDS" in
''|*[!0-9]*)
LAUNCH_DELAY_SECONDS=0
;;
esac
case "$KUAL_QUIT_GRACE_SECONDS" in
''|*[!0-9]*)
KUAL_QUIT_GRACE_SECONDS=0
;;
esac
if [ -f "$PID_FILE" ]; then
old_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "${old_pid:-}" ] && kill -0 "$old_pid" 2>/dev/null; then
kill "$old_pid" 2>/dev/null || true
sleep 1
fi
fi
# KUAL 直接同步执行 start.sh 时framework/KUAL 正在切换的那一瞬间,
# 后台 dash 进程仍可能留在同一个 session 里被一起 TERM 掉。
# 这里仍然先让独立 session 请求 KUAL 正常退出,但默认不再故意停留在原生首页,
# 而是把启动等待压到 0尽量直接切到 dashboard。
if command -v setsid >/dev/null 2>&1; then
nohup setsid /bin/sh -c '
delay=$1
target_dir=$2
kual_app_id=$3
quit_grace=$4
# 复用 KUAL 自己的 appmgrd stop 路径,尽量让它先干净退回原生 UI。
if command -v lipc-set-prop >/dev/null 2>&1; then
lipc-set-prop com.lab126.appmgrd stop "$kual_app_id" >/dev/null 2>&1 || true
fi
if [ "$quit_grace" -gt 0 ] 2>/dev/null; then
sleep "$quit_grace"
fi
if [ "$delay" -gt 0 ] 2>/dev/null; then
sleep "$delay"
fi
exec "$target_dir/start.sh"
' sh "$LAUNCH_DELAY_SECONDS" "$DIR" "$KUAL_APP_ID" "$KUAL_QUIT_GRACE_SECONDS" >>"$LOG_FILE" 2>&1 </dev/null &
else
nohup /bin/sh -c '
delay=$1
target_dir=$2
kual_app_id=$3
quit_grace=$4
# 复用 KUAL 自己的 appmgrd stop 路径,尽量让它先干净退回原生 UI。
if command -v lipc-set-prop >/dev/null 2>&1; then
lipc-set-prop com.lab126.appmgrd stop "$kual_app_id" >/dev/null 2>&1 || true
fi
if [ "$quit_grace" -gt 0 ] 2>/dev/null; then
sleep "$quit_grace"
fi
if [ "$delay" -gt 0 ] 2>/dev/null; then
sleep "$delay"
fi
exec "$target_dir/start.sh"
' sh "$LAUNCH_DELAY_SECONDS" "$DIR" "$KUAL_APP_ID" "$KUAL_QUIT_GRACE_SECONDS" >>"$LOG_FILE" 2>&1 </dev/null &
fi
echo "$!" >"$PID_FILE"
echo "$(date 2>/dev/null || true) requested KUAL quit, scheduled dashboard launch with quit_grace=${KUAL_QUIT_GRACE_SECONDS}s launch_delay=${LAUNCH_DELAY_SECONDS}s" >>"$LOG_FILE"

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env sh
set -eu
DIR="$(dirname "$0")"
SWITCH_THEME_CMD="$DIR/switch-theme.sh"
LAUNCH_FROM_KUAL_CMD="$DIR/launch-from-kual.sh"
requested_theme_id=${1:?"usage: launch-theme-from-kual.sh <theme-id> [orientation]"}
requested_orientation=${2:-}
# KUAL 里的主题入口先切主题,再复用现有的 launch-from-kual 启动链。
# 这样可以保留当前已经收敛过的 KUAL 退出与 detached 启动逻辑,
# 同时把“选主题”前移到进入 dashboard 之前。
if [ -n "$requested_orientation" ]; then
"$SWITCH_THEME_CMD" "$requested_theme_id" "$requested_orientation"
else
"$SWITCH_THEME_CMD" "$requested_theme_id"
fi
exec "$LAUNCH_FROM_KUAL_CMD"

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env sh
set -eu
to_decimal() {
printf '%s' "$1" | awk '{
sub(/^0+/, "", $0)
if ($0 == "") {
$0 = 0
}
print $0
}'
}
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() {
# 表盘显示优先走固定 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
hour_value=$1
minute_value=$2
else
set -- $(current_clock_values)
hour_value=$1
minute_value=$2
fi
hour_decimal=$(to_decimal "$hour_value")
minute_decimal=$(to_decimal "$minute_value")
hour_index=$(( (hour_decimal % 12) * 60 + minute_decimal ))
printf 'hour=%s\n' "$hour_decimal"
printf 'minute=%s\n' "$minute_decimal"
printf 'hour_index=%03d\n' "$hour_index"
printf 'minute_index=%02d\n' "$minute_decimal"

View File

@@ -4,7 +4,89 @@
export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1} export WIFI_TEST_IP=${WIFI_TEST_IP:-1.1.1.1}
# 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。 # 测试配置:全天每分钟刷新一次,便于验证图片拉取与屏幕刷新是否正常。
export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"} export REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"}
# 调度计算依赖 next-wakeup 这个 Rust 程序,它要求使用 IANA 时区名。
# 这里必须保留 Asia/Shanghai才能正确计算下一次唤醒时间。
export TIMEZONE=${TIMEZONE:-"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:-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}
export CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
# 本机时钟外观参数:
# 页面改版导致时钟区域尺寸变化时,通常只需要改 CLOCK_REGION_*
# 这组比例参数会随新的宽高自动缩放。
# 如果只是想微调指针长短、粗细或刻度长度,再改下面这些值即可,不用改 Lua 代码。
export CLOCK_FACE_RADIUS_RATIO=${CLOCK_FACE_RADIUS_RATIO:-0.47}
export CLOCK_FACE_STROKE=${CLOCK_FACE_STROKE:-3}
export CLOCK_TICK_OUTER_INSET=${CLOCK_TICK_OUTER_INSET:-6}
export CLOCK_MAJOR_TICK_LENGTH=${CLOCK_MAJOR_TICK_LENGTH:-14}
export CLOCK_MINOR_TICK_LENGTH=${CLOCK_MINOR_TICK_LENGTH:-7}
export CLOCK_MAJOR_TICK_THICKNESS=${CLOCK_MAJOR_TICK_THICKNESS:-4}
export CLOCK_MINOR_TICK_THICKNESS=${CLOCK_MINOR_TICK_THICKNESS:-2}
export CLOCK_HOUR_LENGTH_RATIO=${CLOCK_HOUR_LENGTH_RATIO:-0.48}
export CLOCK_MINUTE_LENGTH_RATIO=${CLOCK_MINUTE_LENGTH_RATIO:-0.72}
export CLOCK_HOUR_THICKNESS=${CLOCK_HOUR_THICKNESS:-9}
export CLOCK_MINUTE_THICKNESS=${CLOCK_MINUTE_THICKNESS:-5}
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、电池图标。
# 当前坐标只覆盖页面顶部空白带,不会擦到天气卡上边框。
export STATUS_MASK_ENABLED=${STATUS_MASK_ENABLED:-true}
export STATUS_MASK_LEFT=${STATUS_MASK_LEFT:-700}
export STATUS_MASK_TOP=${STATUS_MASK_TOP:-0}
export STATUS_MASK_WIDTH=${STATUS_MASK_WIDTH:-372}
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}
# 运行态主题菜单当前默认关闭,主题切换统一收口到 KUAL 入口。
# 如需恢复双翻页键菜单,可临时改回 true 做专项验证。
export THEME_MENU_ENABLED=${THEME_MENU_ENABLED:-false}
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}
# `/mnt/us` 是 FAT 分区,不能创建 FIFO菜单监听器的临时管道必须放在 `/tmp`。
export THEME_MENU_RUNTIME_DIR=${THEME_MENU_RUNTIME_DIR:-"/tmp/kindle-dash-theme-menu"}
# 默认模式:保留 Kindle 原生 UI 栈,让 dashboard 只负责画面覆盖与休眠调度。
# 这条路线已经在 Voyage 5.13.6 上实机验证通过:
# calendar -> 主页 -> KUAL -> 回主页 可正常工作。
export KEEP_NATIVE_UI_STACK_RUNNING=${KEEP_NATIVE_UI_STACK_RUNNING:-true}
# 从 KUAL 进入 dashboard 时,默认不再刻意停留在原生首页。
# 如果某台机器仍需要更保守的切换节奏,可把这两个值临时调大后再做回归验证。
export KUAL_QUIT_GRACE_SECONDS=${KUAL_QUIT_GRACE_SECONDS:-0}
export KUAL_LAUNCH_DELAY_SECONDS=${KUAL_LAUNCH_DELAY_SECONDS:-0}
# 右下角长按热区当前默认关闭,避免和底层原生桌面的触摸响应冲突。
# 如需恢复这条实验入口,可临时改回 true但当前默认只保留 KUAL 主题入口。
export TOUCH_HOME_ENABLED=${TOUCH_HOME_ENABLED:-false}
export TOUCH_HOME_EVENT_DEVICE=${TOUCH_HOME_EVENT_DEVICE:-"/dev/input/event1"}
export TOUCH_HOME_RUNTIME_DIR=${TOUCH_HOME_RUNTIME_DIR:-"/tmp/kindle-dash-touch-home"}
export TOUCH_HOME_SCREEN_WIDTH=${TOUCH_HOME_SCREEN_WIDTH:-1072}
export TOUCH_HOME_SCREEN_HEIGHT=${TOUCH_HOME_SCREEN_HEIGHT:-1448}
export TOUCH_HOME_HOTSPOT_WIDTH_RATIO=${TOUCH_HOME_HOTSPOT_WIDTH_RATIO:-0.18}
export TOUCH_HOME_HOTSPOT_HEIGHT_RATIO=${TOUCH_HOME_HOTSPOT_HEIGHT_RATIO:-0.18}
export TOUCH_HOME_LONG_PRESS_SECONDS=${TOUCH_HOME_LONG_PRESS_SECONDS:-1.2}
# By default, partial screen updates are used to update the screen, # By default, partial screen updates are used to update the screen,
# to prevent the screen from flashing. After a few partial updates, # to prevent the screen from flashing. After a few partial updates,
@@ -13,6 +95,11 @@ export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
# 等图片尺寸与刷新逻辑确认无误后,再改回 4 之类的值以节省功耗。 # 等图片尺寸与刷新逻辑确认无误后,再改回 4 之类的值以节省功耗。
export FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0} export FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
# 调试开关:设为 true 后,主循环仍会按计划拉图和刷新屏幕,
# 不会进入 sleeping.png 分支,也不会把 Kindle 写入 /sys/power/state 进入系统挂起。
# 适合通过 KUAL 或普通 start.sh 连续观察效果,调试结束后再改回 false。
export DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
# When the time until the next wakeup is greater or equal to this number, # When the time until the next wakeup is greater or equal to this number,
# the dashboard will not be refreshed anymore, but instead show a # the dashboard will not be refreshed anymore, but instead show a
# 'kindle is sleeping' screen. This can be useful if your schedule only runs # 'kindle is sleeping' screen. This can be useful if your schedule only runs

View File

@@ -1,4 +1,32 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Fetch a new dashboard image, make sure to output it to "$1". set -eu
# For example:
"$(dirname "$0")/../xh" -d -q -o "$1" get https://raw.githubusercontent.com/pascalw/kindle-dash/master/example/example.png # 拉取低频背景图,调用方负责传入输出路径。
output_path=${1:?"missing output path"}
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
ENV_FILE="$DIR/env.sh"
THEME_RUNTIME_ENV_FILE="$DIR/state/theme-runtime.env"
# 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"

View File

@@ -0,0 +1,191 @@
-- 在 Kindle 本机生成一张时钟区域位图,然后交给 fbink 刷到屏幕。
-- 这里不再依赖透明 PNG 叠图,避免 eips 处理 alpha 时出现整块发黑的问题。
local output_path = assert(arg[1], "missing output path")
local width = tonumber((assert(arg[2], "missing width")))
local height = tonumber((assert(arg[3], "missing height")))
local hour_value = tonumber((assert(arg[4], "missing hour")))
local minute_value = tonumber((assert(arg[5], "missing minute")))
local function number_arg(index, fallback)
local value = arg[index]
if value == nil or value == "" then
return fallback
end
local numeric = tonumber(value)
if numeric == nil then
return fallback
end
return numeric
end
local WHITE = 255
local BLACK = 0
local cx = (width - 1) / 2
local cy = (height - 1) / 2
-- 偶数尺寸下如果半径直接顶到边界,右侧和下侧会更容易出现裁切感。
-- 这里把描边厚度的一半让给外圈,保持四边可视留白更均匀。
local face_stroke = number_arg(7, 3)
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
pixels[index] = WHITE
end
local function pixel_index(x, y)
return y * width + x + 1
end
local function set_pixel(x, y, value)
if x < 0 or y < 0 or x >= width or y >= height then
return
end
pixels[pixel_index(x, y)] = value
end
local function fill_disk(x, y, radius, value)
local r2 = radius * radius
local min_x = math.floor(x - radius)
local max_x = math.ceil(x + radius)
local min_y = math.floor(y - radius)
local max_y = math.ceil(y + radius)
for py = min_y, max_y do
for px = min_x, max_x do
local dx = px - x
local dy = py - y
if dx * dx + dy * dy <= r2 then
set_pixel(px, py, value)
end
end
end
end
local function draw_circle(radius, thickness, value)
local samples = 720
for step = 0, samples - 1 do
local angle = (step / samples) * math.pi * 2
local x = cx + math.cos(angle) * radius
local y = cy + math.sin(angle) * radius
fill_disk(x, y, thickness / 2, value)
end
end
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 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)
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
draw_circle(face_radius, face_stroke, BLACK)
draw_ticks()
draw_hands()
local file = assert(io.open(output_path, "wb"))
file:write(string.format("P5\n%d %d\n255\n", width, height))
local char_cache = {
[WHITE] = string.char(WHITE),
[BLACK] = string.char(BLACK),
}
for y = 0, height - 1 do
local row = {}
for x = 0, width - 1 do
local value = pixels[pixel_index(x, y)]
row[#row + 1] = char_cache[value] or string.char(value)
end
file:write(table.concat(row))
end
file:close()

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env sh
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}
clock_region_width=${CLOCK_REGION_WIDTH:-220}
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"
eval "$("$DIR/clock-index.sh")"
mkdir -p "$DIR/state"
lua "$DIR/render-clock.lua" \
"$output_path" \
"$clock_region_width" \
"$clock_region_height" \
"$hour" \
"$minute" \
"$clock_face_radius_ratio" \
"$clock_face_stroke" \
"$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_rotation_degrees"
if [ "$force_full_refresh" = true ]; then
# Kindle Voyage 当前这条链路里fbink 默认会叠加 viewport 修正,
# 导致图像在屏幕上出现双重偏移。这里强制关闭 viewport 修正,
# 让坐标与网页导出的像素坐标保持一致。
fbink -q -V -f -g "file=$output_path,x=$clock_region_x,y=$clock_region_y"
else
fbink -q -V -g "file=$output_path,x=$clock_region_x,y=$clock_region_y"
fi

View 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")

View File

@@ -0,0 +1,380 @@
#!/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"
STOP_DASHBOARD_CMD="$DIR/../stop.sh"
STATE_DIR="$DIR/state"
MENU_ITEMS_FILE="$STATE_DIR/theme-menu-items.tsv"
RUNTIME_DIR_DEFAULT="/tmp/kindle-dash-theme-menu"
EVENT_DEVICE_DEFAULT="/dev/input/event2"
THEME_MENU_COMBO_WINDOW_SECONDS_DEFAULT="0.35"
HOME_MENU_ITEM_ID="__return_home__"
HOME_MENU_ITEM_LABEL="Return Home"
# 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}
RUNTIME_DIR=${THEME_MENU_RUNTIME_DIR:-$RUNTIME_DIR_DEFAULT}
ACTION_FIFO="$RUNTIME_DIR/theme-menu-actions.fifo"
PID_FILE="$RUNTIME_DIR/theme-menu-service.pid"
menu_open=false
selected_index=1
current_theme_id=${THEME_ID:-default}
current_orientation=${ORIENTATION:-portrait}
stream_pid=""
owns_runtime_state=false
log_event() {
# 调试阶段把状态机动作写进日志,便于确认按键是否真的被识别到。
printf '%s theme-menu: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2
}
service_running() {
if [ ! -f "$PID_FILE" ]; then
return 1
fi
existing_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -z "$existing_pid" ]; then
return 1
fi
kill -0 "$existing_pid" 2>/dev/null
}
send_action_to_service() {
requested_action=$1
attempts=0
if ! service_running; then
printf 'theme-menu-service not running\n' >&2
return 1
fi
# action fifo 由主监听循环持有;正常情况下会一直存在。
# 这里保守重试几次兼容监听器刚重启、fifo 正在重建的瞬间。
while [ "$attempts" -lt 3 ]; do
if [ -p "$ACTION_FIFO" ]; then
printf '%s\n' "$requested_action" >"$ACTION_FIFO"
return 0
fi
attempts=$((attempts + 1))
sleep 1
done
printf 'theme-menu-service action fifo not ready\n' >&2
return 1
}
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"
# 在主题列表末尾追加一个动作项,用于安全退出 dashboard 并恢复 Kindle 首页。
printf '%s\t%s\n' "$HOME_MENU_ITEM_ID" "$HOME_MENU_ITEM_LABEL" >>"$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=" "
line_text=""
if [ "$index" -eq "$selected_index" ]; then
prefix="> "
fi
if [ "$theme_id" = "$HOME_MENU_ITEM_ID" ]; then
line_text="${prefix}${theme_label}"
else
line_text="${prefix}${theme_label} (${theme_id})"
fi
print_line 3 "$row" "$line_text"
row=$((row + 2))
index=$((index + 1))
done
print_line 3 18 "PageUp/PageDown: move"
print_line 3 20 "Press both keys: select"
}
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
if [ "$selected_theme_id" = "$HOME_MENU_ITEM_ID" ]; then
log_event "apply return_home"
print_line 3 5 "Returning home..."
print_line 3 7 "Restoring framework/webreader"
menu_open=false
"$STOP_DASHBOARD_CMD"
return
fi
log_event "apply theme=$selected_theme_id orientation=$current_orientation"
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
log_event "open_menu theme=$current_theme_id orientation=$current_orientation selected_index=$selected_index"
render_menu
}
handle_action() {
action=$1
log_event "action=$action menu_open=$menu_open"
case "$action" in
open_menu)
if [ "$menu_open" = true ]; then
# 触摸热区重复触发时,不直接确认当前选项,只重画当前菜单。
render_menu
else
open_menu
fi
;;
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
if [ "$owns_runtime_state" != true ]; then
return
fi
rm -f "$ACTION_FIFO"
rm -f "$PID_FILE"
}
ensure_single_instance() {
if [ -f "$PID_FILE" ]; then
existing_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then
log_event "service_already_running pid=$existing_pid"
exit 0
fi
fi
printf '%s\n' "$$" >"$PID_FILE"
owns_runtime_state=true
}
if [ "${1:-}" = "trigger" ]; then
if [ "$#" -ne 2 ]; then
printf 'usage: %s trigger <action>\n' "$0" >&2
exit 64
fi
send_action_to_service "$2"
exit $?
fi
trap cleanup EXIT INT TERM
mkdir -p "$STATE_DIR"
mkdir -p "$RUNTIME_DIR"
ensure_single_instance
log_event "service_started event_device=$EVENT_DEVICE runtime_dir=$RUNTIME_DIR combo_window=$THEME_MENU_COMBO_WINDOW_SECONDS"
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

View 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

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env sh
set -eu
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
ENV_FILE="$DIR/env.sh"
THEME_MENU_SERVICE_CMD="$DIR/theme-menu-service.sh"
STOP_DASHBOARD_CMD="$DIR/../stop.sh"
RUNTIME_DIR_DEFAULT="/tmp/kindle-dash-touch-home"
TOUCH_DEVICE_DEFAULT="/dev/input/event1"
SCREEN_WIDTH_DEFAULT="1072"
SCREEN_HEIGHT_DEFAULT="1448"
HOTSPOT_WIDTH_RATIO_DEFAULT="0.18"
HOTSPOT_HEIGHT_RATIO_DEFAULT="0.18"
LONG_PRESS_SECONDS_DEFAULT="1.2"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
RUNTIME_DIR=${TOUCH_HOME_RUNTIME_DIR:-$RUNTIME_DIR_DEFAULT}
PID_FILE="$RUNTIME_DIR/touch-home-service.pid"
ACTION_FIFO="$RUNTIME_DIR/touch-home-actions.fifo"
TOUCH_DEVICE=${TOUCH_HOME_EVENT_DEVICE:-$TOUCH_DEVICE_DEFAULT}
SCREEN_WIDTH=${TOUCH_HOME_SCREEN_WIDTH:-$SCREEN_WIDTH_DEFAULT}
SCREEN_HEIGHT=${TOUCH_HOME_SCREEN_HEIGHT:-$SCREEN_HEIGHT_DEFAULT}
HOTSPOT_WIDTH_RATIO=${TOUCH_HOME_HOTSPOT_WIDTH_RATIO:-$HOTSPOT_WIDTH_RATIO_DEFAULT}
HOTSPOT_HEIGHT_RATIO=${TOUCH_HOME_HOTSPOT_HEIGHT_RATIO:-$HOTSPOT_HEIGHT_RATIO_DEFAULT}
LONG_PRESS_SECONDS=${TOUCH_HOME_LONG_PRESS_SECONDS:-$LONG_PRESS_SECONDS_DEFAULT}
stream_pid=""
owns_runtime_state=false
log_event() {
# 统一把触摸热区服务日志写到 stderr交给上层 nohup 重定向到日志文件。
printf '%s touch-home: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2
}
cleanup() {
if [ -n "$stream_pid" ]; then
kill "$stream_pid" 2>/dev/null || true
fi
if [ "$owns_runtime_state" != true ]; then
return
fi
rm -f "$ACTION_FIFO"
rm -f "$PID_FILE"
}
ensure_single_instance() {
if [ -f "$PID_FILE" ]; then
existing_pid=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then
log_event "service_already_running pid=$existing_pid"
exit 0
fi
fi
printf '%s\n' "$$" >"$PID_FILE"
owns_runtime_state=true
}
trigger_theme_menu() {
log_event "trigger_theme_menu"
# 右下角长按现在优先复用主题菜单服务,让用户先看到主题列表。
# 如果菜单服务意外没起来,再回退到旧的 stop.sh 路径,避免设备失去退出入口。
if "$THEME_MENU_SERVICE_CMD" trigger open_menu; then
return 0
fi
log_event "trigger_theme_menu_failed fallback_home"
"$STOP_DASHBOARD_CMD"
}
event_stream() {
evtest --grab "$TOUCH_DEVICE" 2>/dev/null | awk \
-v screen_width="$SCREEN_WIDTH" \
-v screen_height="$SCREEN_HEIGHT" \
-v hotspot_width_ratio="$HOTSPOT_WIDTH_RATIO" \
-v hotspot_height_ratio="$HOTSPOT_HEIGHT_RATIO" \
-v long_press_seconds="$LONG_PRESS_SECONDS" '
BEGIN {
hotspot_min_x = int(screen_width * (1 - hotspot_width_ratio))
hotspot_min_y = int(screen_height * (1 - hotspot_height_ratio))
touch_active = 0
touch_x = -1
touch_y = -1
touch_start_time = 0
already_triggered = 0
}
function extract_time(line, matched) {
matched = match(line, /time [0-9]+\.[0-9]+/)
if (matched == 0) {
return 0
}
return substr(line, RSTART + 5, RLENGTH - 5) + 0
}
function emit(name) {
print name
fflush()
}
function reset_touch() {
touch_active = 0
touch_x = -1
touch_y = -1
touch_start_time = 0
already_triggered = 0
}
{
current_time = extract_time($0)
# Voyage 上实际输出是 "code 57 (MT Tracking ID)",但不同 evtest 版本
# 也可能写成 ABS_MT_TRACKING_ID这里同时兼容两种格式。
if (($0 ~ /code 57 \(MT Tracking ID\)/ || $0 ~ /ABS_MT_TRACKING_ID/) && $0 ~ /value -1/) {
reset_touch()
next
}
if ($0 ~ /code 57 \(MT Tracking ID\)/ || $0 ~ /ABS_MT_TRACKING_ID/) {
touch_active = 1
touch_start_time = current_time
already_triggered = 0
next
}
if ($0 ~ /code 53 \(MT X\)/ || $0 ~ /ABS_MT_POSITION_X/) {
if (match($0, /value -?[0-9]+/)) {
touch_x = substr($0, RSTART + 6, RLENGTH - 6) + 0
}
next
}
if ($0 ~ /code 54 \(MT Y\)/ || $0 ~ /ABS_MT_POSITION_Y/) {
if (match($0, /value -?[0-9]+/)) {
touch_y = substr($0, RSTART + 6, RLENGTH - 6) + 0
}
next
}
if ($0 ~ /Report Sync/ || $0 ~ /SYN_REPORT/) {
if (touch_active && !already_triggered && touch_x >= hotspot_min_x && touch_y >= hotspot_min_y) {
if (current_time - touch_start_time >= long_press_seconds) {
already_triggered = 1
emit("trigger_theme_menu")
}
}
}
}
'
}
trap cleanup EXIT INT TERM
mkdir -p "$RUNTIME_DIR"
ensure_single_instance
log_event "service_started touch_device=$TOUCH_DEVICE long_press_seconds=$LONG_PRESS_SECONDS hotspot_width_ratio=$HOTSPOT_WIDTH_RATIO hotspot_height_ratio=$HOTSPOT_HEIGHT_RATIO"
while true; do
rm -f "$ACTION_FIFO"
mkfifo "$ACTION_FIFO"
event_stream >"$ACTION_FIFO" &
stream_pid=$!
while IFS= read -r action; do
case "$action" in
trigger_theme_menu)
trigger_theme_menu
;;
esac
done <"$ACTION_FIFO"
wait "$stream_pid" 2>/dev/null || true
stream_pid=""
rm -f "$ACTION_FIFO"
sleep 1
done

View File

@@ -4,15 +4,25 @@ DEBUG=${DEBUG:-false}
DIR="$(dirname "$0")" DIR="$(dirname "$0")"
ENV_FILE="$DIR/local/env.sh" ENV_FILE="$DIR/local/env.sh"
THEME_FILE="$DIR/local/theme.env"
LOG_FILE="$DIR/logs/dash.log" LOG_FILE="$DIR/logs/dash.log"
mkdir -p "$(dirname "$LOG_FILE")" mkdir -p "$(dirname "$LOG_FILE")"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE" [ -f "$ENV_FILE" ] && . "$ENV_FILE"
# shellcheck disable=SC1090
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
if [ "$DEBUG" = true ]; then if [ "$DEBUG" = true ]; then
"$DIR/dash.sh" "$DIR/dash.sh"
else else
"$DIR/dash.sh" >>"$LOG_FILE" 2>&1 & # 通过 SSH 或 KUAL 触发时,父 shell 很快就会退出。
# 仅用 nohup 仍可能残留在同一个 session/group 里KUAL/framework 切换时
# 依然可能把后台子进程一起打掉;这里额外用 setsid 彻底脱离会话。
if command -v setsid >/dev/null 2>&1; then
nohup setsid "$DIR/dash.sh" >>"$LOG_FILE" 2>&1 </dev/null &
else
nohup "$DIR/dash.sh" >>"$LOG_FILE" 2>&1 </dev/null &
fi
fi fi

View File

@@ -1,2 +1,187 @@
#!/usr/bin/env sh #!/usr/bin/env sh
pkill -f dash.sh set -eu
DIR="$(dirname "$0")"
ENV_FILE="$DIR/local/env.sh"
# 退出 dashboard 时,统一走一条保守恢复路径:
# 1. 停掉 dashboard 自己的进程
# 2. 干净重启 framework / webreader / cvm
#
# Voyage 5.13.6 上试过的“快切换”路径会把 blanket/cvm 打崩,
# 当前这台机型仍以稳定恢复优先。
START_BIN=/sbin/start
STOP_BIN=/sbin/stop
STATUS_BIN=/sbin/status
INITCTL_BIN=/sbin/initctl
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
KEEP_NATIVE_UI_STACK_RUNNING=${KEEP_NATIVE_UI_STACK_RUNNING:-false}
KUAL_APP_ID=${KUAL_APP_ID:-app://com.mobileread.ixtab.kindlelauncher}
run_job_cmd() {
bin_path=$1
shift
if [ -x "$bin_path" ]; then
"$bin_path" "$@" >/dev/null 2>&1
return $?
fi
return 127
}
job_running() {
job_name=$1
job_state=$(
if [ -x "$STATUS_BIN" ]; then
"$STATUS_BIN" "$job_name" 2>/dev/null || true
elif [ -x "$INITCTL_BIN" ]; then
"$INITCTL_BIN" status "$job_name" 2>/dev/null || true
else
true
fi
)
case "$job_state" in
*"start/running"*)
return 0
;;
esac
return 1
}
stop_job() {
job_name=$1
if ! job_running "$job_name"; then
return 0
fi
run_job_cmd "$STOP_BIN" "$job_name" || run_job_cmd "$INITCTL_BIN" stop "$job_name" || true
}
start_job() {
job_name=$1
if job_running "$job_name"; then
return 0
fi
run_job_cmd "$START_BIN" "$job_name" || run_job_cmd "$INITCTL_BIN" start "$job_name" || true
}
wait_for_cvm() {
attempts=0
while [ "$attempts" -lt 12 ]; do
if pgrep -x cvm >/dev/null 2>&1; then
return 0
fi
attempts=$((attempts + 1))
sleep 1
done
return 1
}
ensure_job_stable_running() {
job_name=$1
max_attempts=$2
stable_required=$3
attempts=0
stable_hits=0
# Voyage 上 webreader 经常会出现“刚被拉起1-2 秒后又掉回 stop/waiting”的状态。
# 这里不再只发一次 start而是循环观察一段时间直到连续多次都看到 start/running
# 才把恢复流程视为成功。
while [ "$attempts" -lt "$max_attempts" ]; do
if job_running "$job_name"; then
stable_hits=$((stable_hits + 1))
if [ "$stable_hits" -ge "$stable_required" ]; then
return 0
fi
else
stable_hits=0
start_job "$job_name"
fi
attempts=$((attempts + 1))
sleep 1
done
return 1
}
full_restore_ui() {
# 先把残留 UI 栈彻底停干净,避免 webreader 存活但 cvm 已崩的白屏状态。
stop_job webreader
stop_job framework
killall cvm 2>/dev/null || true
sleep 2
if [ -x /etc/init.d/framework ]; then
/etc/init.d/framework start || true
else
start_job framework
fi
# framework 拉起后,先等 cvm 真正起来,再启动 webreader。
if ! wait_for_cvm; then
echo "警告framework 已请求启动,但 cvm 未在预期时间内出现。"
fi
if ! ensure_job_stable_running webreader 12 3; then
echo "警告webreader 未能稳定恢复,可能仍需手工执行 /sbin/start webreader。"
fi
}
stop_dashboard_processes() {
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
pkill -f touch-home-service.sh 2>/dev/null || true
lipc-set-prop com.lab126.powerd preventScreenSaver 0 2>/dev/null || true
}
native_active_app() {
if ! command -v lipc-get-prop >/dev/null 2>&1; then
return 0
fi
lipc-get-prop com.lab126.appmgrd activeApp 2>/dev/null || true
}
return_to_home_from_kual() {
active_app=$(native_active_app)
case "$active_app" in
com.mobileread.ixtab.kindlelauncher|app://com.mobileread.ixtab.kindlelauncher)
# 保留原生 UI 栈时dashboard 底下通常仍是 KUAL。
# 这里复用 launch-from-kual 已验证过的 appmgrd stop 路径,把 KUAL 正常退回首页。
if command -v lipc-set-prop >/dev/null 2>&1; then
lipc-set-prop com.lab126.appmgrd stop "$KUAL_APP_ID" >/dev/null 2>&1 || true
sleep 1
fi
;;
esac
}
soft_stop_for_native_ui_overlay() {
# 原生 UI 栈保持存活时,退出 dashboard 只需要停掉我们自己的覆盖进程,
# 不要再重建 framework/webreader/cvm否则会重新引入旧架构的切换风险。
stop_dashboard_processes
return_to_home_from_kual
}
if [ "$KEEP_NATIVE_UI_STACK_RUNNING" = true ]; then
soft_stop_for_native_ui_overlay
exit 0
fi
stop_dashboard_processes
full_restore_ui

View 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}"

View File

@@ -1,6 +1,14 @@
{ {
"items": [ "items": [
{"name": "Kindle Dashboard", "action": "/mnt/us/dashboard/start.sh"}, {
"name": "Kindle Dashboard",
"priority": -998,
"items": [
{"name": "Default", "priority": 1, "action": "/mnt/us/dashboard/launch-theme-from-kual.sh", "params": "default", "exitmenu": true},
{"name": "Paper", "priority": 2, "action": "/mnt/us/dashboard/launch-theme-from-kual.sh", "params": "paper", "exitmenu": true},
{"name": "Classic", "priority": 3, "action": "/mnt/us/dashboard/launch-theme-from-kual.sh", "params": "classic", "exitmenu": true}
]
},
{"name": "Dashboard Debug On", "action": "/mnt/us/dashboard/debug-on.sh"}, {"name": "Dashboard Debug On", "action": "/mnt/us/dashboard/debug-on.sh"},
{"name": "Dashboard Debug Off", "action": "/mnt/us/dashboard/debug-off.sh"} {"name": "Dashboard Debug Off", "action": "/mnt/us/dashboard/debug-off.sh"}
] ]

View File

@@ -1,166 +0,0 @@
#!/usr/bin/env swift
import AppKit
import Foundation
struct HandConfig {
let sourcePath: String
let outputDirectoryName: String
let frameCount: Int
let sourceWidth: CGFloat
let sourceHeight: CGFloat
let pivotX: CGFloat
let pivotY: CGFloat
let digits: Int
let angleStep: CGFloat
}
enum AssetError: Error, CustomStringConvertible {
case invalidImage(String)
case pngEncodingFailed(String)
var description: String {
switch self {
case let .invalidImage(path):
return "无法读取图片:\(path)"
case let .pngEncodingFailed(path):
return "无法编码 PNG\(path)"
}
}
}
let fileManager = FileManager.default
let workingDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true)
let repoRoot = CommandLine.arguments.count > 1
? URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
: workingDirectory
let outputRoot = CommandLine.arguments.count > 2
? URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true)
: repoRoot.appendingPathComponent("assets/kindle-clock", isDirectory: true)
let faceSourceURL = repoRoot.appendingPathComponent("assets/clock-face.png")
let targetSize = NSSize(width: 220, height: 220)
let faceSourceSize = NSSize(width: 431, height: 431)
let scale = targetSize.width / faceSourceSize.width
let handConfigs = [
HandConfig(
sourcePath: "assets/hour-hand.png",
outputDirectoryName: "hour-hand",
frameCount: 720,
sourceWidth: 32,
sourceHeight: 205,
pivotX: 13,
pivotY: 138,
digits: 3,
angleStep: 0.5
),
HandConfig(
sourcePath: "assets/minite-hand.png",
outputDirectoryName: "minute-hand",
frameCount: 60,
sourceWidth: 32,
sourceHeight: 288,
pivotX: 15,
pivotY: 203,
digits: 2,
angleStep: 6
),
]
func loadImage(at url: URL) throws -> NSImage {
guard let image = NSImage(contentsOf: url) else {
throw AssetError.invalidImage(url.path)
}
return image
}
func savePNG(_ image: NSImage, to url: URL) throws {
guard
let tiffRepresentation = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffRepresentation),
let pngData = bitmap.representation(using: .png, properties: [:])
else {
throw AssetError.pngEncodingFailed(url.path)
}
try fileManager.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try pngData.write(to: url)
}
func renderImage(size: NSSize, draw: () -> Void) -> NSImage {
let image = NSImage(size: size)
image.lockFocusFlipped(true)
NSColor.clear.setFill()
NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill()
draw()
image.unlockFocus()
return image
}
func renderFace() throws {
let faceImage = try loadImage(at: faceSourceURL)
let renderedFace = renderImage(size: targetSize) {
faceImage.draw(
in: NSRect(origin: .zero, size: targetSize),
from: NSRect(origin: .zero, size: faceImage.size),
operation: .sourceOver,
fraction: 1
)
}
try savePNG(renderedFace, to: outputRoot.appendingPathComponent("clock-face.png"))
}
func renderHandFrames(config: HandConfig) throws {
let sourceURL = repoRoot.appendingPathComponent(config.sourcePath)
let handImage = try loadImage(at: sourceURL)
let outputDirectory = outputRoot.appendingPathComponent(config.outputDirectoryName, isDirectory: true)
let scaledWidth = config.sourceWidth * scale
let scaledHeight = config.sourceHeight * scale
let scaledPivotX = config.pivotX * scale
let scaledPivotY = config.pivotY * scale
try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
for frameIndex in 0..<config.frameCount {
let angle = CGFloat(frameIndex) * config.angleStep
let renderedFrame = renderImage(size: targetSize) {
guard let context = NSGraphicsContext.current?.cgContext else {
return
}
context.translateBy(x: targetSize.width / 2, y: targetSize.height / 2)
context.rotate(by: angle * .pi / 180)
context.translateBy(x: -scaledPivotX, y: -scaledPivotY)
handImage.draw(
in: NSRect(x: 0, y: 0, width: scaledWidth, height: scaledHeight),
from: NSRect(origin: .zero, size: handImage.size),
operation: .sourceOver,
fraction: 1
)
}
let filename = String(format: "%0\(config.digits)d.png", frameIndex)
try savePNG(renderedFrame, to: outputDirectory.appendingPathComponent(filename))
}
}
do {
try fileManager.createDirectory(at: outputRoot, withIntermediateDirectories: true)
try renderFace()
for config in handConfigs {
try renderHandFrames(config: config)
}
print("Generated Kindle clock assets at \(outputRoot.path)")
} catch {
fputs("\(error)\n", stderr)
exit(1)
}

View File

@@ -1,63 +0,0 @@
#!/bin/sh
set -eu
# 强制清理残留 SSH 进程,然后在 22 端口拉起一份 usbnet 自带的 OpenSSH。
# 这份 sshd 会优先读取 /mnt/us/usbnet/etc/dot.ssh/authorized_keys。
TS="$(date +%Y%m%d-%H%M%S 2>/dev/null || echo now)"
OUT_DIR="/mnt/us/ssh-debug/${TS}"
LOG_FILE="${OUT_DIR}/force-openssh-22.log"
PID_FILE="/mnt/us/usbnet/run/sshd-force-22.pid"
SOURCE_KEYS="/mnt/us/usbnet/etc/authorized_keys"
TARGET_KEYS="/mnt/us/usbnet/etc/dot.ssh/authorized_keys"
mkdir -p "${OUT_DIR}" /mnt/us/usbnet/run /mnt/us/usbnet/etc/dot.ssh
exec >"${LOG_FILE}" 2>&1
echo "=== FORCE OPENSSH 22 ==="
date 2>/dev/null || true
id 2>/dev/null || true
if [ -f "${SOURCE_KEYS}" ]; then
cp "${SOURCE_KEYS}" "${TARGET_KEYS}"
chmod 600 "${TARGET_KEYS}" 2>/dev/null || true
fi
chmod 755 /mnt/us/usbnet/etc/dot.ssh 2>/dev/null || true
killall sshd 2>/dev/null || true
killall dropbear 2>/dev/null || true
killall dropbearmulti 2>/dev/null || true
sleep 1
rm -f "${PID_FILE}" 2>/dev/null || true
iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT 2>/dev/null || true
(
exec /mnt/us/usbnet/sbin/sshd -D -e \
-f /mnt/us/usbnet/etc/sshd_config \
-o ListenAddress=0.0.0.0 \
-o Port=22 \
-o PidFile="${PID_FILE}" \
-o AuthorizedKeysFile="${TARGET_KEYS}" \
-o PasswordAuthentication=no \
-o KbdInteractiveAuthentication=no \
-o PubkeyAuthentication=yes \
-o PermitRootLogin=yes \
-o HostKey=/mnt/us/usbnet/etc/ssh_host_rsa_key \
-o HostKey=/mnt/us/usbnet/etc/ssh_host_ecdsa_key \
-o HostKey=/mnt/us/usbnet/etc/ssh_host_ed25519_key
) &
LAUNCHER_PID="$!"
echo "${LAUNCHER_PID}" > "${OUT_DIR}/launcher.pid"
sleep 1
echo "launcher pid: ${LAUNCHER_PID}"
echo "pid file: ${PID_FILE}"
if [ -x /mnt/us/usbnet/bin/lsof ]; then
/mnt/us/usbnet/bin/lsof -n -P -iTCP:22 2>/dev/null || true
fi
echo "=== DONE ==="
echo "${OUT_DIR}"

View File

@@ -1,14 +0,0 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
TMP_DIR="${TMPDIR:-/tmp}/kindle-clock-assets"
KINDLE_TARGET=${1:-kindle}
rm -rf "$TMP_DIR"
/usr/bin/swift "$ROOT_DIR/scripts/generate-kindle-clock-assets.swift" "$ROOT_DIR" "$TMP_DIR"
ssh "$KINDLE_TARGET" 'mkdir -p /mnt/us/dashboard/assets/hour-hand /mnt/us/dashboard/assets/minute-hand'
rsync -av --no-o --no-g --delete "$TMP_DIR"/ "$KINDLE_TARGET":/mnt/us/dashboard/assets/
echo "Clock assets synced to $KINDLE_TARGET:/mnt/us/dashboard/assets"

View File

@@ -88,6 +88,9 @@ sync_dashboard_runtime() {
rsync -av --no-o --no-g \ rsync -av --no-o --no-g \
"$ROOT_DIR/dash/src/start.sh" \ "$ROOT_DIR/dash/src/start.sh" \
"$ROOT_DIR/dash/src/dash.sh" \ "$ROOT_DIR/dash/src/dash.sh" \
"$ROOT_DIR/dash/src/stop.sh" \
"$ROOT_DIR/dash/src/launch-from-kual.sh" \
"$ROOT_DIR/dash/src/launch-theme-from-kual.sh" \
"$ROOT_DIR/dash/src/switch-theme.sh" \ "$ROOT_DIR/dash/src/switch-theme.sh" \
"$KINDLE_TARGET":/mnt/us/dashboard/ "$KINDLE_TARGET":/mnt/us/dashboard/
@@ -97,16 +100,24 @@ sync_dashboard_runtime() {
"$ROOT_DIR/dash/src/local/clock-index.sh" \ "$ROOT_DIR/dash/src/local/clock-index.sh" \
"$ROOT_DIR/dash/src/local/render-clock.lua" \ "$ROOT_DIR/dash/src/local/render-clock.lua" \
"$ROOT_DIR/dash/src/local/render-clock.sh" \ "$ROOT_DIR/dash/src/local/render-clock.sh" \
"$ROOT_DIR/dash/src/local/touch-home-service.sh" \
"$ROOT_DIR/dash/src/local/theme-menu-service.sh" \ "$ROOT_DIR/dash/src/local/theme-menu-service.sh" \
"$ROOT_DIR/dash/src/local/theme-json.lua" \ "$ROOT_DIR/dash/src/local/theme-json.lua" \
"$ROOT_DIR/dash/src/local/theme-sync.sh" \ "$ROOT_DIR/dash/src/local/theme-sync.sh" \
"$KINDLE_TARGET":/mnt/us/dashboard/local/ "$KINDLE_TARGET":/mnt/us/dashboard/local/
ssh "$KINDLE_TARGET" "chmod +x /mnt/us/dashboard/start.sh /mnt/us/dashboard/dash.sh /mnt/us/dashboard/switch-theme.sh /mnt/us/dashboard/local/fetch-dashboard.sh /mnt/us/dashboard/local/clock-index.sh /mnt/us/dashboard/local/render-clock.sh /mnt/us/dashboard/local/theme-menu-service.sh /mnt/us/dashboard/local/theme-sync.sh" ssh "$KINDLE_TARGET" "chmod +x /mnt/us/dashboard/start.sh /mnt/us/dashboard/dash.sh /mnt/us/dashboard/stop.sh /mnt/us/dashboard/launch-from-kual.sh /mnt/us/dashboard/launch-theme-from-kual.sh /mnt/us/dashboard/switch-theme.sh /mnt/us/dashboard/local/fetch-dashboard.sh /mnt/us/dashboard/local/clock-index.sh /mnt/us/dashboard/local/render-clock.sh /mnt/us/dashboard/local/touch-home-service.sh /mnt/us/dashboard/local/theme-menu-service.sh /mnt/us/dashboard/local/theme-sync.sh"
ssh "$KINDLE_TARGET" "mkdir -p /mnt/us/dashboard/local/state" ssh "$KINDLE_TARGET" "mkdir -p /mnt/us/dashboard/local/state"
ssh "$KINDLE_TARGET" "date '+%s' >/mnt/us/dashboard/local/state/background-updated-at" ssh "$KINDLE_TARGET" "date '+%s' >/mnt/us/dashboard/local/state/background-updated-at"
} }
sync_kual_extension() {
ssh "$KINDLE_TARGET" "mkdir -p /mnt/us/extensions/kindle-dash"
rsync -av --no-o --no-g \
"$ROOT_DIR/dash/KUAL/kindle-dash/" \
"$KINDLE_TARGET":/mnt/us/extensions/kindle-dash/
}
sync_theme_bundle() { sync_theme_bundle() {
rsync -av --no-o --no-g \ rsync -av --no-o --no-g \
"$ROOT_DIR/calendar/dist/themes.json" \ "$ROOT_DIR/calendar/dist/themes.json" \
@@ -175,6 +186,7 @@ update_default_clock_region_env() {
sync_dashboard_runtime sync_dashboard_runtime
sync_theme_bundle sync_theme_bundle
sync_kual_extension
update_default_clock_region_env update_default_clock_region_env
if [ -n "$THEME_FILTER" ]; then if [ -n "$THEME_FILTER" ]; then

8
todo.md Normal file
View File

@@ -0,0 +1,8 @@
1. 左右翻页键同时按住呼出主题选择。
1. 一键刷机
1. 定位 dash/docs/kindle-city-location-plan.zh.md
1. dual - dashborad白屏问题
1. calendar项目融合xcs
1. calendar中嵌入图片