first commit

This commit is contained in:
douboer@gmail.com
2026-03-15 09:30:40 +08:00
commit 3d19c4d34f
145 changed files with 11623 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# 依赖目录
node_modules/
# 构建产物
**/dist/
**/coverage/
# 本地环境变量
.env
.env.*
!.env.example
# 日志与缓存
npm-debug.log*
.DS_Store
.npm-cache/
data/tts-cache/
# 小程序本地运维配置(由 .env 生成)
apps/miniprogram/utils/opsEnv.js
calendar/node_modules
dash/backups
dash/downloads
/dist
/kindle-dash-*.tgz
/tmp

12
calendar/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendar Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1226
calendar/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
calendar/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "calendar",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"lunar-typescript": "^1.8.6",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vue-tsc": "^3.2.5"
}
}

72
calendar/src/App.vue Normal file
View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import CalendarCard from '@/components/CalendarCard.vue';
import QuoteCard from '@/components/QuoteCard.vue';
import WeatherCard from '@/components/WeatherCard.vue';
import { buildCalendarModel } from '@/lib/calendar';
import { getQuoteForDate } from '@/lib/quotes';
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
const now = ref(new Date());
const location = ref<LocationCoordinates>({
latitude: 31.2304,
longitude: 121.4737,
label: '上海',
});
const weather = ref<WeatherSnapshot | null>(null);
const weatherStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle');
let clockTimer = 0;
let weatherTimer = 0;
const calendarModel = computed(() => buildCalendarModel(now.value));
const quoteEntry = computed(() => getQuoteForDate(now.value));
async function refreshWeather() {
weatherStatus.value = 'loading';
try {
weather.value = await fetchWeather(location.value);
weatherStatus.value = 'ready';
} catch (error) {
console.error('获取天气失败', error);
weatherStatus.value = 'error';
}
}
onMounted(async () => {
location.value = await resolveLocation();
await refreshWeather();
clockTimer = window.setInterval(() => {
now.value = new Date();
}, 30 * 1000);
// 天气不需要秒级刷新30 分钟一次足够。
weatherTimer = window.setInterval(() => {
void refreshWeather();
}, 30 * 60 * 1000);
});
onBeforeUnmount(() => {
window.clearInterval(clockTimer);
window.clearInterval(weatherTimer);
});
</script>
<template>
<main class="page-shell">
<div class="dashboard-frame">
<div class="dashboard-grid">
<CalendarCard :model="calendarModel" />
<WeatherCard
:weather="weather"
:status="weatherStatus"
:location-label="location.label"
/>
<QuoteCard :quote="quoteEntry.text" />
</div>
</div>
</main>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { CalendarModel } from '@/lib/calendar';
const props = defineProps<{
model: CalendarModel;
}>();
const weeks = computed(() => {
const chunks: CalendarModel['cells'][] = [];
for (let index = 0; index < props.model.cells.length; index += 7) {
chunks.push(props.model.cells.slice(index, index + 7));
}
return chunks;
});
</script>
<template>
<section class="card calendar-card">
<div class="calendar-card__header">
<div class="calendar-card__day">{{ model.largeDay }}</div>
<div class="calendar-card__meta">
<p class="calendar-card__date-line">
<span>{{ model.gregorianLabel }}</span>
<span class="calendar-card__weekday">{{ model.weekdayLabel }}</span>
</p>
<p class="calendar-card__lunar">
<span>{{ model.lunarYearLabel }}</span>
<span>{{ model.lunarDayLabel }}</span>
<span v-if="model.summaryBadges.length" class="calendar-card__badges calendar-card__badges--inline">
<span
v-for="badge in model.summaryBadges"
:key="badge.label"
:class="['calendar-card__badge', `calendar-card__badge--${badge.tone}`]"
>
{{ badge.label }}
</span>
</span>
</p>
</div>
</div>
<div class="calendar-card__grid">
<div v-for="label in model.weekLabels" :key="label" class="calendar-card__week-label">
{{ label }}
</div>
<template v-for="week in weeks" :key="week[0].date.toISOString()">
<div
v-for="cell in week"
:key="cell.date.toISOString()"
:class="[
'calendar-card__cell',
{
'calendar-card__cell--muted': !cell.currentMonth,
'calendar-card__cell--today': cell.isToday,
'calendar-card__cell--holiday': cell.badges.some((badge) => badge.tone === 'holiday'),
},
]"
>
<span class="calendar-card__solar">{{ cell.day }}</span>
<span class="calendar-card__lunar-day">{{ cell.subLabel }}</span>
</div>
</template>
</div>
</section>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
quote: string;
}>();
const quoteFontSize = computed(() => {
const length = props.quote.length;
if (length > 140) {
return '1.75rem';
}
if (length > 100) {
return '1.95rem';
}
return '2.2rem';
});
</script>
<template>
<section class="card quote-card">
<p class="quote-card__content" :style="{ fontSize: quoteFontSize }">{{ quote }}</p>
</section>
</template>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed } from 'vue';
import WeatherGlyph from './WeatherGlyph.vue';
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
const props = defineProps<{
weather: WeatherSnapshot | null;
status: 'idle' | 'loading' | 'ready' | 'error';
locationLabel: string;
}>();
function weatherKind(code: number) {
if ([0].includes(code)) {
return 'clear';
}
if ([1, 2].includes(code)) {
return 'partly';
}
if ([3].includes(code)) {
return 'cloudy';
}
if ([45, 48].includes(code)) {
return 'fog';
}
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) {
return 'rain';
}
if ([71, 73, 75, 77, 85, 86].includes(code)) {
return 'snow';
}
if ([95, 96, 99].includes(code)) {
return 'storm';
}
return 'cloudy';
}
const forecast = computed(() => props.weather?.forecast.slice(0, 5) ?? []);
const metrics = computed(() => {
if (!props.weather) {
return [];
}
return [
{
label: '日出',
value: props.weather.sunrise,
accent: 'metric--sunrise',
icon: 'sunrise',
},
{
label: '日落',
value: props.weather.sunset,
accent: 'metric--sunset',
icon: 'sunset',
},
{
label: '空气质量',
value:
props.weather.aqi === null
? '暂无'
: `${props.weather.aqi}${props.weather.aqiLabel}`,
accent: 'metric--air',
icon: 'air',
},
{
label: '能见度',
value: `${props.weather.visibilityKm} km`,
accent: 'metric--visibility',
icon: 'visibility',
},
] as const;
});
const stateLabel = computed(() => {
if (props.status === 'error') {
return '天气数据获取失败';
}
if (props.status === 'loading') {
return '天气数据加载中';
}
return props.locationLabel;
});
const currentWeatherKind = computed(() => weatherKind(props.weather?.weatherCode ?? 3));
function forecastKind(day: ForecastDay) {
return weatherKind(day.weatherCode);
}
</script>
<template>
<section class="card weather-card">
<div class="weather-card__heading">
<div>
<p class="weather-card__title">天气预报</p>
<p class="weather-card__subtitle">{{ stateLabel }}</p>
</div>
</div>
<div v-if="weather" class="weather-card__hero">
<div class="weather-card__hero-main">
<WeatherGlyph :kind="currentWeatherKind" large />
<div>
<div class="weather-card__temperature">{{ weather.temperature }}°C</div>
<div class="weather-card__condition">{{ weather.condition }}</div>
</div>
</div>
<div class="weather-card__facts">
<div class="weather-card__fact">
<WeatherGlyph kind="humidity" />
<span>湿度 {{ weather.humidity }}%</span>
</div>
<div class="weather-card__fact">
<WeatherGlyph kind="wind" />
<span>风速 {{ weather.windSpeed }} km/h</span>
</div>
</div>
</div>
<div v-else class="weather-card__hero weather-card__hero--placeholder">
<p>{{ stateLabel }}</p>
</div>
<div class="weather-card__forecast">
<article v-for="day in forecast" :key="day.label" class="forecast-pill">
<p class="forecast-pill__label">{{ day.label }}</p>
<WeatherGlyph :kind="forecastKind(day)" large />
<p class="forecast-pill__temp">{{ day.high }}°<span>{{ day.low }}°</span></p>
</article>
</div>
<div class="weather-card__metrics">
<article
v-for="metric in metrics"
:key="metric.label"
:class="['metric-pill', metric.accent]"
>
<div class="metric-pill__label">
<WeatherGlyph :kind="metric.icon" />
<span>{{ metric.label }}</span>
</div>
<p class="metric-pill__value">{{ metric.value }}</p>
</article>
</div>
</section>
</template>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed } from 'vue';
type WeatherGlyphKind =
| 'clear'
| 'partly'
| 'cloudy'
| 'fog'
| 'rain'
| 'snow'
| 'storm'
| 'sunrise'
| 'sunset'
| 'air'
| 'visibility'
| 'humidity'
| 'wind';
const props = defineProps<{
kind: WeatherGlyphKind;
large?: boolean;
}>();
// 统一收细线宽和轮廓,让图标在墨水屏和小尺寸卡片里更精致、更稳定。
const strokeWidth = computed(() => (props.large ? 1.95 : 1.8));
</script>
<template>
<svg
:class="['glyph', { 'glyph--large': props.large }]"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="strokeWidth"
aria-hidden="true"
>
<g v-if="props.kind === 'clear'">
<circle cx="24" cy="24" r="6.5" />
<line x1="24" y1="8.5" x2="24" y2="12.5" />
<line x1="24" y1="35.5" x2="24" y2="39.5" />
<line x1="8.5" y1="24" x2="12.5" y2="24" />
<line x1="35.5" y1="24" x2="39.5" y2="24" />
<line x1="13.1" y1="13.1" x2="15.9" y2="15.9" />
<line x1="32.1" y1="32.1" x2="34.9" y2="34.9" />
<line x1="13.1" y1="34.9" x2="15.9" y2="32.1" />
<line x1="32.1" y1="15.9" x2="34.9" y2="13.1" />
</g>
<g v-else-if="props.kind === 'partly'">
<circle cx="16.5" cy="16.5" r="5" />
<line x1="16.5" y1="7.5" x2="16.5" y2="10.5" />
<line x1="9.2" y1="16.5" x2="12.2" y2="16.5" />
<line x1="11.4" y1="11.4" x2="13.6" y2="13.6" />
<line x1="21.6" y1="11.4" x2="19.4" y2="13.6" />
<path d="M15.5 31.5h15.8a5.7 5.7 0 1 0-.5-11.3 8.4 8.4 0 0 0-15.3-1.7 6.2 6.2 0 0 0 0 13Z" />
</g>
<g v-else-if="props.kind === 'cloudy'">
<path d="M13 31.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 31.5Z" />
</g>
<g v-else-if="props.kind === 'fog'">
<path d="M13 25.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 25.5Z" />
<path d="M11 31c2 0 3-1 5-1s3 1 5 1 3-1 5-1 3 1 5 1 3-1 5-1" />
<path d="M14 36c1.7 0 2.6-.8 4.3-.8s2.6.8 4.3.8 2.6-.8 4.3-.8 2.6.8 4.3.8" />
</g>
<g v-else-if="props.kind === 'rain'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<path d="M16 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9S16 29.4 16 31Z" />
<path d="M25.4 34.2c0 1.8-1.1 3.2-2.4 3.2s-2.4-1.4-2.4-3.2c0-1.5 2.4-4.5 2.4-4.5s2.4 3 2.4 4.5Z" />
<path d="M34.8 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9s2.7 3.3 2.7 4.9Z" />
</g>
<g v-else-if="props.kind === 'snow'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<g>
<line x1="16.5" y1="30.5" x2="16.5" y2="38.5" />
<line x1="13" y1="34.5" x2="20" y2="34.5" />
<line x1="13.8" y1="31.8" x2="19.2" y2="37.2" />
<line x1="19.2" y1="31.8" x2="13.8" y2="37.2" />
</g>
<g>
<line x1="31.5" y1="30.5" x2="31.5" y2="38.5" />
<line x1="28" y1="34.5" x2="35" y2="34.5" />
<line x1="28.8" y1="31.8" x2="34.2" y2="37.2" />
<line x1="34.2" y1="31.8" x2="28.8" y2="37.2" />
</g>
</g>
<g v-else-if="props.kind === 'storm'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<path d="M24.5 29.5l-4 7h4.4l-2 8 7.6-10h-4.8l2.3-5Z" />
</g>
<g v-else-if="props.kind === 'sunrise'">
<line x1="10" y1="33" x2="38" y2="33" />
<path d="M16 33a8 8 0 0 1 16 0" />
<line x1="24" y1="10" x2="24" y2="16" />
<polyline points="21,13 24,10 27,13" />
<line x1="15" y1="26" x2="17.5" y2="23.5" />
<line x1="33" y1="26" x2="30.5" y2="23.5" />
</g>
<g v-else-if="props.kind === 'sunset'">
<line x1="10" y1="33" x2="38" y2="33" />
<path d="M16 33a8 8 0 0 1 16 0" />
<line x1="24" y1="10" x2="24" y2="16" />
<polyline points="21,13 24,16 27,13" />
<line x1="15" y1="26" x2="17.5" y2="28.5" />
<line x1="33" y1="26" x2="30.5" y2="28.5" />
</g>
<g v-else-if="props.kind === 'air'">
<path d="M10 17c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
<path d="M13 24c3.8 0 3.8-2.5 7.6-2.5s3.8 2.5 7.6 2.5 3.8-2.5 7.6-2.5" />
<path d="M10 31c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
</g>
<g v-else-if="props.kind === 'visibility'">
<path d="M6 24c3.1-4.8 8.8-8 18-8s14.9 3.2 18 8c-3.1 4.8-8.8 8-18 8S9.1 28.8 6 24Z" />
<circle cx="24" cy="24" r="4.8" />
<circle cx="24" cy="24" r="1.5" fill="currentColor" stroke="none" />
</g>
<g v-else-if="props.kind === 'humidity'">
<path d="M24 10C20.5 15 16 20.4 16 27a8 8 0 0 0 16 0c0-6.6-4.5-12-8-17Z" />
<path d="M24 30.5a4.6 4.6 0 0 0 4.4-3.1" />
</g>
<g v-else-if="props.kind === 'wind'">
<path d="M10 17h17c3.3 0 5.5-2 5.5-4.8 0-2.4-1.9-4.2-4.4-4.2-2.1 0-3.7 1.2-4.4 3" />
<path d="M10 25h22c2.8 0 4.8 1.8 4.8 4.2 0 2.3-1.8 4.1-4.1 4.1-1.9 0-3.4-.9-4.2-2.4" />
<path d="M10 33h11c2.5 0 4.3 1.6 4.3 3.8 0 2.1-1.7 3.7-3.9 3.7-1.6 0-2.9-.7-3.7-2" />
</g>
</svg>
</template>
<style scoped>
.glyph {
width: 1.12rem;
height: 1.12rem;
flex-shrink: 0;
color: currentColor;
overflow: visible;
}
.glyph--large {
width: 2.7rem;
height: 2.7rem;
}
</style>

