update at 2026-03-16 09:00:35
This commit is contained in:
BIN
assets/bg_default.png
Normal file
BIN
assets/bg_default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -18,6 +18,7 @@ trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
|
|||||||
|
|
||||||
sleep 1
|
sleep 1
|
||||||
/usr/bin/swift "$CALENDAR_DIR/scripts/export-kindle-background.swift" "$URL" "$OUT_PNG" "$OUT_REGION"
|
/usr/bin/swift "$CALENDAR_DIR/scripts/export-kindle-background.swift" "$URL" "$OUT_PNG" "$OUT_REGION"
|
||||||
|
|
||||||
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
|
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
|
||||||
|
|
||||||
cat "$OUT_REGION"
|
cat "$OUT_REGION"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
enum ExportError: Error, CustomStringConvertible {
|
enum ExportError: Error, CustomStringConvertible {
|
||||||
@@ -29,7 +31,8 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
|||||||
private let pngOutputURL: URL
|
private let pngOutputURL: URL
|
||||||
private let regionOutputURL: URL
|
private let regionOutputURL: URL
|
||||||
private let completion: (Result<Void, Error>) -> Void
|
private let completion: (Result<Void, Error>) -> Void
|
||||||
private let targetSize = CGSize(width: 1024, height: 600)
|
// 直接按 Kindle Voyage 的系统屏保尺寸导出,避免额外旋转和补边。
|
||||||
|
private let targetSize = CGSize(width: 1072, height: 1448)
|
||||||
|
|
||||||
private lazy var window: NSWindow = {
|
private lazy var window: NSWindow = {
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
@@ -129,16 +132,52 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
|||||||
image.draw(in: NSRect(origin: .zero, size: targetSize))
|
image.draw(in: NSRect(origin: .zero, size: targetSize))
|
||||||
normalizedImage.unlockFocus()
|
normalizedImage.unlockFocus()
|
||||||
|
|
||||||
guard
|
guard let sourceCGImage = normalizedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
let tiffRepresentation = normalizedImage.tiffRepresentation,
|
throw ExportError.pngEncodingFailed(url.path)
|
||||||
let bitmap = NSBitmapImageRep(data: tiffRepresentation),
|
}
|
||||||
let pngData = bitmap.representation(using: .png, properties: [:])
|
|
||||||
else {
|
let width = Int(targetSize.width)
|
||||||
|
let height = Int(targetSize.height)
|
||||||
|
let colorSpace = CGColorSpaceCreateDeviceGray()
|
||||||
|
|
||||||
|
// 输出 8-bit 灰度 PNG,但页面本身仍按纯白底和纯黑字设计,避免额外灰阶装饰。
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: colorSpace,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||||
|
) else {
|
||||||
|
throw ExportError.pngEncodingFailed(url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setFillColor(gray: 1, alpha: 1)
|
||||||
|
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
context.interpolationQuality = .high
|
||||||
|
context.draw(sourceCGImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
|
||||||
|
guard let grayscaleImage = context.makeImage() else {
|
||||||
throw ExportError.pngEncodingFailed(url.path)
|
throw ExportError.pngEncodingFailed(url.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
try pngData.write(to: url)
|
|
||||||
|
guard let destination = CGImageDestinationCreateWithURL(
|
||||||
|
url as CFURL,
|
||||||
|
UTType.png.identifier as CFString,
|
||||||
|
1,
|
||||||
|
nil
|
||||||
|
) else {
|
||||||
|
throw ExportError.pngEncodingFailed(url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
CGImageDestinationAddImage(destination, grayscaleImage, nil)
|
||||||
|
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
throw ExportError.pngEncodingFailed(url.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveRegion(region: [String: NSNumber]) throws {
|
private func saveRegion(region: [String: NSNumber]) throws {
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ const stageStyle = computed(() => ({
|
|||||||
:style="stageStyle"
|
:style="stageStyle"
|
||||||
data-clock-region="true"
|
data-clock-region="true"
|
||||||
>
|
>
|
||||||
<div v-if="mode === 'background'" class="analog-clock__placeholder" />
|
<template v-if="mode !== 'background'">
|
||||||
<template v-else>
|
|
||||||
<img class="analog-clock__face" :src="CLOCK_FACE_ASSET" alt="时钟表盘" />
|
<img class="analog-clock__face" :src="CLOCK_FACE_ASSET" alt="时钟表盘" />
|
||||||
<img
|
<img
|
||||||
v-if="mode === 'full'"
|
v-if="mode === 'full'"
|
||||||
@@ -88,8 +87,7 @@ const stageStyle = computed(() => ({
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analog-clock__face,
|
.analog-clock__face {
|
||||||
.analog-clock__placeholder {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -98,15 +96,6 @@ const stageStyle = computed(() => ({
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analog-clock__placeholder {
|
|
||||||
border-radius: 50%;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.94), rgba(255, 250, 246, 0.78) 68%, rgba(239, 226, 221, 0.52));
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px rgba(214, 196, 187, 0.35),
|
|
||||||
inset 0 0 24px rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analog-clock__hand {
|
.analog-clock__hand {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|||||||
@@ -97,30 +97,30 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: 0.95rem;
|
gap: 1rem;
|
||||||
padding: 1.28rem 1.32rem 1.12rem;
|
padding: 1.28rem 1.28rem 1.16rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__hero {
|
.calendar-card__hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.9rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__headline {
|
.calendar-card__headline {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: center;
|
align-content: start;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0.18rem;
|
gap: 0.24rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__headline-copy {
|
.calendar-card__headline-copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.45rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__day {
|
.calendar-card__day {
|
||||||
@@ -128,33 +128,34 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
|
|||||||
'Iowan Old Style',
|
'Iowan Old Style',
|
||||||
'Baskerville',
|
'Baskerville',
|
||||||
serif;
|
serif;
|
||||||
font-size: 5.7rem;
|
font-size: 6.9rem;
|
||||||
line-height: 0.88;
|
line-height: 0.88;
|
||||||
letter-spacing: -0.08em;
|
letter-spacing: -0.08em;
|
||||||
color: #111111;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__lunar-day,
|
.calendar-card__lunar-day,
|
||||||
.calendar-card__weekday {
|
.calendar-card__weekday {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.48rem;
|
font-size: 1.88rem;
|
||||||
line-height: 1.02;
|
line-height: 1.02;
|
||||||
color: #232323;
|
color: #000000;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__weekday {
|
.calendar-card__weekday {
|
||||||
font-size: 1.54rem;
|
font-size: 1.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__panel {
|
.calendar-card__panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
gap: 0.45rem;
|
gap: 0.55rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.74rem 0.9rem 0.84rem;
|
padding: 0.88rem 0.94rem 0.94rem;
|
||||||
border-radius: 1.1rem;
|
border-radius: 1.25rem;
|
||||||
background: linear-gradient(180deg, rgba(250, 237, 238, 0.58), rgba(247, 240, 239, 0.86));
|
border: 2px solid var(--frame-stroke);
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__panel-header {
|
.calendar-card__panel-header {
|
||||||
@@ -176,8 +177,8 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
|
|||||||
|
|
||||||
.calendar-card__panel-subtitle {
|
.calendar-card__panel-subtitle {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
font-size: 0.76rem;
|
font-size: 0.84rem;
|
||||||
color: #7a6a61;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__badges {
|
.calendar-card__badges {
|
||||||
@@ -188,36 +189,21 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__badge {
|
.calendar-card__badge {
|
||||||
padding: 0.12rem 0.42rem;
|
padding: 0.14rem 0.46rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.66rem;
|
font-size: 0.72rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
border: 1px solid currentColor;
|
color: #000000;
|
||||||
background: rgba(255, 255, 255, 0.66);
|
border: 1.5px solid var(--frame-stroke);
|
||||||
}
|
background: #ffffff;
|
||||||
|
|
||||||
.calendar-card__badge--holiday {
|
|
||||||
color: #8b4a20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__badge--workday {
|
|
||||||
color: #7c5d2f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__badge--festival {
|
|
||||||
color: #67503d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__badge--term {
|
|
||||||
color: #4a6b6f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__grid {
|
.calendar-card__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
grid-auto-rows: minmax(0, 1fr);
|
grid-auto-rows: minmax(0, 1fr);
|
||||||
column-gap: 0.16rem;
|
column-gap: 0.18rem;
|
||||||
row-gap: 0.04rem;
|
row-gap: 0.1rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
@@ -225,18 +211,19 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
|
|||||||
.calendar-card__week-label {
|
.calendar-card__week-label {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding-bottom: 0.08rem;
|
padding-bottom: 0.14rem;
|
||||||
font-size: 0.7rem;
|
font-size: 0.82rem;
|
||||||
color: #7b7b7b;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__cell {
|
.calendar-card__cell {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.08rem 0 0.1rem;
|
padding: 0.16rem 0 0.18rem;
|
||||||
border-radius: 0.78rem;
|
border-radius: 0.9rem;
|
||||||
color: #3f454e;
|
border: 1.5px solid transparent;
|
||||||
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__cell-copy {
|
.calendar-card__cell-copy {
|
||||||
@@ -246,59 +233,26 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__cell--muted {
|
|
||||||
color: #b7b0b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__cell--today {
|
.calendar-card__cell--today {
|
||||||
background: #171717;
|
border-color: var(--frame-stroke-strong);
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__cell--holiday .calendar-card__solar {
|
|
||||||
color: #b76114;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__solar {
|
.calendar-card__solar {
|
||||||
font-size: 0.92rem;
|
font-size: 0.98rem;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__sub {
|
.calendar-card__sub {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0.42rem;
|
font-size: 0.84rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #8b8b8b;
|
color: #000000;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-card__sub--holiday {
|
.calendar-card__cell--muted {
|
||||||
color: #b76114;
|
border-color: rgba(139, 107, 71, 0.35);
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__sub--workday {
|
|
||||||
color: #7c5d2f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__sub--festival {
|
|
||||||
color: #67503d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__sub--term {
|
|
||||||
color: #4a6b6f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__sub--lunar {
|
|
||||||
color: #8b8b8b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__cell--muted .calendar-card__sub {
|
|
||||||
color: #c3bbbe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-card__cell--today .calendar-card__sub {
|
|
||||||
color: rgba(255, 255, 255, 0.84);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const quoteFontSize = computed(() => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.34rem;
|
gap: 0.34rem;
|
||||||
padding: 0.72rem 1.02rem;
|
padding: 0.72rem 1.02rem;
|
||||||
background: linear-gradient(180deg, rgba(255, 248, 230, 0.96), rgba(255, 252, 243, 0.94));
|
background: #ffffff;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,12 +50,13 @@ const quoteFontSize = computed(() => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
color: #c75d00;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-card__icon {
|
.quote-card__icon {
|
||||||
width: 0.9rem;
|
width: 0.9rem;
|
||||||
height: 0.9rem;
|
height: 0.9rem;
|
||||||
|
filter: brightness(0) saturate(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-card__title {
|
.quote-card__title {
|
||||||
@@ -66,6 +67,6 @@ const quoteFontSize = computed(() => {
|
|||||||
.quote-card__content {
|
.quote-card__content {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.34;
|
line-height: 1.34;
|
||||||
color: #292929;
|
color: #000000;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import WeatherGlyph from './WeatherGlyph.vue';
|
import WeatherGlyph from './WeatherGlyph.vue';
|
||||||
|
import {
|
||||||
|
HUMIDITY_ICON_ASSET,
|
||||||
|
SUNRISE_ICON_ASSET,
|
||||||
|
SUNSET_ICON_ASSET,
|
||||||
|
VISIBILITY_ICON_ASSET,
|
||||||
|
WIND_SPEED_ICON_ASSET,
|
||||||
|
} from '@/lib/icon-assets';
|
||||||
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
|
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -54,11 +61,13 @@ const metrics = computed(() => {
|
|||||||
label: '日出',
|
label: '日出',
|
||||||
value: props.weather.sunrise,
|
value: props.weather.sunrise,
|
||||||
accent: 'metric-pill--sunrise',
|
accent: 'metric-pill--sunrise',
|
||||||
|
icon: SUNRISE_ICON_ASSET,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '日落',
|
label: '日落',
|
||||||
value: props.weather.sunset,
|
value: props.weather.sunset,
|
||||||
accent: 'metric-pill--sunset',
|
accent: 'metric-pill--sunset',
|
||||||
|
icon: SUNSET_ICON_ASSET,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '空气质量',
|
label: '空气质量',
|
||||||
@@ -67,11 +76,13 @@ const metrics = computed(() => {
|
|||||||
? '暂无'
|
? '暂无'
|
||||||
: `${props.weather.aqi}${props.weather.aqiLabel}`,
|
: `${props.weather.aqi}${props.weather.aqiLabel}`,
|
||||||
accent: 'metric-pill--air',
|
accent: 'metric-pill--air',
|
||||||
|
icon: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '能见度',
|
label: '能见度',
|
||||||
value: `${props.weather.visibilityKm} km`,
|
value: `${props.weather.visibilityKm} km`,
|
||||||
accent: 'metric-pill--visibility',
|
accent: 'metric-pill--visibility',
|
||||||
|
icon: VISIBILITY_ICON_ASSET,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
});
|
});
|
||||||
@@ -115,11 +126,11 @@ function forecastKind(day: ForecastDay) {
|
|||||||
|
|
||||||
<div class="weather-card__facts">
|
<div class="weather-card__facts">
|
||||||
<div class="weather-card__fact">
|
<div class="weather-card__fact">
|
||||||
<span class="weather-card__fact-dot weather-card__fact-dot--humidity" />
|
<img class="weather-card__fact-icon" :src="HUMIDITY_ICON_ASSET" alt="" aria-hidden="true" />
|
||||||
<span>湿度 {{ weather.humidity }}%</span>
|
<span>湿度 {{ weather.humidity }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="weather-card__fact">
|
<div class="weather-card__fact">
|
||||||
<span class="weather-card__fact-dot weather-card__fact-dot--wind" />
|
<img class="weather-card__fact-icon" :src="WIND_SPEED_ICON_ASSET" alt="" aria-hidden="true" />
|
||||||
<span>风速 {{ weather.windSpeed }} km/h</span>
|
<span>风速 {{ weather.windSpeed }} km/h</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +155,14 @@ function forecastKind(day: ForecastDay) {
|
|||||||
:class="['metric-pill', metric.accent]"
|
:class="['metric-pill', metric.accent]"
|
||||||
>
|
>
|
||||||
<div class="metric-pill__label">
|
<div class="metric-pill__label">
|
||||||
<span class="metric-pill__dot" />
|
<img
|
||||||
|
v-if="metric.icon"
|
||||||
|
class="metric-pill__icon"
|
||||||
|
:src="metric.icon"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span v-else class="metric-pill__dot" />
|
||||||
<span>{{ metric.label }}</span>
|
<span>{{ metric.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="metric-pill__value">{{ metric.value }}</p>
|
<p class="metric-pill__value">{{ metric.value }}</p>
|
||||||
@@ -159,8 +177,8 @@ function forecastKind(day: ForecastDay) {
|
|||||||
grid-template-rows: auto minmax(0, 1.15fr) minmax(0, 0.9fr) minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1.15fr) minmax(0, 0.9fr) minmax(0, 1fr);
|
||||||
align-content: stretch;
|
align-content: stretch;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: 0.65rem;
|
gap: 0.72rem;
|
||||||
padding: 1.05rem 1.1rem 0.92rem;
|
padding: 1.08rem 1.12rem 0.98rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,16 +194,16 @@ function forecastKind(day: ForecastDay) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__title {
|
.weather-card__title {
|
||||||
font-size: 2.2rem;
|
font-size: 2.16rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #111111;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__subtitle {
|
.weather-card__subtitle {
|
||||||
margin-top: 0.14rem;
|
margin-top: 0.14rem;
|
||||||
font-size: 1.18rem;
|
font-size: 1.12rem;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
color: #707070;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__hero {
|
.weather-card__hero {
|
||||||
@@ -196,13 +214,14 @@ function forecastKind(day: ForecastDay) {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.88rem 0.94rem;
|
padding: 0.88rem 0.94rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: linear-gradient(180deg, #dfeaf8, #d7e4f6);
|
border: 2px solid var(--frame-stroke);
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__hero--placeholder {
|
.weather-card__hero--placeholder {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 5.75rem;
|
min-height: 5.75rem;
|
||||||
color: #617288;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__hero-main {
|
.weather-card__hero-main {
|
||||||
@@ -215,14 +234,14 @@ function forecastKind(day: ForecastDay) {
|
|||||||
.weather-card__temperature {
|
.weather-card__temperature {
|
||||||
font-size: 2.8rem;
|
font-size: 2.8rem;
|
||||||
line-height: 0.94;
|
line-height: 0.94;
|
||||||
color: #111111;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__condition {
|
.weather-card__condition {
|
||||||
margin-top: 0.18rem;
|
margin-top: 0.18rem;
|
||||||
font-size: 1.36rem;
|
font-size: 1.36rem;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
color: #2c3641;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__facts {
|
.weather-card__facts {
|
||||||
@@ -237,13 +256,21 @@ function forecastKind(day: ForecastDay) {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
color: #617288;
|
color: #000000;
|
||||||
font-size: 1.08rem;
|
font-size: 1.08rem;
|
||||||
line-height: 1.06;
|
line-height: 1.06;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__fact-dot,
|
.weather-card__fact-icon,
|
||||||
|
.metric-pill__icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
object-fit: contain;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
filter: brightness(0) saturate(100%);
|
||||||
|
}
|
||||||
|
|
||||||
.metric-pill__dot {
|
.metric-pill__dot {
|
||||||
width: 0.42rem;
|
width: 0.42rem;
|
||||||
height: 0.42rem;
|
height: 0.42rem;
|
||||||
@@ -252,14 +279,6 @@ function forecastKind(day: ForecastDay) {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__fact-dot--humidity {
|
|
||||||
color: #5c84be;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-card__fact-dot--wind {
|
|
||||||
color: #6c7e95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-card__forecast {
|
.weather-card__forecast {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
@@ -275,7 +294,8 @@ function forecastKind(day: ForecastDay) {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.68rem 0.28rem;
|
padding: 0.68rem 0.28rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: #f8f7f6;
|
border: 2px solid var(--frame-stroke);
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forecast-pill__label,
|
.forecast-pill__label,
|
||||||
@@ -286,19 +306,19 @@ function forecastKind(day: ForecastDay) {
|
|||||||
.forecast-pill__label {
|
.forecast-pill__label {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #5a5a5a;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forecast-pill__temp {
|
.forecast-pill__temp {
|
||||||
font-size: 1.24rem;
|
font-size: 1.24rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #1e1e1e;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forecast-pill__temp span {
|
.forecast-pill__temp span {
|
||||||
margin-left: 0.12rem;
|
margin-left: 0.12rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #7a7a7a;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card__metrics {
|
.weather-card__metrics {
|
||||||
@@ -316,6 +336,9 @@ function forecastKind(day: ForecastDay) {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.68rem 0.74rem;
|
padding: 0.68rem 0.74rem;
|
||||||
border-radius: 0.95rem;
|
border-radius: 0.95rem;
|
||||||
|
border: 2px solid var(--frame-stroke);
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-pill__label,
|
.metric-pill__label,
|
||||||
@@ -334,31 +357,12 @@ function forecastKind(day: ForecastDay) {
|
|||||||
.metric-pill__value {
|
.metric-pill__value {
|
||||||
font-size: 1.34rem;
|
font-size: 1.34rem;
|
||||||
line-height: 1.02;
|
line-height: 1.02;
|
||||||
color: #1c1c1c;
|
color: #000000;
|
||||||
}
|
|
||||||
|
|
||||||
.metric-pill--sunrise {
|
|
||||||
background: #fff1dd;
|
|
||||||
color: #cb6800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-pill--sunset {
|
|
||||||
background: #efe4ff;
|
|
||||||
color: #8e42e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-pill--air {
|
|
||||||
background: #dbf5df;
|
|
||||||
color: #1f9451;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-pill--visibility {
|
|
||||||
background: #d8f4fa;
|
|
||||||
color: #17779d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-card :deep(.glyph--large) {
|
.weather-card :deep(.glyph--large) {
|
||||||
width: 2.8rem;
|
width: 2.8rem;
|
||||||
height: 2.8rem;
|
height: 2.8rem;
|
||||||
|
filter: brightness(0) saturate(100%);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const source = computed(() => weatherIconForKind(props.kind));
|
|||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
filter: brightness(0) saturate(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glyph--large {
|
.glyph--large {
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import bookIcon from '../../../assets/书摘.svg';
|
|||||||
import cloudyIcon from '../../../assets/多云.svg';
|
import cloudyIcon from '../../../assets/多云.svg';
|
||||||
import heavyRainIcon from '../../../assets/大雨.svg';
|
import heavyRainIcon from '../../../assets/大雨.svg';
|
||||||
import snowIcon from '../../../assets/大雪.svg';
|
import snowIcon from '../../../assets/大雪.svg';
|
||||||
|
import sunriseIcon from '../../../assets/日出.svg';
|
||||||
|
import sunsetIcon from '../../../assets/日落.svg';
|
||||||
import lightRainIcon from '../../../assets/小雨.svg';
|
import lightRainIcon from '../../../assets/小雨.svg';
|
||||||
import nightIcon from '../../../assets/晚上.svg';
|
import nightIcon from '../../../assets/晚上.svg';
|
||||||
import clearIcon from '../../../assets/晴天.svg';
|
import clearIcon from '../../../assets/晴天.svg';
|
||||||
|
import humidityIcon from '../../../assets/湿度.svg';
|
||||||
|
import visibilityIcon from '../../../assets/能见度.svg';
|
||||||
import sleetIcon from '../../../assets/雨夹雪.svg';
|
import sleetIcon from '../../../assets/雨夹雪.svg';
|
||||||
|
import windSpeedIcon from '../../../assets/风速.svg';
|
||||||
|
|
||||||
export type WeatherIconKind =
|
export type WeatherIconKind =
|
||||||
| 'clear'
|
| 'clear'
|
||||||
@@ -37,3 +42,8 @@ export function weatherIconForKind(kind: WeatherIconKind) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const QUOTE_ICON_ASSET = bookIcon;
|
export const QUOTE_ICON_ASSET = bookIcon;
|
||||||
|
export const HUMIDITY_ICON_ASSET = humidityIcon;
|
||||||
|
export const WIND_SPEED_ICON_ASSET = windSpeedIcon;
|
||||||
|
export const SUNRISE_ICON_ASSET = sunriseIcon;
|
||||||
|
export const SUNSET_ICON_ASSET = sunsetIcon;
|
||||||
|
export const VISIBILITY_ICON_ASSET = visibilityIcon;
|
||||||
|
|||||||
@@ -4,11 +4,14 @@
|
|||||||
'PingFang SC',
|
'PingFang SC',
|
||||||
'Noto Sans SC',
|
'Noto Sans SC',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
color: #111111;
|
color: #000000;
|
||||||
background:
|
background: #ffffff;
|
||||||
radial-gradient(circle at 12% 10%, rgba(255, 255, 255, 0.9), transparent 26%),
|
--dashboard-width: 1072px;
|
||||||
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.74), transparent 22%),
|
--dashboard-height: 1448px;
|
||||||
linear-gradient(150deg, #fbe9e8 0%, #f2d7d7 48%, #eed6d5 100%);
|
--ink: #000000;
|
||||||
|
--paper: #ffffff;
|
||||||
|
--frame-stroke: #8b6b47;
|
||||||
|
--frame-stroke-strong: #6f5235;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -23,7 +26,9 @@ html,
|
|||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -39,45 +44,37 @@ img {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.75rem;
|
padding: 0;
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-shell--clock-face {
|
.page-shell--clock-face {
|
||||||
background: #f5f2ef;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-frame {
|
.dashboard-frame {
|
||||||
width: 100%;
|
width: min(100vw, var(--dashboard-width));
|
||||||
aspect-ratio: 1024 / 600;
|
aspect-ratio: 1072 / 1448;
|
||||||
padding: 1rem;
|
background: var(--paper);
|
||||||
border-radius: 1.9rem;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)),
|
|
||||||
rgba(255, 255, 255, 0.16);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.62),
|
|
||||||
0 24px 46px rgba(99, 64, 66, 0.18);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr);
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||||
grid-template-rows: minmax(0, 1fr) 72px;
|
grid-template-rows: minmax(0, 1fr) 168px;
|
||||||
gap: 1rem;
|
gap: 1.25rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 1.4rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-radius: 1.75rem;
|
border-radius: 2rem;
|
||||||
background: rgba(255, 255, 255, 0.94);
|
background: var(--paper);
|
||||||
box-shadow:
|
border: 2px solid var(--frame-stroke);
|
||||||
0 16px 34px rgba(92, 67, 60, 0.18),
|
box-shadow: none;
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
|
||||||
border: 1px solid rgba(117, 80, 76, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock-face-stage {
|
.clock-face-stage {
|
||||||
@@ -87,6 +84,12 @@ img {
|
|||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.page-shell {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.dashboard-frame {
|
.dashboard-frame {
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
@@ -95,6 +98,7 @@ img {
|
|||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: none;
|
grid-template-rows: none;
|
||||||
|
padding: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-shell {
|
.page-shell {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ This repo only contains the code that runs on the Kindle. It periodically fetche
|
|||||||
|
|
||||||
This code _does not_ render the dashboard itself. It's expected that what to display on the screen is rendered elsewhere and can be fetched via HTTP(s). This is both more power efficient and allows you to use any tool you like to produce the dashboard image.
|
This code _does not_ render the dashboard itself. It's expected that what to display on the screen is rendered elsewhere and can be fetched via HTTP(s). This is both more power efficient and allows you to use any tool you like to produce the dashboard image.
|
||||||
|
|
||||||
In the current Voyage layered-clock setup, the Kindle only fetches a low-frequency `kindlebg.png` background. The clock face and hand patches are synced as local assets and re-drawn on-device once per minute without network access.
|
In the current Voyage layered-clock setup, the Kindle only fetches a low-frequency `kindlebg.png` background. The clock region itself is re-rendered on-device once per minute with a local Lua script and FBInk, without network access.
|
||||||
|
|
||||||
In my case I use a [dashbling](https://github.com/pascalw/dashbling) dashboard that I render into a PNG screenshot on a server. See [here](https://github.com/pascalw/kindle-dash/blob/main/docs/tipstricks.md#producing-dashboard-images-from-a-webpage) for information on how these PNGs should be produced, including some sample code.
|
In my case I use a [dashbling](https://github.com/pascalw/dashbling) dashboard that I render into a PNG screenshot on a server. See [here](https://github.com/pascalw/kindle-dash/blob/main/docs/tipstricks.md#producing-dashboard-images-from-a-webpage) for information on how these PNGs should be produced, including some sample code.
|
||||||
|
|
||||||
@@ -27,6 +27,8 @@ In my case I use a [dashbling](https://github.com/pascalw/dashbling) dashboard t
|
|||||||
3. Copy the files to the Kindle, for example: `rsync -vr ./ kindle:/mnt/us/dashboard`.
|
3. Copy the files to the Kindle, for example: `rsync -vr ./ kindle:/mnt/us/dashboard`.
|
||||||
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`.
|
||||||
|
This now stops `dash.sh` and restores the Kindle framework.
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ If you're running kindle-dash already and want to update to the latest version f
|
|||||||
4. Modify files in `/mnt/us/dashboard/local` if applicable.
|
4. Modify files in `/mnt/us/dashboard/local` if applicable.
|
||||||
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.
|
||||||
|
|
||||||
## KUAL
|
## KUAL
|
||||||
|
|
||||||
@@ -48,18 +51,18 @@ If you're using KUAL you can use simple extension to start this Dashboard
|
|||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
For on-device debugging without suspending the Kindle, set `DISABLE_SYSTEM_SUSPEND=true` in `local/env.sh`.
|
For on-device debugging without suspending the Kindle, set `DISABLE_SYSTEM_SUSPEND=true` in `local/env.sh`.
|
||||||
The dashboard loop will keep running and use a normal `sleep` between refreshes instead of writing to `/sys/power/state`.
|
The dashboard loop will keep running, skip the `sleeping.png` branch, and use a normal `sleep` between refreshes instead of writing to `/sys/power/state`.
|
||||||
|
|
||||||
If you're connected over SSH you can also run `DEBUG=true ./start.sh` to keep the process in the foreground with shell tracing enabled.
|
If you're connected over SSH you can also run `DEBUG=true ./start.sh` to keep the process in the foreground with shell tracing enabled.
|
||||||
If you're launching from KUAL, use `Dashboard Debug On` before a normal start to persistently disable suspend in `local/env.sh`, and `Dashboard Debug Off` when you want to restore the normal low-power behavior.
|
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.
|
||||||
Both actions stop the current dashboard process so the new setting takes effect on the next start.
|
|
||||||
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.
|
||||||
|
|
||||||
## How this works
|
## How this works
|
||||||
|
|
||||||
* This code periodically downloads a dashboard background image from an HTTP(s) endpoint.
|
* This code periodically downloads a dashboard background image from an HTTP(s) endpoint.
|
||||||
* The interval can be configured in `dist/local/env.sh` using a cron expression.
|
* The interval can be configured in `dist/local/env.sh` using a cron expression.
|
||||||
* When layered clock assets are present, the Kindle re-renders the clock patch locally every minute.
|
* When the layered clock renderer is enabled, the Kindle re-renders the clock region locally every minute.
|
||||||
* During the update intervals the device is suspended to RAM to save power.
|
* During the update intervals the device is suspended to RAM to save power.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
@@ -67,6 +70,7 @@ If you're connected over SSH and only want a one-off foreground session, you can
|
|||||||
* The releases contain a pre-compiled binary of the [ht](https://github.com/ducaale/ht) command-line HTTP client. This fully supports modern HTTPS crypto, wheras the built-in `curl` and `wget` commands don't (because they rely on a very old `openssl` library).
|
* The releases contain a pre-compiled binary of the [ht](https://github.com/ducaale/ht) command-line HTTP client. This fully supports modern HTTPS crypto, wheras the built-in `curl` and `wget` commands don't (because they rely on a very old `openssl` library).
|
||||||
* For a detailed Kindle Voyage 5.13.6 jailbreak and deployment walkthrough, see [docs/kindle-voyage-5.13.6-watchthis-zh.md](./docs/kindle-voyage-5.13.6-watchthis-zh.md).
|
* For a detailed Kindle Voyage 5.13.6 jailbreak and deployment walkthrough, see [docs/kindle-voyage-5.13.6-watchthis-zh.md](./docs/kindle-voyage-5.13.6-watchthis-zh.md).
|
||||||
* For a detailed same-device dashboard/SSH troubleshooting playbook based on the 2026-03-15 session, see [docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md](./docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md).
|
* For a detailed same-device dashboard/SSH troubleshooting playbook based on the 2026-03-15 session, see [docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md](./docs/kindle-voyage-5.13.6-dual-ssh-playbook-zh.md).
|
||||||
|
* For the current white-screen/KUAL/SSH blocked-state handoff, see [docs/kindle-voyage-5.13.6-white-screen-handoff-zh.md](./docs/kindle-voyage-5.13.6-white-screen-handoff-zh.md).
|
||||||
* For the layered clock split and runtime model, see [docs/layered-clock-plan.zh.md](./docs/layered-clock-plan.zh.md).
|
* For the layered clock split and runtime model, see [docs/layered-clock-plan.zh.md](./docs/layered-clock-plan.zh.md).
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|||||||
@@ -85,12 +85,10 @@ ssh kindle
|
|||||||
调试 dashboard 时,先在 KUAL 中执行:
|
调试 dashboard 时,先在 KUAL 中执行:
|
||||||
|
|
||||||
1. `Dashboard Debug On`
|
1. `Dashboard Debug On`
|
||||||
2. `Kindle Dashboard`
|
|
||||||
|
|
||||||
调试结束后再执行:
|
调试结束后再执行:
|
||||||
|
|
||||||
1. `Dashboard Debug Off`
|
1. `Dashboard Debug Off`
|
||||||
2. `Kindle Dashboard`
|
|
||||||
|
|
||||||
相关脚本:
|
相关脚本:
|
||||||
|
|
||||||
@@ -207,8 +205,7 @@ bin/dropbearmulti dropbear -F -E -p 22 -P /mnt/us/usbnet/run/dropbear-force-22.p
|
|||||||
### B. Dashboard 调试
|
### B. Dashboard 调试
|
||||||
|
|
||||||
1. KUAL -> `Dashboard Debug On`
|
1. KUAL -> `Dashboard Debug On`
|
||||||
2. KUAL -> `Kindle Dashboard`
|
2. 调试完成后再 `Dashboard Debug Off`
|
||||||
3. 调试完成后再 `Dashboard Debug Off`
|
|
||||||
|
|
||||||
### C. SSH 启动的最短稳定路线
|
### C. SSH 启动的最短稳定路线
|
||||||
|
|
||||||
|
|||||||
317
dash/docs/kindle-voyage-5.13.6-white-screen-handoff-zh.md
Normal file
317
dash/docs/kindle-voyage-5.13.6-white-screen-handoff-zh.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# Kindle Voyage 5.13.6 白屏/KUAL/SSH 交接文档
|
||||||
|
|
||||||
|
本文记录 2026-03-15 这轮 dashboard 调试在后半段进入的异常状态,目标是给下一次接手排障的人一个明确起点,避免继续沿着已经证伪或高风险的路径重复试错。
|
||||||
|
|
||||||
|
## 当前交接状态
|
||||||
|
|
||||||
|
截至本次更新时,设备不再处于“完全卡死且无法 SSH”的状态,而是进入了一个更窄的失败场景:
|
||||||
|
|
||||||
|
- Kindle 已能回到主页
|
||||||
|
- `ssh kindle` 已恢复可用
|
||||||
|
- 从 `KUAL -> Kindle Dashboard` 进入 dashboard 时,仍会复现白屏
|
||||||
|
- 白屏出现时,dashboard 本身往往没有真正接管成功,更像是 `framework/KUAL` 启动链在中途被打断
|
||||||
|
- 当前最稳定的恢复路径,仍然是通过 SSH 执行 `./stop.sh`
|
||||||
|
|
||||||
|
这份文档只记录当前交接结论,不再继续尝试修复。
|
||||||
|
|
||||||
|
## 已确认的事实
|
||||||
|
|
||||||
|
### 1. `Dashboard Debug On` 已能阻止自动挂起
|
||||||
|
|
||||||
|
这部分在设备可连 SSH 时已经实机验证通过:
|
||||||
|
|
||||||
|
- [dash/src/debug-on.sh](/Users/gavin/kindle-dash/dash/src/debug-on.sh) 现在会:
|
||||||
|
- 把 `DISABLE_SYSTEM_SUSPEND=true` 写入 `local/env.sh`
|
||||||
|
- 自动重启 dashboard
|
||||||
|
- 日志里已经出现过:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Skipping system suspend, sleeping for 40s instead
|
||||||
|
```
|
||||||
|
|
||||||
|
所以“点 `Dashboard Debug On` 之后 3 秒就休眠”这个问题,本轮已经修住。
|
||||||
|
|
||||||
|
### 2. `dashboard` 模式不是可交互界面
|
||||||
|
|
||||||
|
当前实现里,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#L51) 停掉 `webreader`
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 进入 dashboard 之后,不应再期望当前屏幕仍然像普通 Kindle 页面那样可点击
|
||||||
|
- 也不应再期待“从 dashboard 直接返回刚才那个 KUAL 页面”
|
||||||
|
|
||||||
|
### 3. 顶栏遮罩不处理触摸
|
||||||
|
|
||||||
|
右上角状态栏遮罩逻辑在:
|
||||||
|
|
||||||
|
- [dash/src/dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh#L121)
|
||||||
|
|
||||||
|
它只是调用 `fbink` 在帧缓冲上画白色矩形,不负责输入,也不会接管触摸事件。因此:
|
||||||
|
|
||||||
|
- “点不到 KUAL” 不是顶栏遮罩造成的
|
||||||
|
- 真正相关的是 `framework/webreader` 被停掉
|
||||||
|
|
||||||
|
### 4. `stop.sh` 现在只负责恢复 UI 栈,不负责直接打开 KUAL
|
||||||
|
|
||||||
|
当前 [dash/src/stop.sh](/Users/gavin/kindle-dash/dash/src/stop.sh) 已改成:
|
||||||
|
|
||||||
|
- 停掉 `dash.sh`
|
||||||
|
- 清掉 `preventScreenSaver`
|
||||||
|
- 启动 `framework`
|
||||||
|
- 启动 `webreader`
|
||||||
|
|
||||||
|
也就是说它的职责是:
|
||||||
|
|
||||||
|
- 让 Kindle 回到“应该可以恢复正常 UI”的状态
|
||||||
|
|
||||||
|
不是:
|
||||||
|
|
||||||
|
- 直接把 KUAL booklet 弹出来
|
||||||
|
|
||||||
|
补充一点:在白屏恢复过程中,`stop.sh` 已经比旧版稳定很多,但仍存在一种残留状态:
|
||||||
|
|
||||||
|
- `framework` 和 `cvm` 已回来了
|
||||||
|
- `webreader` 可能还停在 `stop/waiting`
|
||||||
|
|
||||||
|
这时手工再执行一次 `start webreader`,主页通常就能回来。
|
||||||
|
|
||||||
|
### 5. 直接 `booklet run` 的试探命令不安全
|
||||||
|
|
||||||
|
本轮为了验证能否从 shell 里直接拉起主页或 KUAL,试过两类命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
lipc-set-prop com.lab126.booklet run "app://com.lab126.booklet.home"
|
||||||
|
lipc-set-prop com.lab126.booklet run "com.mobileread.ixtab.kindlelauncher.KualBooklet"
|
||||||
|
```
|
||||||
|
|
||||||
|
这条路不稳定,已经触发过 `cvm` 崩溃打包。设备上看到过:
|
||||||
|
|
||||||
|
- `/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`
|
||||||
|
|
||||||
|
因此下次接手时,不要再直接复用这两条命令。
|
||||||
|
|
||||||
|
### 6. dashboard 本身可以工作,失败更像发生在 KUAL 启动路径
|
||||||
|
|
||||||
|
本轮已经验证过:
|
||||||
|
|
||||||
|
- 通过 SSH 直接前台运行 `DEBUG=true ./start.sh`,dashboard 可以正常渲染
|
||||||
|
- 时钟、背景图、顶栏遮罩都能按预期执行
|
||||||
|
- 前台日志里可以看到正常的刷新过程
|
||||||
|
|
||||||
|
这说明:
|
||||||
|
|
||||||
|
- dashboard 渲染逻辑本身不是当前白屏问题的主因
|
||||||
|
- 真正未解的是 `KUAL -> start.sh -> dash.sh` 这条非调试、后台化启动路径
|
||||||
|
|
||||||
|
### 7. 白屏时,帧缓冲本身就是白的,不是单纯 e-ink 残影
|
||||||
|
|
||||||
|
本轮抓过多次 `fbgrab`:
|
||||||
|
|
||||||
|
- `tmp/current-ui.png`
|
||||||
|
- `tmp/ui-restart-screen.png`
|
||||||
|
- `tmp/ui-after-power-cycle.png`
|
||||||
|
|
||||||
|
这些截图都是真正的纯白图,不是“系统其实起来了,只是屏幕没刷新”。因此:
|
||||||
|
|
||||||
|
- 白屏发生时,不能只从物理屏幕角度判断
|
||||||
|
- 需要继续围绕 `framework / cvm / webreader / dash.sh` 的实际进程状态排查
|
||||||
|
|
||||||
|
### 8. 日志证据表明:KUAL 切到 dashboard 的过程中,framework 主进程被 TERM
|
||||||
|
|
||||||
|
这轮从 `/var/log/messages` 里已经看到关键序列:
|
||||||
|
|
||||||
|
- KUAL booklet 被启动
|
||||||
|
- home booklet 被恢复
|
||||||
|
- 随后 `framework main process (...) killed by TERM signal`
|
||||||
|
|
||||||
|
这说明当前最可疑的点是:
|
||||||
|
|
||||||
|
- KUAL 页面触发 dashboard 启动时,父 UI 进程在切换链路中被自己或系统杀掉
|
||||||
|
- dashboard 又没有在这之前稳定脱离 KUAL 会话
|
||||||
|
- 结果就是前台白屏,而不是正常切入 dashboard
|
||||||
|
|
||||||
|
## 本轮过程中已验证过的有效路径
|
||||||
|
|
||||||
|
在问题进一步收敛前,以下链路是验证过可工作的:
|
||||||
|
|
||||||
|
### 1. 背景图链路
|
||||||
|
|
||||||
|
- 网页导出 `1072x1448` 的 `8-bit grayscale PNG`
|
||||||
|
- Kindle 直接显示这张背景图时,所见即所得
|
||||||
|
|
||||||
|
关键文件:
|
||||||
|
|
||||||
|
- [calendar/dist/kindlebg.png](/Users/gavin/kindle-dash/calendar/dist/kindlebg.png)
|
||||||
|
- [dash/src/local/fetch-dashboard.sh](/Users/gavin/kindle-dash/dash/src/local/fetch-dashboard.sh)
|
||||||
|
|
||||||
|
### 2. 本机时钟链路
|
||||||
|
|
||||||
|
黑块问题已经从“透明 PNG patch”切到“Lua 本机绘制”:
|
||||||
|
|
||||||
|
- [dash/src/local/render-clock.lua](/Users/gavin/kindle-dash/dash/src/local/render-clock.lua)
|
||||||
|
- [dash/src/local/render-clock.sh](/Users/gavin/kindle-dash/dash/src/local/render-clock.sh)
|
||||||
|
|
||||||
|
当设备 SSH 正常时,这条链路已经实机验证过:
|
||||||
|
|
||||||
|
- 背景正常
|
||||||
|
- 时钟可叠加
|
||||||
|
- 不再出现整块黑色 patch
|
||||||
|
|
||||||
|
### 3. Wi-Fi SSH 链路
|
||||||
|
|
||||||
|
目前仍可用的稳定入口是:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh kindle
|
||||||
|
```
|
||||||
|
|
||||||
|
它依赖本机 `~/.ssh/config` 中的:
|
||||||
|
|
||||||
|
- `HostName 192.168.72.3`
|
||||||
|
- `IdentityFile ~/.ssh/id_ed25519_git`
|
||||||
|
|
||||||
|
这一条在本次文档更新时已经恢复。
|
||||||
|
|
||||||
|
### 4. 直接从 SSH 前台启动 dashboard
|
||||||
|
|
||||||
|
当前唯一明确验证成功的 dashboard 启动方式是:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh kindle 'cd /mnt/us/dashboard && DEBUG=true ./start.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
这条路径的特点是:
|
||||||
|
|
||||||
|
- `dash.sh` 以前台方式运行
|
||||||
|
- 不依赖 KUAL 页面还活着
|
||||||
|
- 能直接看到 shell trace 和实时日志
|
||||||
|
|
||||||
|
相对地:
|
||||||
|
|
||||||
|
- 直接点 `KUAL -> Kindle Dashboard`
|
||||||
|
- 或通过普通 `./start.sh` 后台起进程
|
||||||
|
|
||||||
|
这两条路径目前都没有被证明稳定。
|
||||||
|
|
||||||
|
## 当前不要再做的事情
|
||||||
|
|
||||||
|
以下路径本轮已经证明风险高或收益低,下次接手前不要重复:
|
||||||
|
|
||||||
|
1. 不要再尝试“双击电源键 / 同时按翻页条”呼出 KUAL
|
||||||
|
|
||||||
|
- 当前仓库没有任何这类按键绑定实现
|
||||||
|
- 这条路没有现成机制可用
|
||||||
|
|
||||||
|
2. 不要再尝试 `booklet run` 直接拉起主页或 KUAL
|
||||||
|
|
||||||
|
- 已触发 `cvm` 崩溃
|
||||||
|
- 风险高于收益
|
||||||
|
|
||||||
|
3. 不要继续走“KUAL -> Dashboard -> 再返回 KUAL”的交互路径
|
||||||
|
|
||||||
|
- dashboard 启动后会停掉 `framework/webreader`
|
||||||
|
- 从逻辑上这就不是一个受支持的返回路径
|
||||||
|
|
||||||
|
4. 不要把 `KUAL -> Kindle Dashboard` 当成当前可用入口
|
||||||
|
|
||||||
|
- 这正是现在仍会复现白屏的路径
|
||||||
|
- 问题还没有修住
|
||||||
|
|
||||||
|
## 下一次接手的安全起点
|
||||||
|
|
||||||
|
下一次恢复排障时,请按这个顺序来:
|
||||||
|
|
||||||
|
### A. 先把设备恢复到正常 UI
|
||||||
|
|
||||||
|
1. 长按电源键约 40 秒重启
|
||||||
|
2. 先不要启动 dashboard
|
||||||
|
3. 先确认能正常回到 Kindle 首页
|
||||||
|
4. 再确认能正常打开 KUAL
|
||||||
|
|
||||||
|
### B. 再确认网络
|
||||||
|
|
||||||
|
只有在设备已经稳定回到正常 UI 后,才做这一步:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh kindle
|
||||||
|
```
|
||||||
|
|
||||||
|
如果仍然不通,再查:
|
||||||
|
|
||||||
|
- Kindle 是否连回同一个主 Wi-Fi
|
||||||
|
- IP 是否还是 `192.168.72.3`
|
||||||
|
- DropBear 是否还在监听 `22`
|
||||||
|
|
||||||
|
### C. 重新进入 dashboard 时的推荐方式
|
||||||
|
|
||||||
|
恢复后如果还要继续调 dashboard,当前建议只走这条路径:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh kindle 'cd /mnt/us/dashboard && DEBUG=true ./start.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 这条路径已经验证成功
|
||||||
|
- 可以直接看到日志
|
||||||
|
- 不依赖 KUAL 的 UI 切换链路
|
||||||
|
|
||||||
|
退出 dashboard 时:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh kindle 'cd /mnt/us/dashboard && ./stop.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
等几秒,让 UI 栈恢复,再从 Kindle 首页重新打开 KUAL。
|
||||||
|
|
||||||
|
如果执行完 `./stop.sh` 后主页仍然没有回来,再补:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh kindle 'start webreader'
|
||||||
|
```
|
||||||
|
|
||||||
|
不要从 dashboard 页面直接尝试回 KUAL。
|
||||||
|
|
||||||
|
### D. 当前真正待修的点
|
||||||
|
|
||||||
|
下次接手时,排障目标应该收敛到这一条:
|
||||||
|
|
||||||
|
- 为什么 `KUAL -> Kindle Dashboard` 会白屏,而 `ssh kindle 'DEBUG=true ./start.sh'` 却能正常显示
|
||||||
|
|
||||||
|
也就是说,重点应该放在:
|
||||||
|
|
||||||
|
- KUAL 菜单动作
|
||||||
|
- `start.sh` 的后台脱离方式
|
||||||
|
- `framework` 被 TERM 的时机
|
||||||
|
|
||||||
|
而不是继续怀疑背景图、时钟绘制或顶栏遮罩。
|
||||||
|
|
||||||
|
## 这轮涉及的关键文件
|
||||||
|
|
||||||
|
- [dash/src/dash.sh](/Users/gavin/kindle-dash/dash/src/dash.sh)
|
||||||
|
- [dash/src/start.sh](/Users/gavin/kindle-dash/dash/src/start.sh)
|
||||||
|
- [dash/src/stop.sh](/Users/gavin/kindle-dash/dash/src/stop.sh)
|
||||||
|
- [dash/src/debug-on.sh](/Users/gavin/kindle-dash/dash/src/debug-on.sh)
|
||||||
|
- [dash/src/debug-off.sh](/Users/gavin/kindle-dash/dash/src/debug-off.sh)
|
||||||
|
- [dash/src/local/env.sh](/Users/gavin/kindle-dash/dash/src/local/env.sh)
|
||||||
|
- [dash/src/local/render-clock.sh](/Users/gavin/kindle-dash/dash/src/local/render-clock.sh)
|
||||||
|
- [dash/src/local/render-clock.lua](/Users/gavin/kindle-dash/dash/src/local/render-clock.lua)
|
||||||
|
- [dash/docs/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)
|
||||||
|
|
||||||
|
## 最后结论
|
||||||
|
|
||||||
|
本轮后半段的主要问题已经不是 dashboard 页面本身,而是:
|
||||||
|
|
||||||
|
- dashboard 与 Kindle 原生 `framework/KUAL` 的边界切换不稳定
|
||||||
|
- `KUAL -> Kindle Dashboard` 这条启动链仍会白屏
|
||||||
|
- 直接用 shell 强拉 booklet 会触发前台 Java 崩溃
|
||||||
|
|
||||||
|
因此,当前最重要的不是继续调页面,而是:
|
||||||
|
|
||||||
|
1. 保留当前已经可用的 SSH 启动/停止路径
|
||||||
|
2. 修住 `KUAL -> Kindle Dashboard` 白屏
|
||||||
|
3. 在不再触发 `cvm` 崩溃的前提下,把“进入 dashboard”和“退出 dashboard”都收敛成稳定流程
|
||||||
@@ -53,6 +53,35 @@
|
|||||||
- 降低全屏刷新频率
|
- 降低全屏刷新频率
|
||||||
- 减少墨水屏闪烁与残影
|
- 减少墨水屏闪烁与残影
|
||||||
|
|
||||||
|
### 2.1 当前导出基线
|
||||||
|
|
||||||
|
基于 Kindle Voyage 系统自带屏保 `bg_default.png` 的实机验证,当前背景导出链路应遵守这几个固定约束:
|
||||||
|
|
||||||
|
- 网页成品页尺寸直接使用 `1072 x 1448`
|
||||||
|
- Kindle 主背景图直接导出为同尺寸,不再做额外旋转、补边或缩放
|
||||||
|
- 导出格式固定为 `8-bit grayscale PNG`
|
||||||
|
- 页面视觉尽量保持纯白底、纯黑文字与图标,避免大面积灰阶装饰
|
||||||
|
|
||||||
|
这部分不是临时调试参数,而是后续继续微调版式时的默认基准。
|
||||||
|
|
||||||
|
当前设备端时钟实现也已经切换为:
|
||||||
|
|
||||||
|
- Kindle 本机 `lua` 生成时钟区位图
|
||||||
|
- 通过 `fbink` 刷新指定矩形
|
||||||
|
|
||||||
|
在 Kindle Voyage 5.13.6 的当前环境里,还需要额外注意一点:
|
||||||
|
|
||||||
|
- `fbink` 叠加图片时必须带 `-V` / `--noviewport`
|
||||||
|
- 否则会因为 viewport 修正导致时钟区域纵向偏移
|
||||||
|
- 并且会在同一帧缓冲里出现两个重叠时钟
|
||||||
|
|
||||||
|
也就是说,Voyage 上本地时钟叠加的稳定命令形态应当是:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fbink -q -V -g "file=/mnt/us/dashboard/local/state/clock-render.pgm,x=$CLOCK_REGION_X,y=$CLOCK_REGION_Y"
|
||||||
|
```
|
||||||
|
- 不再依赖 `eips + 透明 PNG patch` 叠图
|
||||||
|
|
||||||
## 3. 分层边界
|
## 3. 分层边界
|
||||||
|
|
||||||
### 3.1 全屏背景层
|
### 3.1 全屏背景层
|
||||||
@@ -465,8 +494,22 @@ export CLOCK_REGION_Y=0
|
|||||||
export CLOCK_REGION_WIDTH=220
|
export CLOCK_REGION_WIDTH=220
|
||||||
export CLOCK_REGION_HEIGHT=220
|
export CLOCK_REGION_HEIGHT=220
|
||||||
export CLOCK_FULL_REFRESH_INTERVAL_MINUTES=15
|
export CLOCK_FULL_REFRESH_INTERVAL_MINUTES=15
|
||||||
|
export PRE_SLEEP_GRACE_SECONDS=10
|
||||||
```
|
```
|
||||||
|
|
||||||
|
补充两条运行期约束:
|
||||||
|
|
||||||
|
- `clock-index.sh` 取当前时间时必须沿用 `TIMEZONE`,不能直接读系统默认时区
|
||||||
|
- `PRE_SLEEP_GRACE_SECONDS` 这类“进入休眠前的可中断窗口”必须从实际休眠时长里扣掉,否则分钟刷新会长期落后一个节拍
|
||||||
|
|
||||||
|
当前实现里,时钟区域的适配已经分成两层:
|
||||||
|
|
||||||
|
- `CLOCK_REGION_X/Y/WIDTH/HEIGHT` 负责位置与尺寸
|
||||||
|
- `CLOCK_*` 外观参数负责指针长度、粗细、刻度长度和圆心点大小
|
||||||
|
|
||||||
|
因此,页面板式变化导致时钟区域变大变小时,一般不需要重画任何静态素材,也不需要改 Lua 代码;
|
||||||
|
只要重新导出网页拿到新的 `clock-region.json`,同步到 Kindle 后,本机时钟就会按新尺寸重画。
|
||||||
|
|
||||||
## 10. 实施顺序
|
## 10. 实施顺序
|
||||||
|
|
||||||
建议按下面顺序落地,避免一次改太多导致链路难排查。
|
建议按下面顺序落地,避免一次改太多导致链路难排查。
|
||||||
|
|||||||
@@ -16,12 +16,21 @@ SLEEP_SCREEN_INTERVAL=${SLEEP_SCREEN_INTERVAL:-3600}
|
|||||||
DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
|
DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
|
||||||
BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
|
BACKGROUND_REFRESH_INTERVAL_MINUTES=${BACKGROUND_REFRESH_INTERVAL_MINUTES:-120}
|
||||||
CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
|
CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
|
||||||
|
PRE_SLEEP_GRACE_SECONDS=${PRE_SLEEP_GRACE_SECONDS:-10}
|
||||||
|
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
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then
|
if [ -z "$TIMEZONE" ] || [ -z "$REFRESH_SCHEDULE" ]; then
|
||||||
@@ -38,16 +47,28 @@ init() {
|
|||||||
echo "System suspend disabled, using normal sleep between refreshes."
|
echo "System suspend disabled, using normal sleep between refreshes."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
/etc/init.d/framework stop
|
stop_framework
|
||||||
initctl stop webreader >/dev/null 2>&1
|
initctl stop webreader >/dev/null 2>&1
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -97,6 +118,27 @@ clock_force_full_refresh() {
|
|||||||
[ $((minute % CLOCK_FULL_REFRESH_INTERVAL_MINUTES)) -eq 0 ]
|
[ $((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() {
|
refresh_dashboard() {
|
||||||
background_refreshed=false
|
background_refreshed=false
|
||||||
|
|
||||||
@@ -116,6 +158,16 @@ refresh_dashboard() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
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
|
if [ "$background_refreshed" = true ] || clock_force_full_refresh; then
|
||||||
echo "Clock patch full refresh"
|
echo "Clock patch full refresh"
|
||||||
"$CLOCK_RENDER_CMD" true
|
"$CLOCK_RENDER_CMD" true
|
||||||
@@ -124,18 +176,40 @@ refresh_dashboard() {
|
|||||||
"$CLOCK_RENDER_CMD" false
|
"$CLOCK_RENDER_CMD" false
|
||||||
fi
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +234,31 @@ main_loop() {
|
|||||||
|
|
||||||
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"
|
rtc_sleep "$actual_sleep_secs"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ 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
|
||||||
|
sleep 1
|
||||||
|
"$DIR/start.sh"
|
||||||
|
|
||||||
echo "已关闭 Dashboard 调试模式。当前 Dashboard 已停止,请重新启动 Kindle Dashboard。"
|
echo "已关闭 Dashboard 调试模式,并自动重启 Kindle Dashboard。"
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ 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
|
||||||
|
sleep 1
|
||||||
|
"$DIR/start.sh"
|
||||||
|
|
||||||
echo "已开启 Dashboard 调试模式。当前 Dashboard 已停止,请重新启动 Kindle Dashboard。"
|
echo "已开启 Dashboard 调试模式,并自动重启 Kindle Dashboard。"
|
||||||
|
|||||||
@@ -11,8 +11,24 @@ to_decimal() {
|
|||||||
}'
|
}'
|
||||||
}
|
}
|
||||||
|
|
||||||
hour_value=${1:-$(date '+%H')}
|
current_clock_values() {
|
||||||
minute_value=${2:-$(date '+%M')}
|
# 时钟渲染要和 dashboard 配置的时区保持一致。
|
||||||
|
# 否则即使 next-wakeup 按 TIMEZONE 唤醒,指针仍会按系统默认时区取值。
|
||||||
|
if [ -n "${TIMEZONE:-}" ]; then
|
||||||
|
TZ="$TIMEZONE" date '+%H %M'
|
||||||
|
else
|
||||||
|
date '+%H %M'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
hour_decimal=$(to_decimal "$hour_value")
|
||||||
minute_decimal=$(to_decimal "$minute_value")
|
minute_decimal=$(to_decimal "$minute_value")
|
||||||
|
|||||||
@@ -13,6 +13,37 @@ export CLOCK_REGION_WIDTH=${CLOCK_REGION_WIDTH:-220}
|
|||||||
export CLOCK_REGION_HEIGHT=${CLOCK_REGION_HEIGHT:-220}
|
export CLOCK_REGION_HEIGHT=${CLOCK_REGION_HEIGHT:-220}
|
||||||
export CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES:-15}
|
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}
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
|
||||||
# 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,
|
||||||
# the screen will start to look a bit distorted (due to e-ink ghosting).
|
# the screen will start to look a bit distorted (due to e-ink ghosting).
|
||||||
@@ -21,7 +52,7 @@ export CLOCK_FULL_REFRESH_INTERVAL_MINUTES=${CLOCK_FULL_REFRESH_INTERVAL_MINUTES
|
|||||||
export FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
|
export FULL_DISPLAY_REFRESH_RATE=${FULL_DISPLAY_REFRESH_RATE:-0}
|
||||||
|
|
||||||
# 调试开关:设为 true 后,主循环仍会按计划拉图和刷新屏幕,
|
# 调试开关:设为 true 后,主循环仍会按计划拉图和刷新屏幕,
|
||||||
# 但不会把 Kindle 写入 /sys/power/state 进入系统挂起。
|
# 不会进入 sleeping.png 分支,也不会把 Kindle 写入 /sys/power/state 进入系统挂起。
|
||||||
# 适合通过 KUAL 或普通 start.sh 连续观察效果,调试结束后再改回 false。
|
# 适合通过 KUAL 或普通 start.sh 连续观察效果,调试结束后再改回 false。
|
||||||
export DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
|
export DISABLE_SYSTEM_SUSPEND=${DISABLE_SYSTEM_SUSPEND:-false}
|
||||||
|
|
||||||
|
|||||||
157
dash/src/local/render-clock.lua
Normal file
157
dash/src/local/render-clock.lua
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
-- 在 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_radius = math.floor(math.min(width, height) * number_arg(6, 0.47))
|
||||||
|
local face_stroke = number_arg(7, 3)
|
||||||
|
local tick_outer_inset = number_arg(8, 6)
|
||||||
|
local major_tick_length = number_arg(9, 14)
|
||||||
|
local minor_tick_length = number_arg(10, 7)
|
||||||
|
local major_tick_thickness = number_arg(11, 4)
|
||||||
|
local minor_tick_thickness = number_arg(12, 2)
|
||||||
|
local hour_length_ratio = number_arg(13, 0.48)
|
||||||
|
local minute_length_ratio = number_arg(14, 0.72)
|
||||||
|
local hour_thickness = number_arg(15, 9)
|
||||||
|
local minute_thickness = number_arg(16, 5)
|
||||||
|
local center_radius = number_arg(17, 7)
|
||||||
|
|
||||||
|
local 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_segment(x1, y1, x2, y2, thickness, value)
|
||||||
|
local dx = x2 - x1
|
||||||
|
local dy = y2 - y1
|
||||||
|
local steps = math.max(math.abs(dx), math.abs(dy))
|
||||||
|
|
||||||
|
if steps == 0 then
|
||||||
|
fill_disk(x1, y1, math.max(1, thickness / 2), value)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local radius = math.max(1, thickness / 2)
|
||||||
|
for step = 0, steps do
|
||||||
|
local t = step / steps
|
||||||
|
local x = x1 + dx * t
|
||||||
|
local y = y1 + dy * t
|
||||||
|
fill_disk(x, y, radius, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function draw_circle(radius, thickness, value)
|
||||||
|
local samples = 720
|
||||||
|
for step = 0, samples - 1 do
|
||||||
|
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 draw_ticks()
|
||||||
|
for tick = 0, 59 do
|
||||||
|
local is_major = tick % 5 == 0
|
||||||
|
local angle = (tick / 60) * math.pi * 2 - math.pi / 2
|
||||||
|
local outer = face_radius - tick_outer_inset
|
||||||
|
local inner = outer - (is_major and major_tick_length or minor_tick_length)
|
||||||
|
local x1 = cx + math.cos(angle) * inner
|
||||||
|
local y1 = cy + math.sin(angle) * inner
|
||||||
|
local x2 = cx + math.cos(angle) * outer
|
||||||
|
local y2 = cy + math.sin(angle) * outer
|
||||||
|
draw_segment(x1, y1, x2, y2, is_major and major_tick_thickness or minor_tick_thickness, BLACK)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hand_endpoint(angle_deg, length)
|
||||||
|
local angle = math.rad(angle_deg - 90)
|
||||||
|
return cx + math.cos(angle) * length, cy + math.sin(angle) * length
|
||||||
|
end
|
||||||
|
|
||||||
|
local function draw_hands()
|
||||||
|
local hour_angle = (hour_value % 12) * 30 + minute_value * 0.5
|
||||||
|
local minute_angle = minute_value * 6
|
||||||
|
|
||||||
|
local hour_x, hour_y = hand_endpoint(hour_angle, face_radius * hour_length_ratio)
|
||||||
|
local minute_x, minute_y = hand_endpoint(minute_angle, face_radius * minute_length_ratio)
|
||||||
|
|
||||||
|
draw_segment(cx, cy, hour_x, hour_y, hour_thickness, BLACK)
|
||||||
|
draw_segment(cx, cy, minute_x, minute_y, minute_thickness, BLACK)
|
||||||
|
fill_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()
|
||||||
@@ -2,31 +2,60 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
||||||
clock_face_path=${CLOCK_FACE_PATH:-"$DIR/../assets/clock-face.png"}
|
ENV_FILE="$DIR/env.sh"
|
||||||
hour_assets_dir=${CLOCK_HOUR_ASSETS_DIR:-"$DIR/../assets/hour-hand"}
|
|
||||||
minute_assets_dir=${CLOCK_MINUTE_ASSETS_DIR:-"$DIR/../assets/minute-hand"}
|
# 单独执行本脚本时,也需要读取同一份坐标配置。
|
||||||
clock_region_x=${CLOCK_REGION_X:-313}
|
# 否则会退回到脚本内默认值,导致手工调试与主循环绘制位置不一致。
|
||||||
clock_region_y=${CLOCK_REGION_Y:-0}
|
# shellcheck disable=SC1090
|
||||||
|
[ -f "$ENV_FILE" ] && . "$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_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_minute_length_ratio=${CLOCK_MINUTE_LENGTH_RATIO:-0.72}
|
||||||
|
clock_hour_thickness=${CLOCK_HOUR_THICKNESS:-9}
|
||||||
|
clock_minute_thickness=${CLOCK_MINUTE_THICKNESS:-5}
|
||||||
|
clock_center_radius=${CLOCK_CENTER_RADIUS:-7}
|
||||||
force_full_refresh=${1:-false}
|
force_full_refresh=${1:-false}
|
||||||
|
output_path="$DIR/state/clock-render.pgm"
|
||||||
|
|
||||||
eval "$("$DIR/clock-index.sh")"
|
eval "$("$DIR/clock-index.sh")"
|
||||||
|
|
||||||
hour_patch="$hour_assets_dir/$hour_index.png"
|
mkdir -p "$DIR/state"
|
||||||
minute_patch="$minute_assets_dir/$minute_index.png"
|
|
||||||
|
|
||||||
if [ ! -f "$clock_face_path" ] || [ ! -f "$hour_patch" ] || [ ! -f "$minute_patch" ]; then
|
lua "$DIR/render-clock.lua" \
|
||||||
echo "Clock assets missing."
|
"$output_path" \
|
||||||
echo "Face: $clock_face_path"
|
"$clock_region_width" \
|
||||||
echo "Hour: $hour_patch"
|
"$clock_region_height" \
|
||||||
echo "Minute: $minute_patch"
|
"$hour" \
|
||||||
exit 1
|
"$minute" \
|
||||||
fi
|
"$clock_face_radius_ratio" \
|
||||||
|
"$clock_face_stroke" \
|
||||||
|
"$clock_tick_outer_inset" \
|
||||||
|
"$clock_major_tick_length" \
|
||||||
|
"$clock_minor_tick_length" \
|
||||||
|
"$clock_major_tick_thickness" \
|
||||||
|
"$clock_minor_tick_thickness" \
|
||||||
|
"$clock_hour_length_ratio" \
|
||||||
|
"$clock_minute_length_ratio" \
|
||||||
|
"$clock_hour_thickness" \
|
||||||
|
"$clock_minute_thickness" \
|
||||||
|
"$clock_center_radius"
|
||||||
|
|
||||||
if [ "$force_full_refresh" = true ]; then
|
if [ "$force_full_refresh" = true ]; then
|
||||||
/usr/sbin/eips -f -g "$clock_face_path" -x "$clock_region_x" -y "$clock_region_y"
|
# Kindle Voyage 当前这条链路里,fbink 默认会叠加 viewport 修正,
|
||||||
|
# 导致图像在屏幕上出现双重偏移。这里强制关闭 viewport 修正,
|
||||||
|
# 让坐标与网页导出的像素坐标保持一致。
|
||||||
|
fbink -q -V -f -g "file=$output_path,x=$clock_region_x,y=$clock_region_y"
|
||||||
else
|
else
|
||||||
/usr/sbin/eips -g "$clock_face_path" -x "$clock_region_x" -y "$clock_region_y"
|
fbink -q -V -g "file=$output_path,x=$clock_region_x,y=$clock_region_y"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
/usr/sbin/eips -g "$hour_patch" -x "$clock_region_x" -y "$clock_region_y"
|
|
||||||
/usr/sbin/eips -g "$minute_patch" -x "$clock_region_x" -y "$clock_region_y"
|
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ mkdir -p "$(dirname "$LOG_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 脱离会话,否则后台的 dash.sh 会跟着收到 HUP 退出。
|
||||||
|
nohup "$DIR/dash.sh" >>"$LOG_FILE" 2>&1 </dev/null &
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,2 +1,56 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
pkill -f dash.sh
|
set -eu
|
||||||
|
|
||||||
|
# 退出 dashboard 时,不能只“启动一下 framework”就结束。
|
||||||
|
# Voyage 5.13.6 上白屏往往来自 framework / webreader / cvm 半恢复状态:
|
||||||
|
# 进程看起来在,但前台 Java UI 实际没起来。
|
||||||
|
# 这里统一做一次干净的 UI 栈重启,尽量把设备拉回正常首页/KUAL 可用状态。
|
||||||
|
|
||||||
|
stop_job() {
|
||||||
|
job_name=$1
|
||||||
|
stop "$job_name" >/dev/null 2>&1 || initctl stop "$job_name" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
start_job() {
|
||||||
|
job_name=$1
|
||||||
|
start "$job_name" >/dev/null 2>&1 || initctl start "$job_name" >/dev/null 2>&1 || 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
|
||||||
|
}
|
||||||
|
|
||||||
|
pkill -f dash.sh 2>/dev/null || true
|
||||||
|
pkill -f start.sh 2>/dev/null || true
|
||||||
|
|
||||||
|
lipc-set-prop com.lab126.powerd preventScreenSaver 0 2>/dev/null || true
|
||||||
|
|
||||||
|
# 先把残留 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
|
||||||
|
|
||||||
|
start_job webreader
|
||||||
|
sleep 2
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ clock_y=$(clock_region_value y)
|
|||||||
clock_width=$(clock_region_value width)
|
clock_width=$(clock_region_value width)
|
||||||
clock_height=$(clock_region_value height)
|
clock_height=$(clock_region_value height)
|
||||||
|
|
||||||
sh "$ROOT_DIR/scripts/sync-layered-clock-assets.sh" "$KINDLE_TARGET"
|
|
||||||
|
|
||||||
rsync -av --no-o --no-g \
|
rsync -av --no-o --no-g \
|
||||||
"$ROOT_DIR/dash/src/dash.sh" \
|
"$ROOT_DIR/dash/src/dash.sh" \
|
||||||
"$KINDLE_TARGET":/mnt/us/dashboard/
|
"$KINDLE_TARGET":/mnt/us/dashboard/
|
||||||
@@ -39,6 +37,7 @@ rsync -av --no-o --no-g \
|
|||||||
rsync -av --no-o --no-g \
|
rsync -av --no-o --no-g \
|
||||||
"$ROOT_DIR/dash/src/local/fetch-dashboard.sh" \
|
"$ROOT_DIR/dash/src/local/fetch-dashboard.sh" \
|
||||||
"$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.sh" \
|
"$ROOT_DIR/dash/src/local/render-clock.sh" \
|
||||||
"$KINDLE_TARGET":/mnt/us/dashboard/local/
|
"$KINDLE_TARGET":/mnt/us/dashboard/local/
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user