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

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
DIR="$(dirname "$0")"
DASH_PNG="$DIR/dash.png"
BACKGROUND_PNG="$DIR/kindlebg.png"
FETCH_DASHBOARD_CMD="$DIR/local/fetch-dashboard.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"}
FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
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
KEEP_NATIVE_UI_STACK_RUNNING=${KEEP_NATIVE_UI_STACK_RUNNING:-false}
LOW_BATTERY_REPORTING=${LOW_BATTERY_REPORTING:-false}
LOW_BATTERY_THRESHOLD_PERCENT=${LOW_BATTERY_THRESHOLD_PERCENT:-10}
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() {
if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then
@@ -26,17 +85,41 @@ init() {
fi
echo "Starting dashboard with $REFRESH_SCHEDULE refresh..."
mkdir -p "$STATE_DIR"
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
fi
/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
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() {
echo "Preparing sleep"
/usr/sbin/eips -f -g "$DIR/sleeping.png"
background_needs_redraw=true
# Give screen time to refresh
sleep 2
@@ -45,41 +128,151 @@ prepare_sleep() {
num_refresh=$FULL_DISPLAY_REFRESH_RATE
}
refresh_dashboard() {
echo "Refreshing dashboard"
now_epoch() {
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"
"$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=$?
if [ "$fetch_status" -ne 0 ]; then
echo "Not updating screen, fetch-dashboard returned $fetch_status"
echo "Background fetch failed with $fetch_status"
return 1
fi
if [ "$num_refresh" -eq "$FULL_DISPLAY_REFRESH_RATE" ]; then
num_refresh=0
store_background_timestamp
return 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"
clock_force_full_refresh() {
eval "$("$DIR/local/clock-index.sh")"
[ $((minute % CLOCK_FULL_REFRESH_INTERVAL_MINUTES)) -eq 0 ]
}
mask_system_status_overlay() {
if [ "$STATUS_MASK_ENABLED" != true ]; then
return
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))
}
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() {
battery_level=$(gasgauge-info -c)
echo "$(date) Battery level: $battery_level."
battery_level=$(gasgauge-info -c 2>/dev/null || echo "unknown")
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
battery_level_numeric=${battery_level%?}
if [ "$battery_level_numeric" -le "$LOW_BATTERY_THRESHOLD_PERCENT" ]; then
"$LOW_BATTERY_CMD" "$battery_level_numeric"
fi
case "$battery_level" in
*%)
battery_level_numeric=${battery_level%?}
if [ "$battery_level_numeric" -le "$LOW_BATTERY_THRESHOLD_PERCENT" ]; then
"$LOW_BATTERY_CMD" "$battery_level_numeric"
fi
;;
esac
fi
}
@@ -88,6 +281,9 @@ rtc_sleep() {
if [ "$DEBUG" = true ]; then
sleep "$duration"
elif [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
echo "Skipping system suspend, sleeping for ${duration}s instead"
sleep "$duration"
else
# shellcheck disable=SC2039
[ "$(cat "$RTC")" -eq 0 ] && echo -n "$duration" >"$RTC"
@@ -95,26 +291,73 @@ rtc_sleep() {
fi
}
manual_wake_detected() {
requested_duration=$1
actual_duration=$2
if [ "$DEBUG" = true ] || [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
return 1
fi
if [ "$requested_duration" -le "$MANUAL_WAKE_EARLY_TOLERANCE_SECONDS" ]; then
return 1
fi
[ "$actual_duration" -lt $((requested_duration - MANUAL_WAKE_EARLY_TOLERANCE_SECONDS)) ]
}
hold_after_manual_wake() {
requested_duration=$1
sleep_started_at=$2
sleep_finished_at=$3
actual_duration=$((sleep_finished_at - sleep_started_at))
if ! manual_wake_detected "$requested_duration" "$actual_duration"; then
return
fi
echo "Manual wake detected after ${actual_duration}s, keeping awake for ${MANUAL_WAKE_KEEP_AWAKE_SECONDS}s"
# 短按电源键提前唤醒后,先把 dashboard 内容恢复回来,
# 再给出一段明确的可交互窗口,避免 2~3 秒内再次休眠。
refresh_dashboard || true
sleep "$MANUAL_WAKE_KEEP_AWAKE_SECONDS"
}
main_loop() {
while true; do
log_battery_stats
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"
prepare_sleep
else
if [ "$next_wakeup_secs" -gt "$SLEEP_SCREEN_INTERVAL" ] && [ "$DISABLE_SYSTEM_SUSPEND" = true ]; then
echo "Debug mode active, skipping sleeping screen."
fi
action="suspend"
refresh_dashboard
fi
# take a bit of time before going to sleep, so this process can be aborted
sleep 10
actual_sleep_secs=$next_wakeup_secs
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"
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
}

View File

@@ -35,7 +35,12 @@ END {
mv "$TMP_FILE" "$ENV_FILE"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 避免旧进程继续按旧配置运行
# 然后立刻拉起新的 dashboard避免用户还要再次手动启动
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"
# 已运行的 dashboard 进程不会重新读取 env.sh切换后先停掉它
# 避免旧进程继续按旧配置进入系统挂起
# 然后立刻拉起新的 dashboard避免用户还要在短时间内再点一次菜单
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 REFRESH_SCHEDULE=${REFRESH_SCHEDULE:-"* * * * *"}
# 调度计算依赖 next-wakeup 这个 Rust 程序,它要求使用 IANA 时区名。
# 这里必须保留 Asia/Shanghai才能正确计算下一次唤醒时间。
export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
# Kindle 上的 BusyBox date 对 IANA 时区名支持不稳定,直接拿 TIMEZONE 画表盘会退回 UTC。
# 北京时间全年固定为 UTC+8没有夏令时所以表盘单独走固定偏移避免依赖系统时区解析。
export CLOCK_TIME_OFFSET_MINUTES=${CLOCK_TIME_OFFSET_MINUTES:-480}
export BACKGROUND_URL=${BACKGROUND_URL:-"https://shell.biboer.cn:20001/kindlebg.png"}
export THEMES_INDEX_URL=${THEMES_INDEX_URL:-"https://shell.biboer.cn:20001/themes.json"}
export THEMES_INDEX_REFRESH_INTERVAL_MINUTES=${THEMES_INDEX_REFRESH_INTERVAL_MINUTES:-1440}
export THEME_CONFIG_REFRESH_INTERVAL_MINUTES=${THEME_CONFIG_REFRESH_INTERVAL_MINUTES:-1440}
export THEME_ID=${THEME_ID:-"default"}
export ORIENTATION=${ORIENTATION:-"portrait"}
export BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
export CLOCK_REGION_X=${CLOCK_REGION_X:-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,
# to prevent the screen from flashing. After a few partial updates,
@@ -13,6 +95,11 @@ export TIMEZONE=${TIMEZONE:-"Asia/Shanghai"}
# 等图片尺寸与刷新逻辑确认无误后,再改回 4 之类的值以节省功耗。
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,
# the dashboard will not be refreshed anymore, but instead show a
# 'kindle is sleeping' screen. This can be useful if your schedule only runs

View File

@@ -1,4 +1,32 @@
#!/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
set -eu
# 拉取低频背景图,调用方负责传入输出路径。
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")"
ENV_FILE="$DIR/local/env.sh"
THEME_FILE="$DIR/local/theme.env"
LOG_FILE="$DIR/logs/dash.log"
mkdir -p "$(dirname "$LOG_FILE")"
# shellcheck disable=SC1090
[ -f "$ENV_FILE" ] && . "$ENV_FILE"
# shellcheck disable=SC1090
[ -f "$THEME_FILE" ] && . "$THEME_FILE"
if [ "$DEBUG" = true ]; then
"$DIR/dash.sh"
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

View File

@@ -1,2 +1,187 @@
#!/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": [
{"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 Off", "action": "/mnt/us/dashboard/debug-off.sh"}
]