11
calendar/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}
declare module '*.md?raw' {
const content: string;
export default content;
}

View File

@@ -0,0 +1,132 @@
import { HolidayUtil, Solar } from 'lunar-typescript';
export interface CalendarBadge {
label: string;
tone: 'holiday' | 'workday' | 'festival' | 'term';
}
export interface CalendarCell {
date: Date;
day: number;
subLabel: string;
currentMonth: boolean;
isToday: boolean;
badges: CalendarBadge[];
}
export interface CalendarModel {
largeDay: string;
gregorianLabel: string;
weekdayLabel: string;
lunarYearLabel: string;
lunarDayLabel: string;
summaryBadges: CalendarBadge[];
weekLabels: string[];
cells: CalendarCell[];
}
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
function isSameDay(left: Date, right: Date) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function badgeFromHoliday(dateKey: string): CalendarBadge[] {
const holiday = HolidayUtil.getHoliday(dateKey);
if (!holiday) {
return [];
}
return [
{
label: holiday.getName(),
tone: holiday.isWork() ? 'workday' : 'holiday',
},
];
}
function buildSolar(date: Date) {
return Solar.fromYmd(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
function buildCellBadges(date: Date) {
const solar = buildSolar(date);
const lunar = solar.getLunar();
const solarFestivals = solar.getFestivals();
const lunarFestivals = lunar.getFestivals();
const otherFestivals = solar.getOtherFestivals();
const jieQi = lunar.getJieQi();
return [
...badgeFromHoliday(solar.toYmd()),
...solarFestivals.map((label) => ({ label, tone: 'festival' as const })),
...lunarFestivals.map((label) => ({ label, tone: 'festival' as const })),
...otherFestivals.map((label) => ({ label, tone: 'festival' as const })),
...(jieQi ? [{ label: jieQi, tone: 'term' as const }] : []),
];
}
function buildCellSubLabel(date: Date) {
const solar = buildSolar(date);
const lunar = solar.getLunar();
const badges = buildCellBadges(date);
if (badges.length > 0) {
return badges[0].label;
}
const lunarDay = lunar.getDayInChinese();
if (lunarDay === '初一') {
return lunar.getMonthInChinese();
}
return lunarDay;
}
export function buildCalendarModel(currentDate: Date): CalendarModel {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
const monthStart = new Date(currentYear, currentMonth, 1);
const monthEnd = new Date(currentYear, currentMonth + 1, 0);
const gridStart = new Date(monthStart);
gridStart.setDate(monthStart.getDate() - monthStart.getDay());
const gridEnd = new Date(monthEnd);
gridEnd.setDate(monthEnd.getDate() + (6 - monthEnd.getDay()));
const cells: CalendarCell[] = [];
const cursor = new Date(gridStart);
while (cursor <= gridEnd) {
const cellDate = new Date(cursor);
cells.push({
date: cellDate,
day: cellDate.getDate(),
subLabel: buildCellSubLabel(cellDate),
badges: buildCellBadges(cellDate),
currentMonth: cellDate.getMonth() === currentMonth,
isToday: isSameDay(cellDate, currentDate),
});
cursor.setDate(cursor.getDate() + 1);
}
const solar = buildSolar(currentDate);
const lunar = solar.getLunar();
const summaryBadges = buildCellBadges(currentDate).slice(0, 3);
return {
largeDay: String(currentDate.getDate()).padStart(2, '0'),
gregorianLabel: `${currentYear}${currentMonth + 1}${currentDate.getDate()}`,
weekdayLabel: `星期${solar.getWeekInChinese()}`,
lunarYearLabel: `${lunar.getYearInGanZhi()}${lunar.getYearShengXiao()}`,
lunarDayLabel: `${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`,
summaryBadges,
weekLabels: WEEK_LABELS,
cells,
};
}

View File

@@ -0,0 +1,49 @@
import quoteMarkdown from '../../../日知录.md?raw';
export interface QuoteEntry {
date: string;
text: string;
}
const QUOTE_PATTERN = /^>\s*(.+?)\s*--\s*(\d{4}-\d{2}-\d{2})$/gm;
const QUOTE_ENTRIES: QuoteEntry[] = Array.from(quoteMarkdown.matchAll(QUOTE_PATTERN)).map(
(match) => ({
text: match[1].trim(),
date: match[2],
}),
);
function pad(value: number) {
return String(value).padStart(2, '0');
}
function toMonthDay(date: Date) {
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function toIsoDate(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export function getQuoteForDate(date: Date) {
const isoDate = toIsoDate(date);
const monthDay = toMonthDay(date);
const exactMatch = QUOTE_ENTRIES.find((entry) => entry.date === isoDate);
if (exactMatch) {
return exactMatch;
}
const monthDayMatch = QUOTE_ENTRIES.find((entry) => entry.date.slice(5) === monthDay);
if (monthDayMatch) {
return monthDayMatch;
}
// 文件日期不覆盖全年时,回退到一个稳定的循环选择,保证每天都有内容可显示。
const startOfYear = new Date(date.getFullYear(), 0, 1);
const dayIndex = Math.floor((date.getTime() - startOfYear.getTime()) / 86_400_000);
return QUOTE_ENTRIES[(dayIndex % QUOTE_ENTRIES.length + QUOTE_ENTRIES.length) % QUOTE_ENTRIES.length];
}

203
calendar/src/lib/weather.ts Normal file
View File

@@ -0,0 +1,203 @@
export interface LocationCoordinates {
latitude: number;
longitude: number;
label: string;
}
export interface ForecastDay {
label: string;
weatherCode: number;
condition: string;
high: number;
low: number;
}
export interface WeatherSnapshot {
temperature: number;
condition: string;
weatherCode: number;
humidity: number;
windSpeed: number;
visibilityKm: number;
forecast: ForecastDay[];
sunrise: string;
sunset: string;
aqi: number | null;
aqiLabel: string;
}
const DEFAULT_LOCATION: LocationCoordinates = {
latitude: 31.2304,
longitude: 121.4737,
label: '上海',
};
const WEATHER_CODE_LABELS: Record<number, string> = {
0: '晴朗',
1: '晴间多云',
2: '多云',
3: '阴天',
45: '有雾',
48: '冻雾',
51: '毛毛雨',
53: '小雨',
55: '中雨',
56: '冻毛雨',
57: '冻毛雨',
61: '小雨',
63: '中雨',
65: '大雨',
66: '冻雨',
67: '冻雨',
71: '小雪',
73: '中雪',
75: '大雪',
77: '阵雪',
80: '阵雨',
81: '强阵雨',
82: '暴雨',
85: '阵雪',
86: '大阵雪',
95: '雷阵雨',
96: '雷暴冰雹',
99: '强雷暴',
};
function formatClock(value: string) {
const date = new Date(value);
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function formatForecastLabel(value: string, index: number) {
if (index === 0) {
return '今天';
}
if (index === 1) {
return '明天';
}
return new Intl.DateTimeFormat('zh-CN', {
weekday: 'short',
}).format(new Date(value));
}
function aqiToLabel(aqi: number | null) {
if (aqi === null) {
return '暂无';
}
if (aqi <= 50) {
return '优';
}
if (aqi <= 100) {
return '良';
}
if (aqi <= 150) {
return '轻度';
}
if (aqi <= 200) {
return '中度';
}
if (aqi <= 300) {
return '重度';
}
return '严重';
}
export function weatherCodeToLabel(code: number) {
return WEATHER_CODE_LABELS[code] ?? '天气';
}
export async function resolveLocation(): Promise<LocationCoordinates> {
if (!('geolocation' in navigator)) {
return DEFAULT_LOCATION;
}
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
label: '当前位置',
});
},
() => resolve(DEFAULT_LOCATION),
{
enableHighAccuracy: false,
timeout: 5000,
maximumAge: 10 * 60 * 1000,
},
);
});
}
export async function fetchWeather(location: LocationCoordinates): Promise<WeatherSnapshot> {
const weatherParams = new URLSearchParams({
latitude: String(location.latitude),
longitude: String(location.longitude),
current: [
'temperature_2m',
'relative_humidity_2m',
'weather_code',
'wind_speed_10m',
'visibility',
].join(','),
daily: ['weather_code', 'temperature_2m_max', 'temperature_2m_min', 'sunrise', 'sunset'].join(','),
forecast_days: '5',
timezone: 'auto',
});
const airParams = new URLSearchParams({
latitude: String(location.latitude),
longitude: String(location.longitude),
current: 'us_aqi',
timezone: 'auto',
});
const [weatherResponse, airResponse] = await Promise.all([
fetch(`https://api.open-meteo.com/v1/forecast?${weatherParams.toString()}`),
fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${airParams.toString()}`),
]);
if (!weatherResponse.ok) {
throw new Error(`天气接口返回异常:${weatherResponse.status}`);
}
const weatherJson = await weatherResponse.json();
const airJson = airResponse.ok ? await airResponse.json() : null;
const aqi = airJson?.current?.us_aqi ?? null;
const daily = weatherJson.daily;
return {
temperature: Math.round(weatherJson.current.temperature_2m),
condition: weatherCodeToLabel(weatherJson.current.weather_code),
weatherCode: weatherJson.current.weather_code,
humidity: Math.round(weatherJson.current.relative_humidity_2m),
windSpeed: Math.round(weatherJson.current.wind_speed_10m),
visibilityKm: Number((weatherJson.current.visibility / 1000).toFixed(1)),
forecast: daily.time.map((value: string, index: number) => ({
label: formatForecastLabel(value, index),
weatherCode: daily.weather_code[index],
condition: weatherCodeToLabel(daily.weather_code[index]),
high: Math.round(daily.temperature_2m_max[index]),
low: Math.round(daily.temperature_2m_min[index]),
})),
sunrise: formatClock(daily.sunrise[0]),
sunset: formatClock(daily.sunset[0]),
aqi,
aqiLabel: aqiToLabel(aqi),
};
}

6
calendar/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

448
calendar/src/style.css Normal file
View File

@@ -0,0 +1,448 @@
:root {
font-family:
'Hiragino Sans GB',
'PingFang SC',
'Noto Sans SC',
sans-serif;
color: #0f172a;
background:
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.88), transparent 30%),
radial-gradient(circle at 82% 18%, rgba(241, 238, 232, 0.92), transparent 24%),
linear-gradient(160deg, #f4f1eb 0%, #ebe5dc 52%, #e4ddd4 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
}
.page-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.dashboard-frame {
width: min(100%, 1448px);
aspect-ratio: 1448 / 1072;
padding: clamp(1.2rem, 1vw + 0.9rem, 2rem);
border-radius: 2rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.18)),
rgba(255, 255, 255, 0.14);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
0 38px 72px rgba(64, 52, 38, 0.14);
backdrop-filter: blur(14px);
}
.dashboard-grid {
display: grid;
grid-template-columns: 1.08fr 1fr;
grid-template-rows: auto auto;
gap: clamp(1rem, 1vw + 0.75rem, 2rem);
height: 100%;
align-content: start;
align-items: stretch;
}
.card {
border-radius: 1.6rem;
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 20px 36px rgba(72, 58, 44, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
border: 1px solid rgba(70, 58, 46, 0.08);
}
.calendar-card,
.weather-card {
padding: clamp(1.4rem, 1.2vw + 1rem, 2rem);
min-height: 0;
height: 100%;
}
.calendar-card {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.8rem;
}
.calendar-card__header {
display: flex;
align-items: center;
gap: 1.6rem;
}
.calendar-card__day {
font-family:
'Iowan Old Style',
'Baskerville',
serif;
font-size: clamp(4.4rem, 5vw, 7rem);
line-height: 0.95;
letter-spacing: -0.08em;
color: #101010;
}
.calendar-card__meta {
display: grid;
gap: 0.65rem;
}
.calendar-card__date-line,
.calendar-card__lunar {
display: flex;
gap: 1rem;
align-items: baseline;
margin: 0;
font-size: clamp(1rem, 0.7vw + 0.7rem, 1.55rem);
}
.calendar-card__weekday {
color: #556274;
}
.calendar-card__lunar {
color: #5f4b32;
flex-wrap: wrap;
}
.calendar-card__badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.calendar-card__badges--inline {
display: inline-flex;
align-items: center;
margin-left: 0.1rem;
}
.calendar-card__badge {
display: inline-flex;
align-items: center;
min-height: 1.6rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid currentColor;
font-size: 0.76rem;
line-height: 1;
}
.calendar-card__badge--holiday {
color: #2f2b26;
background: #ece5db;
}
.calendar-card__badge--workday {
color: #68593f;
background: #f2eadb;
}
.calendar-card__badge--festival {
color: #4f4436;
background: #f5f0e8;
}
.calendar-card__badge--term {
color: #3a4a4a;
background: #e9efef;
}
.calendar-card__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: auto;
grid-auto-rows: minmax(0, 1fr);
gap: 0.35rem 0.3rem;
align-content: start;
height: 100%;
}
.calendar-card__week-label {
text-align: center;
color: #798395;
font-size: 0.92rem;
padding-bottom: 0.45rem;
}
.calendar-card__cell {
min-height: 0;
display: grid;
place-items: center;
align-content: center;
padding: 0.2rem 0;
border-radius: 0.8rem;
color: #304153;
transition: background-color 150ms ease;
}
.calendar-card__cell--muted {
color: #bac4d2;
}
.calendar-card__cell--today {
background: linear-gradient(180deg, #303640 0%, #1f252d 100%);
color: #ffffff;
box-shadow: 0 14px 24px rgba(31, 37, 45, 0.24);
}
.calendar-card__cell--holiday .calendar-card__solar,
.calendar-card__cell--holiday .calendar-card__lunar-day {
color: #6a4a1c;
}
.calendar-card__solar {
font-size: 1.3rem;
line-height: 1.1;
}
.calendar-card__lunar-day {
font-size: 0.68rem;
line-height: 1.2;
opacity: 0.82;
}
.weather-card {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.8rem;
}
.weather-card__heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.weather-card__title {
margin: 0;
font-size: clamp(1.5rem, 1vw + 1rem, 2rem);
font-weight: 700;
}
.weather-card__subtitle {
margin: 0.35rem 0 0;
color: #778396;
font-size: 0.95rem;
}
.weather-card__hero {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.05rem 1.2rem;
border-radius: 1.2rem;
background: linear-gradient(180deg, #ece8e1 0%, #e2ddd4 100%);
}
.weather-card__hero--placeholder {
justify-content: center;
color: #64748b;
}
.weather-card__hero-main {
display: flex;
align-items: center;
gap: 1rem;
}
.weather-card__temperature {
font-size: clamp(2.2rem, 1.2vw + 1.6rem, 3.2rem);
line-height: 1;
font-family:
'Iowan Old Style',
'Baskerville',
serif;
}
.weather-card__condition {
margin-top: 0.35rem;
font-size: 1.15rem;
color: #243041;
}
.weather-card__facts {
display: grid;
gap: 0.45rem;
color: #475569;
}
.weather-card__fact {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.98rem;
}
.weather-card__forecast {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.65rem;
align-items: stretch;
}
.forecast-pill {
display: grid;
justify-items: center;
align-content: center;
gap: 0.45rem;
padding: 0.8rem 0.55rem;
border-radius: 1rem;
background: #f7f4ef;
border: 1px solid rgba(82, 70, 56, 0.08);
height: 100%;
}
.forecast-pill__label {
margin: 0;
color: #556274;
font-size: 0.9rem;
line-height: 1.1;
}
.forecast-pill__temp {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.forecast-pill__temp span {
margin-left: 0.15rem;
color: #7d8798;
font-weight: 500;
}
.weather-card__metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.metric-pill {
display: grid;
align-content: center;
gap: 0.45rem;
padding: 0.85rem 0.9rem 0.9rem;
border-radius: 1rem;
}
.metric-pill__label {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.88rem;
}
.metric-pill__value {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.metric--sunrise {
background: linear-gradient(150deg, #f3eee5, #ebe3d6);
color: #5c4631;
}
.metric--sunset {
background: linear-gradient(150deg, #efebe6, #e5dfd6);
color: #4f463d;
}
.metric--air {
background: linear-gradient(150deg, #eef0eb, #e3e7de);
color: #354338;
}
.metric--visibility {
background: linear-gradient(150deg, #eef1f0, #e2e5e4);
color: #3b4a4d;
}
.quote-card {
grid-column: 1 / -1;
display: grid;
gap: 1.1rem;
padding: clamp(1.5rem, 1.2vw + 1rem, 2rem);
background: linear-gradient(168deg, rgba(249, 245, 236, 0.98), rgba(244, 239, 229, 0.94));
}
.quote-card__content {
margin: 0;
color: #1e293b;
line-height: 1.65;
font-family:
'Iowan Old Style',
'Baskerville',
'Noto Serif SC',
serif;
}
@media (max-width: 1080px) {
.page-shell {
padding: 0.9rem;
}
.dashboard-frame {
aspect-ratio: auto;
width: min(100%, 1448px);
}
.dashboard-grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.quote-card {
grid-column: auto;
}
.weather-card__forecast,
.weather-card__metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.calendar-card__header,
.weather-card__heading,
.weather-card__hero {
grid-template-columns: 1fr;
display: grid;
}
.calendar-card__header {
gap: 1rem;
}
.weather-card__forecast,
.weather-card__metrics {
grid-template-columns: 1fr 1fr;
}
.quote-card__content {
font-size: 1.45rem !important;
}
}

21
calendar/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Node",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

19
calendar/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { fileURLToPath, URL } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
fs: {
// 允许直接读取仓库根目录下的《日知录》原始 Markdown。
allow: ['..'],
},
},
});

22
dash/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
sh-checker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Run sh-checker
uses: luizm/action-sh-checker@v0.1.12
env:
SHELLCHECK_OPTS: -s ash
SHFMT_OPTS: -i 2
with:
sh_checker_comment: false

3
dash/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/dist
/kindle-dash-*.tgz
/tmp

43
dash/CHANGELOG.md Normal file
View File

@@ -0,0 +1,43 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [v1.0.0-beta.4] - 2022-07-27
### Changed
- Only call eips if fetch-dashboard succesfully completes
- Ensure a full screen refresh is triggered after wake from sleep
- Build ht from upstream sources, using rusttls instead of vendored openssl
- Replace ht 0.4.0 with xh 0.16.1 (project was renamed)
## [v1.0.0-beta.3] - 2020-02-03
### Changed
- Use 1.1.1.1 as default Wi-Fi test ip
- Use a more standards-compliant cron parser (BREAKING)
### Added
- Add low battery reporting (`local/low-battery.sh`)
- Add debug mode (DEBUG=true start.sh)
- SSH server prerequisite in docs (@julianlam)
### Fixed
- Typos (@jcmiller11, @starcoat)
## [v1.0.0-beta-2] - 2020-01-26
### Removed
- Power state logging
## [v1.0.0-beta-1] - 2020-01-26
Initial release 🎉

View File

@@ -0,0 +1,10 @@
<?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

@@ -0,0 +1,5 @@
{
"items": [
{"name": "Kindle Dashboard", "action": "/mnt/us/dashboard/start.sh"}
]
}

19
dash/LICENSE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2021 Pascal Widdershoven
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.

44
dash/Makefile Normal file
View File

@@ -0,0 +1,44 @@
VERSION := v1.0.0-beta.4
SRC_FILES := $(shell find src -name '*.sh' -o -name '*.png')
NEXT_WAKEUP_SRC_FILES := $(shell find src/next-wakeup/src -name '*.rs')
TARGET_FILES := $(SRC_FILES:src/%=dist/%)
dist: dist/next-wakeup dist/xh dist/local/state ${TARGET_FILES}
tarball: dist
tar -C dist -cvzf kindle-dash-${VERSION}.tgz ./
dist/%: src/%
@echo "Copying $<"
@mkdir -p $(@D)
@cp "$<" "$@"
dist/next-wakeup: ${NEXT_WAKEUP_SRC_FILES}
cd src/next-wakeup && cross build --release --target arm-unknown-linux-musleabi
cp src/next-wakeup/target/arm-unknown-linux-musleabi/release/next-wakeup dist/
dist/xh: tmp/xh
cd tmp/xh && cross build --release --target arm-unknown-linux-musleabi
docker run --rm \
-v $(shell pwd)/tmp/xh:/src \
rustembedded/cross:arm-unknown-linux-musleabi-0.2.1 \
/usr/local/arm-linux-musleabi/bin/strip /src/target/arm-unknown-linux-musleabi/release/xh
cp tmp/xh/target/arm-unknown-linux-musleabi/release/xh dist/
tmp/xh:
mkdir -p tmp/
git clone --depth 1 --branch v0.16.1 https://github.com/ducaale/xh.git tmp/xh
dist/local/state:
mkdir -p dist/local/state
clean:
rm -r dist/*
watch:
watchexec -w src/ -p -- make
format:
shfmt -i 2 -w -l src/**/*.sh
.PHONY: clean watch tarball format

59
dash/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Low-power Kindle dashboard
Turns out old Kindle devices make great, energy efficient dashboards :-)
![](./example/photo.jpg)
## What this repo is
This repo only contains the code that runs on the Kindle. It periodically fetches an image to be displayed on the screen and suspends the device to RAM (which is very power efficient) until the next screen update.
This code _does not_ render the dashboard itself. It's expected that what to display on the screen is rendered elsewhere and can be fetchd via HTTP(s). This is both more power efficient and allows you to use any tool you like to produce the dashboard image.
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.
## Prerequisites
* A jailbroken Kindle, with Wi-Fi configured.
* An SSH server on the Kindle (via [USBNetwork](https://wiki.mobileread.com/wiki/USBNetwork))
* Tested only on a Kindle 4 NT. Should work on other Kindle devices as well with minor modifications.
## Installation
1. Download the [latest release](https://github.com/pascalw/kindle-dash/releases) on your computer and extract it.
2. Modify `local/fetch-dashboard.sh` and optionally `local/env.sh`.
3. Copy the files to the Kindle, for example: `rsync -vr ./ kindle:/mnt/us/dashboard`.
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.
## Upgrading
If you're running kindle-dash already and want to update to the latest version follow the following steps.
1. Download the [latest release](https://github.com/pascalw/kindle-dash/releases) on your computer and extract it.
2. Review the release notes. Some releases might require changes to files in `local/`.
3. Copy the files to the Kindle, excluding the `local` directory. For example: `rsync -vur --exclude=local ./ kindle:/mnt/us/dashboard`.
4. Modify files in `/mnt/us/dashboard/local` if applicable.
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.
## KUAL
If you're using KUAL you can use simple extension to start this Dashboard
1. Copy folder `kindle-dash` from `KUAL` folder to the kual `extensions` folder. (located in `/mnt/us/extensions`)
## How this works
* This code periodically downloads a dashboard image from an HTTP(s) endpoint.
* The interval can be configured in `dist/local/env.sh` using a cron expression.
* During the update intervals the device is suspended to RAM to save power.
## Notes
* 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).
## Credits
Thanks to [davidhampgonsalves/life-dashboard](https://github.com/davidhampgonsalves/life-dashboard) for the inspiration!

View File

@@ -0,0 +1,238 @@
# Kindle Voyage 5.13.6 一次成功路径
这篇文档只覆盖下面这个组合:
- 机型:`Kindle Voyage (KV)`
- 固件:`5.13.6`
- 目标:完成越狱,并部署 `KUAL``MRPI``renameotabin``kindle-dash`
如果设备型号或固件版本不同,不要直接照抄本文。
## 核心结论
`Kindle Voyage 5.13.6` 应该走 `WatchThis`,不要走 `LanguageBreak`
这次实操里,前面大部分失败都来自两个错误:
- 误走了 `LanguageBreak`
- 在 demo 菜单里点错了分支,提前进入了 `Resell Device` / `销售设备`
对这台设备,正确思路非常简单:
1.`WatchThis` 进入 demo mode
2. 只在正确的 `Sideload Content` 时机导入 `KV-5.13.6.zip`
3.`Get Started` 触发越狱脚本
4. 安装 `KUAL/MRPI`
5.`renameotabin` 关闭 OTA
6. 再部署并启动 `kindle-dash`
## 需要准备的文件
### WatchThis
来自 `watchthis-jailbreak-r03.zip`
- `KV-5.13.6.zip`
- `demo.json`
- `Update_hotfix_watchthis_custom.bin`
在本仓库里对应的是:
- `staging/watchthis/KV-5.13.6/KV-5.13.6.zip`
- `staging/watchthis/KV-5.13.6/demo.json`
- `staging/watchthis/Update_hotfix_watchthis_custom.bin`
### 越狱后安装包
- `extensions/MRInstaller`
- `mrpackages/Update_KUALBooklet_HDRepack.bin`
- `extensions/renameotabin`
- `extensions/kindle-dash`
- `dashboard/`
在本仓库里已经整理到:
- `staging/post-jailbreak-root/extensions/`
- `staging/post-jailbreak-root/mrpackages/`
- `staging/post-jailbreak-root/dashboard/`
## 一次成功的正确路径
### 1. 恢复出厂并进入 demo mode
1. 先恢复出厂设置。
2. 语言选择页只选 `English (United Kingdom)`
这一步非常关键,不要选中文。
3. 到 WiFi 页面后,随便点一个网络,再立刻退回,不要真的联网。
4. 在搜索栏输入 `;enter_demo`
5. 如果 `;enter_demo` 没反应,走备用入口:
- 用 USB 连接电脑
- 在 Kindle 根目录创建空文件 `DONT_CHECK_BATTERY`
- 弹出设备
- 回到 Kindle 搜索输入 `;demo`
6. 如果看到 `Demo Activation`,点 `Yes`
7. 设备重启并进入 demo 流程后:
- 跳过 WiFi
- 店铺注册信息全部填假值
- `Fetching available demo types``Skip`
- demo type 选 `standard`
### 2. 第一次出现 Sideload Content 时不要导入 payload
1. 第一次出现 `Add Content` / `Sideload Content` 提示时,只点 `Done`
2. 这一步不要接 USB。
3. 这一步也不要导入 `KV-5.13.6.zip`
这是最容易做错的一步。第一次 `Done` 只是让 demo setup 继续往下走,不是真正的 payload 导入点。
### 3. 跳过 misconfiguration 锁页
demo setup 完成后,大概率会落到 `Configure Device` / misconfiguration 页面。
不要点 `Configure Device`,直接做隐藏手势:
1. 在屏幕右下角用两根手指同时轻点一下
2. 两指立刻抬起
3. 马上用一根手指从右下向左滑
触发成功后会回到可操作界面。
### 4. 真正的 payload 导入点
1. 回到可操作界面后,搜索输入 `;demo`
2. 进入 demo menu
3. 选择 `Sideload Content` / `导入内容`
4. 到这一步再接 USB
5. 在 Kindle 根目录创建 `.demo/`
6. 把下面三个东西放进去:
```text
.demo/KV-5.13.6.zip
.demo/demo.json
.demo/goodreads/ <- 空目录
```
如果你在 Mac 上操作,可以直接用:
```sh
mkdir -p /Volumes/Kindle/.demo/goodreads
cp staging/watchthis/KV-5.13.6/KV-5.13.6.zip /Volumes/Kindle/.demo/
cp staging/watchthis/KV-5.13.6/demo.json /Volumes/Kindle/.demo/
```
然后:
1. 弹出 Kindle
2. 在 Kindle 上点 `Done`
3. 退出 demo menu
## 5. 触发越狱脚本
1. 退出 demo menu 后,输入 `;dsts`
如果 `;dsts` 没反应,也可以从顶部下拉进入设置。
2. 打开 `Help & User Guides`
3. 再点 `Get Started`
4. 设备会重启
5. 越狱脚本会在下次启动时运行
如果这里弹 `Application Error`,官方补救是:
1. 长按电源键强制重启
2. 再进 demo menu
3. 再执行一次 `Sideload Content -> Done`
4. 这次不要再接 USB
## 6. 成功判据
对这台设备,下面这些现象说明越狱已经落地:
- Kindle 用户存储根目录出现 `mkk`
- Kindle 用户存储根目录出现 `libkh`
- Kindle 用户存储根目录出现 `rp`
如果这三个目录都没有,基本就是前面的 `WatchThis` 没真正成功。
## 7. 安装 KUAL / MRPI / kindle-dash
越狱落地后,把这些目录复制到 Kindle
```sh
rsync -a staging/post-jailbreak-root/extensions/ /Volumes/Kindle/extensions/
rsync -a staging/post-jailbreak-root/mrpackages/ /Volumes/Kindle/mrpackages/
rsync -a staging/post-jailbreak-root/dashboard/ /Volumes/Kindle/dashboard/
```
然后:
1. 弹出 Kindle
2. 回到首页搜索输入 `;log mrpi`
3. 等安装完成
4. 首页会出现 `KUAL` 卡片
## 8. 启动顺序
进入 `KUAL` 后,先做这个顺序:
1. `Rename OTA Binaries -> Rename`
2. 再运行 `Kindle Dashboard`
不要先跑 `Kindle Dashboard`,否则后面如果 OTA 没关掉,还存在自动升级把越狱覆盖掉的风险。
## 9. kindle-dash 默认行为
本项目默认不会在 Kindle 本机实时渲染页面,而是定时去下载一张图片来显示。
因此:
- 如果没有联网,`Kindle Dashboard` 看起来会像“卡住”
- 如果刷新计划不覆盖当前时间,会显示 `kindle is sleeping`
- 图片最好直接按 Voyage 原生分辨率出图:`1072 x 1448`
默认抓图脚本在:
- `src/local/fetch-dashboard.sh`
默认刷新计划在:
- `src/local/env.sh`
## 10. 这台设备上确认过的坑
### 不要走 `LanguageBreak`
`KV + 5.13.6` 应走 `WatchThis`。误走 `LanguageBreak` 会导致:
- `;demo -> Yes -> 重启 -> 回普通系统`
- `;uzb``;dsts` 行为异常
- 反复进入错误的 demo 分支
### 不要点 `Resell Device` / `销售设备`
这个分支会把流程带到 shipping mode / demo 出厂流程,和 `WatchThis` 正常路径无关。
如果你是在 `WatchThis` 流程里demo menu 里真正要点的是:
- `Sideload Content`
不是:
- `Resell Device`
- `Remote Reset`
- `Configure WiFi`
### 第一次 `Add Content` 只能点 `Done`
真正要接 USB 导 payload 的时机,是秘密手势之后再次 `;demo -> Sideload Content` 的那一次。
### 看到左上角只有一小块图片,不一定是失败
这通常只是图片尺寸不匹配。
例如本项目自带的 `sleeping.png` 只有 `600x800`,放到 Voyage 上就只会显示在左上角一部分区域。
## 参考
- WatchThis 包内说明:`watchthis-jailbreak-r03.zip` 中的 `watchthis-release/README.md`
- 项目主说明:`README.md`
- 图片抓取说明:`src/local/fetch-dashboard.sh`
- 本地调度配置:`src/local/env.sh`

View File

@@ -0,0 +1,449 @@
# Kindle Dashboard 分层时钟方案
## 1. 背景
当前仓库里有两部分:
- `calendar/`:负责渲染仪表盘网页与导出背景素材
- `dash/`:运行在 Kindle 上,负责拉图、刷屏、休眠与唤醒
最新设计稿来自 Figma
- 文件:`calendar`
- 节点:`6:2`
- 链接:`https://www.figma.com/design/3bXFNM5nM6mCq0TpL3nPYK/calendar?node-id=6-2&m=dev`
该节点 annotation 已明确:
- `阳历当天`
- `星期`
- `农历日`
- `时钟区域`
- `日历`
- `天气预报卡片`
- `书摘卡片`
本方案基于这些约束,采用“低频背景 + 本地时钟”的拆分方式:
- `calendar/``2 小时` 生成一次背景图
- 背景图直接写到:
- `/Users/gavin/kindle-dash/calendar/dist/kindlebg.png`
- Kindle 通过固定 HTTPS 地址拉取背景图:
- `https://shell.biboer.cn:20001/kindlebg.png`
- Kindle 端每分钟`不联网`
- Kindle 端只在本地重画时钟区域
## 2. 目标
这次改造不是单纯调样式,而是把页面拆成两类刷新节奏完全不同的素材:
1. 低频背景层
2. 高频时钟层
目标收益:
- 背景内容不再分钟级刷新
- 分钟级变化只限制在时钟区域
- 降低 Wi-Fi 唤醒次数
- 降低全屏刷新频率
- 减少墨水屏闪烁与残影
## 3. 分层边界
### 3.1 全屏背景层
背景层文件名固定为:
- `kindlebg.png`
背景层包含:
- 整体外层容器、圆角、阴影、背景渐变
- 日历卡片中的:
- 阳历当天数字
- 星期
- 农历日
- 下方月历区域
- 天气卡片全部内容
- 书摘卡片全部内容
背景层**不包含**
- 时钟表盘主体
- 时钟刻度
- 时针
- 分针
- 中心圆点
也就是说,背景图在时钟区域只保留布局占位,不直接承载任何分钟级内容。
### 3.2 静态表盘 patch
静态表盘 patch 为一个独立本地素材:
- `clock-face.png`
该素材包含:
- 圆形表盘主体
- 刻度
- 中心底座或中心圆盘
建议尺寸直接对齐 Figma 时钟区域:
- 节点:`24:74`
- 设计尺寸:`220 x 220`
这张图应当保存在 Kindle 本地,例如:
- `/mnt/us/dashboard/assets/clock-face.png`
它不需要分钟级联网拉取,只需要在部署时同步到设备,或者在更换设计稿时重新同步。
### 3.3 指针层
高频变化层只包含:
- 时针
- 分针
这里不建议包含秒针:
- 墨水屏收益低
- 刷新频率会显著上升
- 更容易产生残影
由于当前 `dash/` 链路本质是 `eips` 刷图,不是 SVG/Canvas 实时渲染,所以推荐使用`分层素材方案`
- `minute-hand/00.png``minute-hand/59.png`
- `hour-hand/000.png``hour-hand/719.png`
说明:
- 分针每分钟一个角度,共 `60`
- 时针如果要做到真正随分钟连续移动,需要 `12 * 60 = 720`
- 这些都是小尺寸 patch不是整屏图体积可控
如果后续确认 Kindle 端存在稳定的本地绘线工具,再考虑把指针改成算法绘制;当前版本不依赖这个前提。
## 4. 为什么不能只把表盘放进整页背景
这个问题必须单独说明。
如果:
- 表盘主体和刻度只存在于 `kindlebg.png`
- Kindle 每分钟只覆盖新的时针/分针
那么上一分钟留下的旧指针就无法被干净擦除。
因此 Kindle 端每分钟的正确流程应该是:
1. 先重画一张本地 `clock-face.png`
2. 再叠加新的时针素材
3. 再叠加新的分针素材
这等价于“先擦除,再重画”,并且整个流程不依赖网络。
所以本方案不是“背景图 + 两根指针”两层,而是:
1. `kindlebg.png`:全屏低频背景
2. `clock-face.png`:本地静态表盘 patch
3. `hour-hand/*.png + minute-hand/*.png`:本地高频指针素材
## 5. 数据刷新策略
### 5.1 背景层刷新
背景层刷新触发条件:
-`2 小时` 一次
- 跨天时立即刷新一次
- 天气接口异常恢复后可补刷一次
推荐调度:
- `00:00 / 02:00 / 04:00 / ... / 22:00`
背景层刷新输出:
- `/Users/gavin/kindle-dash/calendar/dist/kindlebg.png`
背景层对 Kindle 的访问地址固定为:
- `https://shell.biboer.cn:20001/kindlebg.png`
### 5.2 静态表盘 patch 刷新
静态表盘 patch 不参加分钟级调度。
建议刷新方式:
- 随部署同步一次
- Figma 设计或尺寸变化时重新导出并同步
### 5.3 指针层刷新
指针层刷新触发条件:
- 每分钟一次
指针层数据只依赖 Kindle 本地时间:
- 小时
- 分钟
分钟刷新不需要联网。
## 6. 网页渲染模式设计
为了让 `calendar/` 稳定产出背景与表盘素材,建议支持下面 3 种模式。
### 6.1 `full`
用途:
- 本地开发预览
- 对照 Figma 联调
输出内容:
- 背景层
- 表盘
- 指针预览
### 6.2 `background`
用途:
- 生成 `kindlebg.png`
输出内容:
- 只渲染背景层
- 时钟区域保留占位,但不绘制表盘与指针
### 6.3 `clock-face`
用途:
- 生成静态表盘 patch
输出内容:
- 只渲染表盘主体、刻度和中心底座
- 不绘制时针、分针
### 6.4 URL 约定
建议页面支持如下参数:
```text
/?mode=full
/?mode=background
/?mode=clock-face
```
## 7. 产物约定
推荐最终产出这几类文件:
```text
calendar/dist/kindlebg.png
calendar/dist/dashboard-manifest.json
kindle local:
/mnt/us/dashboard/assets/clock-face.png
/mnt/us/dashboard/assets/hour-hand/000.png ... 719.png
/mnt/us/dashboard/assets/minute-hand/00.png ... 59.png
```
`manifest` 建议至少包含这些字段:
```json
{
"background": {
"path": "kindlebg.png",
"url": "https://shell.biboer.cn:20001/kindlebg.png",
"updatedAt": "2026-03-15T10:00:00+08:00",
"refreshIntervalMinutes": 120
},
"clockRegion": {
"x": 313,
"y": 0,
"width": 220,
"height": 220
},
"clockFace": {
"path": "clock-face.png",
"managedOnKindle": true
},
"clockHands": {
"hourPattern": "assets/hour-hand/%03d.png",
"minutePattern": "assets/minute-hand/%02d.png",
"refreshIntervalMinutes": 1,
"networkRequired": false
}
}
```
说明:
- `x/y/width/height` 先按设计稿记录
- 真正接入 Kindle 时,要换算成最终截图分辨率下的实际像素值
## 8. Kindle 侧刷新策略
### 8.1 已确认的前提
`eips` 支持把 PNG/JPG 绘制到指定坐标,参数包含:
- `-g`
- `-x`
- `-y`
- `-f`
MobileRead Wiki 明确写了:
- `eips -g|-b image_path [-w waveform -f -x xpos -y ypos -v]`
- `-x``-y` 以像素为单位
来源:<https://wiki.mobileread.com/wiki/Eips>
因此,对 Kindle Voyage 而言,“在固定时钟区域重画小图”这个前提是成立的。
### 8.2 推荐流程
#### 启动或背景刷新时
1. 通过 HTTPS 拉取:
- `https://shell.biboer.cn:20001/kindlebg.png`
2. 保存为本地背景缓存
3. 使用全屏刷新显示背景图
4. 在时钟区域重画一次:
- `clock-face.png`
- 当前时针
- 当前分针
#### 每分钟刷新时
1. 读取 Kindle 本机时间
2. 计算:
- `minute_index = 00..59`
- `hour_index = ((hour % 12) * 60 + minute) = 000..719`
3. 在固定坐标先画:
- `clock-face.png`
4. 再画:
- `hour-hand/<hour_index>.png`
5. 再画:
- `minute-hand/<minute_index>.png`
6. 默认做局部/普通刷新
7.`10``15` 分钟对时钟区域补一次全刷,清理残影
### 8.3 功耗模型
这套方式的功耗来源拆成两类:
- 背景刷新:
- 每 2 小时联网一次
- 全屏刷新一次
- 时钟刷新:
- 每分钟本地刷一次小区域
- 不联网
相比“每分钟拉一张整屏背景图”,这会明显省电。
## 9. 代码改造建议
### 9.1 `calendar/`
建议改造点:
- 新增 `mode=background`
- 新增 `mode=clock-face`
- 时钟区域从日历/天气/书摘中完全拆开
- 增加一个定时生成任务,每 2 小时把背景图写到:
- `/Users/gavin/kindle-dash/calendar/dist/kindlebg.png`
- 生成 `dashboard-manifest.json`
### 9.2 `dash/`
建议改造点:
- `dash/src/local/fetch-dashboard.sh`
- 改成只拉取 `kindlebg.png`
- `dash/src/dash.sh`
- 从“单一刷新循环”改为“背景刷新 + 本地时钟刷新”双节奏
- 新增例如:
- `dash/src/local/render-clock.sh`
- `dash/src/local/render-clock-face.sh`
- `dash/src/local/clock-index.sh`
推荐新增配置项:
```sh
export BACKGROUND_URL="https://shell.biboer.cn:20001/kindlebg.png"
export BACKGROUND_REFRESH_SCHEDULE="0 */2 * * *"
export CLOCK_REGION_X=313
export CLOCK_REGION_Y=0
export CLOCK_REGION_WIDTH=220
export CLOCK_REGION_HEIGHT=220
export CLOCK_FULL_REFRESH_INTERVAL_MINUTES=15
```
## 10. 实施顺序
建议按下面顺序落地,避免一次改太多导致链路难排查。
### 阶段 1`calendar/` 分层输出
目标:
- `full/background/clock-face` 三种模式跑通
- 每 2 小时把背景图写到 `calendar/dist/kindlebg.png`
输出:
- 浏览器可预览
- `kindlebg.png` 可被 nginx 直接访问
### 阶段 2时钟静态素材准备
目标:
- 产出 `clock-face.png`
- 产出 `hour-hand``minute-hand` 素材库
输出:
- Kindle 本地时钟素材目录结构确定
### 阶段 3Kindle 本地分钟时钟
目标:
- Kindle 每分钟本地重画时钟
- 不联网
输出:
- 背景低频更新 + 时钟本地高频更新的完整链路
## 11. 推荐结论
当前最稳妥的推进顺序是:
1. 先让 `calendar/` 每 2 小时稳定生成:
- `/Users/gavin/kindle-dash/calendar/dist/kindlebg.png`
2. 再让 Kindle 只从:
- `https://shell.biboer.cn:20001/kindlebg.png`
拉背景图
3. 时钟区域完全本地化:
- 本地 `clock-face.png`
- 本地 `hour-hand/*.png`
- 本地 `minute-hand/*.png`
4. 分钟刷新时:
- 先重画表盘
- 再重画时针
- 再重画分针
也就是说,**背景是远端低频资源,时钟是本地高频资源,二者不要混在同一个刷新链路里**。

View File

@@ -0,0 +1 @@
/node_modules

1
dash/docs/screenshotter/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/node_modules

View File

@@ -0,0 +1,30 @@
FROM node:12-buster-slim
RUN apt-get update \
&& apt-get install -y wget gnupg \
&& wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-stable fonts-freefont-ttf libxss1 fonts-noto-color-emoji \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD 1
ENV PUPPETEER_EXECUTABLE_PATH /usr/bin/google-chrome-stable
ADD package.json yarn.lock /app/
RUN cd /app && yarn install \
# Add user so we don't need --no-sandbox.
# same layer as npm install to keep re-chowned files from using up several hundred MBs more space
&& groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \
&& mkdir -p /home/pptruser/Downloads \
&& chown -R pptruser:pptruser /home/pptruser \
&& chown -R pptruser:pptruser /app/node_modules
ADD . /app/
# Run everything after as non-privileged user.
USER pptruser
CMD ["node", "/app/screenshot.js"]

View File

@@ -0,0 +1,9 @@
{
"name": "screenshotter",
"version": "1.0.0",
"private": true,
"dependencies": {
"pngjs": "^6.0.0",
"puppeteer": "^5.5.0"
}
}

View File

@@ -0,0 +1,38 @@
const puppeteer = require("puppeteer");
const fs = require("fs");
const PNG = require("pngjs").PNG;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const URL = process.env.URL || "CHANGEME";
(async () => {
const browser = await puppeteer.launch({
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
/* Kindle 4 NT resolution */
await page.setViewport({ width: 600, height: 800 });
/* Might want to use networkidle0 here, depending on the type of page */
/* See https://github.com/puppeteer/puppeteer/blob/main/docs/api.md */
await page.goto(URL, { waitUntil: "networkidle2" });
/* This is a bit silly. ¯\_(ツ)_/¯
Networkidle2 doesn't always seem to wait long enough. */
await sleep(3000);
await page.screenshot({ path: "dash.png" });
await fs.createReadStream("dash.png")
.pipe(new PNG({ colorType: 0 }))
.on("parsed", function () {
this.pack().pipe(fs.createWriteStream("dash.png"));
});
browser.close();
})();

View File

@@ -0,0 +1,373 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/node@*":
version "14.14.22"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.22.tgz#0d29f382472c4ccf3bd96ff0ce47daf5b7b84b18"
integrity sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw==
"@types/yauzl@^2.9.1":
version "2.9.1"
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.9.1.tgz#d10f69f9f522eef3cf98e30afb684a1e1ec923af"
integrity sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA==
dependencies:
"@types/node" "*"
agent-base@5:
version "5.1.1"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-5.1.1.tgz#e8fb3f242959db44d63be665db7a8e739537a32c"
integrity sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==
balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
bl@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489"
integrity sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==
dependencies:
buffer "^5.5.0"
inherits "^2.0.4"
readable-stream "^3.4.0"
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
buffer-crc32@~0.2.3:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
buffer@^5.2.1, buffer@^5.5.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.1.13"
chownr@^1.1.1:
version "1.1.4"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
debug@4, debug@^4.1.0, debug@^4.1.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
dependencies:
ms "2.1.2"
devtools-protocol@0.0.818844:
version "0.0.818844"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.818844.tgz#d1947278ec85b53e4c8ca598f607a28fa785ba9e"
integrity sha512-AD1hi7iVJ8OD0aMLQU5VK0XH9LDlA1+BcPIgrAxPfaibx2DbWucuyOhc4oyQCbnvDDO68nN6/LcKfqTP343Jjg==
end-of-stream@^1.1.0, end-of-stream@^1.4.1:
version "1.4.4"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
dependencies:
once "^1.4.0"
extract-zip@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
dependencies:
debug "^4.1.1"
get-stream "^5.1.0"
yauzl "^2.10.0"
optionalDependencies:
"@types/yauzl" "^2.9.1"
fd-slicer@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
dependencies:
pend "~1.2.0"
find-up@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
dependencies:
locate-path "^5.0.0"
path-exists "^4.0.0"
fs-constants@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
get-stream@^5.1.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
dependencies:
pump "^3.0.0"
glob@^7.1.3:
version "7.1.6"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
https-proxy-agent@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz#702b71fb5520a132a66de1f67541d9e62154d82b"
integrity sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==
dependencies:
agent-base "5"
debug "4"
ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
dependencies:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@^2.0.3, inherits@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
locate-path@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
dependencies:
p-locate "^4.1.0"
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
dependencies:
brace-expansion "^1.1.7"
mkdirp-classic@^0.5.2:
version "0.5.3"
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
node-fetch@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
once@^1.3.0, once@^1.3.1, once@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
dependencies:
wrappy "1"
p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
dependencies:
p-try "^2.0.0"
p-locate@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
dependencies:
p-limit "^2.2.0"
p-try@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
pend@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
pkg-dir@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
dependencies:
find-up "^4.0.0"
pngjs@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821"
integrity sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==
progress@^2.0.1:
version "2.0.3"
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
proxy-from-env@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
pump@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
puppeteer@^5.5.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-5.5.0.tgz#331a7edd212ca06b4a556156435f58cbae08af00"
integrity sha512-OM8ZvTXAhfgFA7wBIIGlPQzvyEETzDjeRa4mZRCRHxYL+GNH5WAuYUQdja3rpWZvkX/JKqmuVgbsxDNsDFjMEg==
dependencies:
debug "^4.1.0"
devtools-protocol "0.0.818844"
extract-zip "^2.0.0"
https-proxy-agent "^4.0.0"
node-fetch "^2.6.1"
pkg-dir "^4.2.0"
progress "^2.0.1"
proxy-from-env "^1.0.0"
rimraf "^3.0.2"
tar-fs "^2.0.0"
unbzip2-stream "^1.3.3"
ws "^7.2.3"
readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
dependencies:
inherits "^2.0.3"
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
rimraf@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
dependencies:
glob "^7.1.3"
safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
string_decoder@^1.1.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
dependencies:
safe-buffer "~5.2.0"
tar-fs@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
dependencies:
chownr "^1.1.1"
mkdirp-classic "^0.5.2"
pump "^3.0.0"
tar-stream "^2.1.4"
tar-stream@^2.1.4:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
dependencies:
bl "^4.0.3"
end-of-stream "^1.4.1"
fs-constants "^1.0.0"
inherits "^2.0.3"
readable-stream "^3.1.1"
through@^2.3.8:
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
unbzip2-stream@^1.3.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==
dependencies:
buffer "^5.2.1"
through "^2.3.8"
util-deprecate@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
ws@^7.2.3:
version "7.4.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.2.tgz#782100048e54eb36fe9843363ab1c68672b261dd"
integrity sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA==
yauzl@^2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=
dependencies:
buffer-crc32 "~0.2.3"
fd-slicer "~1.1.0"

12
dash/docs/tipstricks.md Normal file
View File

@@ -0,0 +1,12 @@
# Tips & tricks
## Producing dashboard images from a webpage
A common way to produce dashboard images for the Kindle is to take a screenshot of a website.
This can be done in a variety of ways. A few things to keep in mind are:
1. The images should be grayscale PNG images, without any alpha layers.
2. The resolution should match the display resolution of the Kindle. For example the Kindle 4 NT has a resolution of 800x600 pixels.
I personally use a headless Chrome instance with [Puppeteer](https://pptr.dev/).
The code I use can be found [here](./screenshotter/screenshot.js) as a reference.

BIN
dash/example/example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
dash/example/photo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

122
dash/src/dash.sh Executable file
View File

@@ -0,0 +1,122 @@
#!/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

23
dash/src/local/env.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/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

@@ -0,0 +1,4 @@
#!/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

@@ -0,0 +1,17 @@
#!/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

1
dash/src/next-wakeup/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

143
dash/src/next-wakeup/Cargo.lock generated Normal file
View File

@@ -0,0 +1,143 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
[[package]]
name = "autocfg"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
[[package]]
name = "chrono"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
dependencies = [
"libc",
"num-integer",
"num-traits",
"time",
"winapi",
]
[[package]]
name = "chrono-tz"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2554a3155fec064362507487171dcc4edc3df60cb10f3a1fb10ed8094822b120"
dependencies = [
"chrono",
"parse-zoneinfo",
]
[[package]]
name = "cron-parser"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8446d1ce86096fd2260e732c6f5dffc752cbfa6cb14371d057bc1de3a1831b6"
dependencies = [
"chrono",
]
[[package]]
name = "kindle-dash-next-wakeup"
version = "0.1.0"
dependencies = [
"chrono",
"chrono-tz",
"cron-parser",
"pico-args",
]
[[package]]
name = "libc"
version = "0.2.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89203f3fba0a3795506acaad8ebce3c80c0af93f994d5a1d7a0b1eeb23271929"
[[package]]
name = "num-integer"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
dependencies = [
"autocfg",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
dependencies = [
"autocfg",
]
[[package]]
name = "parse-zoneinfo"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41"
dependencies = [
"regex",
]
[[package]]
name = "pico-args"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d70072c20945e1ab871c472a285fc772aefd4f5407723c206242f2c6f94595d6"
[[package]]
name = "regex"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9251239e129e16308e70d853559389de218ac275b515068abc96829d05b948a"
dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.6.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581"
[[package]]
name = "time"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
dependencies = [
"libc",
"wasi",
"winapi",
]
[[package]]
name = "wasi"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

View File

@@ -0,0 +1,15 @@
[package]
name = "kindle-dash-next-wakeup"
version = "0.1.0"
authors = ["Pascal Widdershoven <hello@pascalw.me>"]
edition = "2018"
[dependencies]
cron-parser = "0.7.7"
chrono = "0.4.19"
chrono-tz = "0.5.3"
pico-args = "0.4.0"
[[bin]]
name = "next-wakeup"
path = "src/main.rs"

View File

@@ -0,0 +1,54 @@
use chrono::Utc;
use chrono_tz::Tz;
const HELP: &str = "\
USAGE:
next-wakeup --schedule '2,32 8-17 * * MON-FRI' --timezone 'Europe/Amsterdam'
next-wakeup -s='2,32 8-17 * * MON-FRI' -tz='Europe/Amsterdam'
OPTIONS:
-tz, --timezone STRING Timezone used to interpret the cron schedule
-s, --schedule STRING Cron schedule to calculate next wakeup
-h, --help Prints help information
";
#[derive(Debug)]
struct Args {
timezone: Tz,
schedule: String,
}
fn main() {
let args = match parse_args() {
Ok(v) => v,
Err(e) => {
eprintln!("Error: {}.", e);
std::process::exit(1);
}
};
let schedule = args.schedule;
let now = Utc::now().with_timezone(&args.timezone);
let next = cron_parser::parse(&schedule, &now).expect("Invalid cron schedule");
let diff = next - now;
println!("{}", diff.num_seconds());
}
fn parse_args() -> Result<Args, pico_args::Error> {
let mut pargs = pico_args::Arguments::from_env();
if pargs.contains(["-h", "--help"]) {
print!("{}", HELP);
std::process::exit(1);
}
let args = Args {
timezone: pargs.value_from_str(["-tz", "--timezone"])?,
schedule: pargs.value_from_str(["-s", "--schedule"])?
};
Ok(args)
}

BIN
dash/src/sleeping.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

18
dash/src/start.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/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

2
dash/src/stop.sh Executable file
View File

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

26
dash/src/wait-for-wifi.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/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"

View File

@@ -0,0 +1,122 @@
#!/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

@@ -0,0 +1,23 @@
#!/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

@@ -0,0 +1,4 @@
#!/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

@@ -0,0 +1,17 @@
#!/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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,18 @@
#!/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

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

View File

@@ -0,0 +1,26 @@
#!/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"

BIN
dash/staging/device/dashboard/xh Executable file

Binary file not shown.

View File

@@ -0,0 +1,10 @@
<?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

@@ -0,0 +1,5 @@
{
"items": [
{"name": "Kindle Dashboard", "action": "/mnt/us/dashboard/start.sh"}
]
}

View File

@@ -0,0 +1,122 @@
#!/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

@@ -0,0 +1,23 @@
#!/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

@@ -0,0 +1,4 @@
#!/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

@@ -0,0 +1,17 @@
#!/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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,18 @@
#!/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

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

View File

@@ -0,0 +1,26 @@
#!/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 @@
All the things!

View File

@@ -0,0 +1,135 @@
#!/bin/sh
#
# Quick'n dirty JB key install script for LanguageBarrier.
# Based on the "emergency" script from the Hotfix/Bridge restoration package.
#
# $Id: jb.sh 18327 2021-03-24 18:08:54Z NiLuJe $
#
##
# Helper functions, in case the bridge was still kicking.
touch /mnt/us/LanguageBreakRan
make_mutable() {
local my_path="${1}"
# NOTE: Can't do that on symlinks, hence the hoop-jumping...
if [ -d "${my_path}" ] ; then
find "${my_path}" -type d -exec chattr -i '{}' \;
find "${my_path}" -type f -exec chattr -i '{}' \;
elif [ -f "${my_path}" ] ; then
chattr -i "${my_path}"
fi
}
# We actually do need that one
make_immutable() {
local my_path="${1}"
if [ -d "${my_path}" ] ; then
find "${my_path}" -type d -exec chattr +i '{}' \;
find "${my_path}" -type f -exec chattr +i '{}' \;
elif [ -f "${my_path}" ] ; then
chattr +i "${my_path}"
fi
}
POS=1
LANGBREAK_LOG="/mnt/us/languagebreak_log"
UKSSQSH="/etc/uks.sqsh"
jb_log() {
f_log "I" "languagebreak" "${2}" "" "${1}"
echo "${1}" >> "${LANGBREAK_LOG}"
eips 1 "${POS}" "${1}"
POS=$((POS+1))
sleep 0.2
}
# For logging
[ -f "/etc/upstart/functions" ] && source "/etc/upstart/functions"
rm -f "${LANGBREAK_LOG}"
touch "${LANGBREAK_LOG}"
jb_log "LanguageBreak by Marek" "info"
jb_log "It was the chinese all along." "info"
POS=$((POS+1))
jb_log "big thanks to bluebotlabs, GeorgeYellow and Niluje" "info"
jb_log "Loaded logging functions" "main"
jb_log "I am $(whoami) - $(id)"
# Duh'
mntroot rw
# JB first
if [ -f $UKSSQSH ] ; then
jb_log "${UKSSQSH} - exists - replacing whole sqshfs"
make_mutable "${UKSSQSH}"
LOOP=$(mount | grep ' on /etc/uks ' | awk '{print $1}')
jb_log "Got uks loop device at $LOOP"
umount $LOOP
losetup -d $LOOP
cp /mnt/us/patchedUks.sqsh ${UKSSQSH}
mount -o loop=$LOOP,norelatime,nodiratime,noatime -t squashfs ${UKSSQSH} /etc/uks
RET=$?
if [ $RET -eq 0 ] ; then
jb_log "Added developer key :)" "jb"
else
jb_log "Unable to add developer key (${RET})" "jb"
fi
POS=$((POS+1))
jb_log "$(ls /etc/uks)"
chown root:root "${UKSSQSH}"
chmod 0644 "${UKSSQSH}"
make_immutable "${UKSSQSH}"
jb_log "Updated permissions for new squashfs keystore" "jb"
else
jb_log "${UKSSQSH} - doesn't exist - using legacy method"
if [ -f "/etc/uks/pubdevkey01.pem" ] ; then
make_mutable "/etc/uks/pubdevkey01.pem"
rm -f "/etc/uks/pubdevkey01.pem"
wt_log "Removed existing developer key" "jb"
else
wt_log "Didn't find existing developer key" "jb"
fi
cat > "/etc/uks/pubdevkey01.pem" << EOF
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDJn1jWU+xxVv/eRKfCPR9e47lP
WN2rH33z9QbfnqmCxBRLP6mMjGy6APyycQXg3nPi5fcb75alZo+Oh012HpMe9Lnp
eEgloIdm1E4LOsyrz4kttQtGRlzCErmBGt6+cAVEV86y2phOJ3mLk0Ek9UQXbIUf
rvyJnS2MKLG2cczjlQIDAQAB
-----END PUBLIC KEY-----
EOF
RET="$?"
if [ -f "/etc/uks/pubdevkey01.pem" ] ; then
wt_log "Created developer key (${RET})" "jb"
else
wt_log "Unable to create developer key (${RET})" "jb"
fi
chown root:root "/etc/uks/pubdevkey01.pem"
chmod 0644 "/etc/uks/pubdevkey01.pem"
make_immutable "/etc/uks/pubdevkey01.pem"
wt_log "Updated permissions for developer key" "jb"
fi
# Make sure we can use UYK for OTA packages on FW >= 5.12.x
make_mutable "/PRE_GM_DEBUGGING_FEATURES_ENABLED__REMOVE_AT_GMC"
rm -rf "/PRE_GM_DEBUGGING_FEATURES_ENABLED__REMOVE_AT_GMC"
touch "/PRE_GM_DEBUGGING_FEATURES_ENABLED__REMOVE_AT_GMC"
make_immutable "/PRE_GM_DEBUGGING_FEATURES_ENABLED__REMOVE_AT_GMC"
jb_log "Enabled developer flag" "br"
make_mutable "/MNTUS_EXEC"
rm -rf "/MNTUS_EXEC"
touch "/MNTUS_EXEC"
make_immutable "/MNTUS_EXEC"
jb_log "Enabled mntus exec flag" "br"
# Bye
sync
mntroot ro
# Finally, change language back to english
lipc-send-event com.lab126.blanket.langpicker changeLocale -s "en-US"
jb_log "Finished installing jailbreak!" "main"

View File

@@ -0,0 +1,123 @@
# **LanguageBreak**
Jailbreak for any kindle running FW 5.16.2.1.1 or **LOWER**
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/E1E1QLG4D)
**The exploit works best around version 5.16.2, so if you are on lower firmware you should consider updating**
Do not update past 5.16.2.1.1 even after jailbreak, there have been big changes since and **everything** is broken, only thing you can do on these versions is downgrade (if your jailbreak survived).
Big thanks to Bluebotlabs for all the help along the way and GeorgeYellow and bulltricks for bringing the vulnerability to light
The latest tarball can always be found [here]("https://github.com/notmarek/LanguageBreak/releases/latest")
##
Make sure to remove any kind of password lock - if you forget to this and are stuck on the password screen enter 111222777 and the kindle will factory reset.
Your files **will** be deleted make sure to make a backup.
# Installation
## Before jailbreak
1. Make sure to read the entirety of the instructions **before** proceeding.
2. Enable airplane mode
3. Make sure that there are no stray .bin files or update.bin.tmp.partial files on the kindle
4. Repeat number 3 troughout the proccess
## Jailbreak
1. Type ;enter_demo in the Kindle search bar
2. Reboot the device
3. Once in demo mode, skip setting up wifi and enter random values for store registration
4. Skip searching for a demo payload
5. Select the "standard" demo type
6. Press "Done" at the prompt to sideload content.
7. Once the demo is setup, do the "secret gesture" (double finger tap on bottom right of screen then swipe left)
8. Enter the demo configuration menu by typing ;demo into the search bar
9. Select the "Sideload Content" option
10. Copy the contents of the LanguageBreak folder to the Kindle - merging and replacing all files
11. Unplug your kindle and go back to the demo menu (viz. step 8)
12. Select the "Resell Device" option press Yes/Resell
13. Now wait for the press power button to start
14. The second it appears plug your kindle back into your computer and copy the contents of the LanguageBreak folder into it once again, overwrite files then safely eject
15. Hold the power button as instructed on screen
16. A language selection menu should appear in a few seconds
17. Choose Chinese (The one above the odd Pseudot language, and/or below Japanese)
18. Your kindle should reboot and you should see some log message on the screen
## After jailbreak
1. After the device has rebooted, type ;uzb into the search bar
2. Connect the device to a PC and copy `Update_hotfix_languagebreak-{language you want to end up with}.bin` to the root of the Kindle storage
3. Eject the device and either enter ;dsts or swipe down and select the settings icon to enter the device settings menu
4. Select `Update Your Kindle` to install the hotfix
5. This will take your device out of demo mode and clean up unneeded jailbreak files.
6. You will now probably be in `managed mode`
## Exiting managed/demo mode after jailbreak
### Unregistered kindle
1. Enter `;demo` into the search bar
2. Press the right button
3. The device will say that its "entering demo", but will actually reset into normal mode in English
4. After this check if you have an mkk folder on your kindle - if it's missing reinstall then hotfix and have fun :)
### Registered kindle
1. Enter `;enter_demo` into the search bar
2. Reboot your device
3. The device will be in full demo mode so do the setup without wifi and with random values
4. Do the secret gesture to get into the kindle UI
5. Enter `;demo` into the search bar
6. Choose `Resell device` and press `Resell/Yes`
7. The device will actually reset into normal mode in English
4. After this check if you have an mkk folder on your kindle - if it's missing reinstall then hotfix and have fun :)
# FAQ
```
Q: How do i check that it worked?
A (before installing hotfix): Install hotfix, if you can do that then it worked.
A (after installing hotfix): Type `;log` into the search bar, this should show some text at the top of the screen.
Q: Where are the hotfix files?
A: The structure of the tarball is as follows
LanguageBreak.tar.gz
|-- LanguageBreak
| |-- documents
| | |-- dictionaries
| | | |-- a; export SLASH=$(awk 'BEGIN {print substr(ARGV[1], 0, 1)}' ${PWD}); sh ${SLASH}mnt${SLASH}us${SLASH}jb
| | | |-- amisane
| |-- DONT_CHECK_BATTERY
| |-- jb
| |-- patchedUks
| |-- .demo
| | |-- boot.flag
|-- Update_hotfix_languagebreak-*.bin
```
# Troubleshooting
Can't seem to get it to work?
The exploit works best around version 5.16.2, so if you are on lower firmware you should consider updating
Download the update file of the kindle version you are currently on from amazon install it and try again.
```
PW5: https://s3.amazonaws.com/firmwaredownloads/update_kindle_all_new_paperwhite_11th_5.XX.X.bin
PW4: https://s3.amazonaws.com/firmwaredownloads/update_kindle_all_new_paperwhite_v2_5.XX.X.bin
PW3: https://s3.amazonaws.com/firmwaredownloads/update_kindle_all_new_paperwhite_5.XX.X.bin
Kindle 11th Gen: https://s3.amazonaws.com/firmwaredownloads/update_kindle_11th_5.XX.X.bin
Kindle 10th Gen: https://s3.amazonaws.com/firmwaredownloads/update_kindle_10th_5.XX.X.bin
Kindle 8th Gen: https://s3.amazonaws.com/firmwaredownloads/update_kindle_8th_5.XX.X.bin
Scribe: https://s3.amazonaws.com/firmwaredownloads/update_kindle_scribe_5.XX.X.bin
Oasis 10th Gen: https://s3.amazonaws.com/firmwaredownloads/update_kindle_all_new_oasis_v2_5.XX.X.bin
Oasis 9th Gen: https://s3.amazonaws.com/firmwaredownloads/update_kindle_all_new_oasis_5.XX.X.bin
Oasis 8th Gen: https://s3.amazonaws.com/firmwaredownloads/update_kindle_oasis_5.XX.X.bin
```
So version 5.16.2.1.1 for PW4 would be [https://s3.amazonaws.com/firmwaredownloads/update_kindle_all_new_paperwhite_v2_5.16.2.1.1.bin](]https://s3.amazonaws.com/firmwaredownloads/update_kindle_all_new_paperwhite_v2_5.16.2.1.1.bin)
[Consider buying me a coffee :)]("https://ko-fi.com/notmarek")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
1.7.N @ r19303 on 2023-Nov-06 @ 18:18 - Patched for FW >= 5.16.3

View File

@@ -0,0 +1,27 @@
kindletool: KindleTool, Copyright (C) 2011-2015 Yifan Lu, licensed under the GNU General Public License version 3+ (http://www.gnu.org/licenses/gpl.html).
(https://github.com/NiLuJe/KindleTool/)
|
|-> libarchive, Copyright (C) Tim Kientzle, licensed under the New BSD License (http://www.opensource.org/licenses/bsd-license.php)
| (http://libarchive.github.com/)
|
|-> GMP, GNU MP Library, Copyright 1991-2013 Free Software Foundation, Inc.,
| licensed under the GNU Lesser General Public License version 3+ (http://www.gnu.org/licenses/lgpl.html).
| (http://gmplib.org/)
|
`-> nettle, Copyright (C) 2001-2013 Niels Möller,
licensed under the GNU Lesser General Public License version 2.1+ (https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html).
(http://www.lysator.liu.se/~nisse/nettle)
libz: zlib, Copyright (C) 1995-2013 Jean-loup Gailly and Mark Adler,
Licensed under the zlib license (http://zlib.net/zlib_license.html)
(http://zlib.net/)
fbink: FBInk (FrameBuffer eInker), Copyright (C) 2018-2019 NiLuJe <ninuje@gmail.com>,
Released under the GNU General Public License version 3+ (https://www.gnu.org/licenses/gpl.html)
(https://github.com/NiLuJe/FBInk)
BigBlue_Terminal.ttf: BigBlue Terminal +, Copyright (C) VileR, <viler@int10h.org>,
Licensed under a Creative Commons Attribution-ShareAlike 4.0 International License (http://creativecommons.org/licenses/by-sa/4.0/)
(https://int10h.org/blog/2015/12/bigblue-terminal-oldschool-fixed-width-font/).
Patched with extra glyphs via https://github.com/ryanoasis/nerd-fonts/, see the WiKi for individual licenses.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension>
<information>
<name>MR Installer</name>
<version>1.6</version>
<author>NiLuJe</author>
<id>MRInstaller</id>
</information>
<menus>
<menu type="json" dynamic="true">menu.json</menu>
</menus>
</extension>

Some files were not shown because too many files have changed in this diff Show More