diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..0b01cdd
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,44 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ remote-render:
+ name: remote-render (Node)
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: remote-render
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: remote-render/package-lock.json
+ - run: npm ci
+ - run: npm run build
+ - run: npm run typecheck
+ - run: npm test
+
+ firmware:
+ name: firmware (PlatformIO)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Cache PlatformIO
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.platformio
+ .pio
+ key: pio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
+ - run: pip install --upgrade platformio
+ - run: pio test -e host
+ - run: pio run -e esp12e
diff --git a/docs/recent-iterations.md b/docs/recent-iterations.md
index 1fb682a..e327576 100644
--- a/docs/recent-iterations.md
+++ b/docs/recent-iterations.md
@@ -70,6 +70,75 @@ project moved to the thin-client architecture.
Settings selection pulse, Brightness value/bar/knob animation, detail panel
pulse. No firmware or protocol change is needed for these animations.
+## 2026-06-29 Audit Optimizations And New Features
+
+Outcome of a code audit pass plus the requested feature work. All changes keep
+the thin-client architecture; remote-render stays at 81 vitest tests, firmware at
+31 host doctest cases, and the `esp12e` build is unchanged in footprint class.
+
+Optimizations (behavior-preserving unless noted):
+
+- Server request hardening (`server.ts`): malformed JSON now returns `422`
+ instead of `500`; non-object bodies fall through to `422`; request bodies are
+ capped at 16KB with `413`. A small `HttpError` maps these without log noise.
+- Lazy full-frame (`state.ts`): the per-device full-screen snapshot is no longer
+ re-encoded on every partial/animation/game frame. It is computed lazily by a
+ memoized getter only when a cold/resync client needs it. Byte-identical
+ semantics; verified by the existing cold-client/cleanup tests.
+- Device eviction (`state.ts`): idle device entries are swept after a TTL
+ (default 1h, max once/60s) so arbitrary or preview device ids no longer grow
+ the registry without bound. Returning devices resync via the normal full-frame
+ path.
+- Snake Hamiltonian cycle is memoized per `(columns, rows)` instead of rebuilt
+ every tick.
+- Graceful shutdown (`main.ts`): `SIGTERM`/`SIGINT` close the HTTP server so
+ in-flight long-poll requests are not hard-killed.
+- Firmware frame hot path (`HttpFrameClient.cpp`): the request URL is built with
+ `snprintf` into a fixed buffer instead of chained Arduino `String`
+ concatenation, and the three timing-response-header reads are deferred to only
+ when frame diagnostics are actually logged. This reduces per-poll heap churn at
+ ~20Hz. Also: removed the unreachable wait-loop in `Net.cpp`
+ `loadingUntilConnected`, the dead overflow guard in `parseHeaderMs`, and added
+ an offline-banner recovery path (force `have=0` once after a failed poll
+ succeeds so a stale "Render server offline" screen repaints).
+- Engineering: added a GitHub Actions CI workflow (vitest + typecheck + build,
+ `pio test -e host`, `pio run -e esp12e`), an `npm run typecheck` that also
+ checks test files, and a `docker-compose` healthcheck against `/api/v1/health`.
+- UI cleanups: removed a dead `useMemo`, deduped `nextFontLabel` into the shared
+ `nextFontKey`, and dropped an unreachable brightness "saved" label branch.
+
+New features (server-only, no firmware reflash):
+
+- Home lunar subtitle: a self-contained lunar calendar service
+ (`renderer/services/lunar.ts`, 1900-2100, no new npm dependency) adds农历 date
+ plus solar terms (二十四节气) and major festivals as a subtitle line under the
+ Gregorian date.
+- Optional weather: `renderer/services/weather.ts` polls Open-Meteo (free, no API
+ key) for Hangzhou Xiaoshan and caches the next 12 hours. Failures are silent and
+ never block the clock; weather polling starts in `main.ts`.
+ - Follow-up (same day): weather was first added as a Settings -> Weather detail
+ page, which buried glanceable info two levels deep. It was promoted onto the
+ Home screen instead. Settings now holds only config + diagnostics (no Weather
+ item). The home header and forecast regions were added to the per-second
+ dirty-render set so the weather elements refresh within ~1s of a cache update.
+
+- Calm home + game show (interaction rework): the ambient game was removed from
+ the Home screen so Home is a quiet clock + weather dashboard (current
+ temp/condition chip + a full next-12-hour forecast with per-hour temperatures
+ and a precipitation bar strip in the freed lower area). The games moved into a
+ dedicated game show (new `page: "game"`, GameShowPage = big clock + large game)
+ reached by `short_press` on Home. The show advances through the games on a
+ per-game dwell timer and on manual `short_press`, then returns to the calm home
+ after the last game; games never auto-run on Home. New regions GAME_TIME_REGION
+ / GAME_AREA_REGION drive game-show dirty updates; games render larger
+ (cellSize/canvas bumped ~216-224px wide).
+- Clock themes: a Settings -> Theme detail cycles Midnight / Sakura / Amber /
+ Mono palettes applied to the home clock, date, lunar line, and card background
+ (`renderer/services/clock-theme.ts`). Settings row spacing is now adaptive so
+ the longer menu still fits the card.
+- New ambient game: a deterministic digital-rain screensaver
+ (`renderer/services/auto-rain.ts` + widget) joins the home game rotation.
+
## Frame Transport And Diagnostics
- The binary frame format is `SDD1` with raw or RGB565 RLE rectangle payloads.
diff --git a/docs/remote-rendering-http-frame-design.md b/docs/remote-rendering-http-frame-design.md
index 4209e1d..59ae8d2 100644
--- a/docs/remote-rendering-http-frame-design.md
+++ b/docs/remote-rendering-http-frame-design.md
@@ -61,10 +61,18 @@ Supported gesture events:
Current remote UI gesture mapping:
+- Home: `short_press` starts the game show (a finite carousel through the ambient
+ games). The home screen itself stays a calm clock + weather and never runs a
+ game on its own.
- Home: `long_press` enters Settings.
- Home: `double_press` keeps the page unchanged but forces the next frame to be
a full-screen refresh. This is a manual resync path for display corruption or
missed partial updates.
+- Game show: `short_press` advances to the next game; after the last game it
+ returns to the calm home. The show also auto-advances on a per-game dwell timer
+ and returns home when finished.
+- Game show: `double_press` exits back to the calm home; `long_press` enters
+ Settings.
- Settings: `short_press` moves the selected item.
- Settings: `long_press` enters the selected detail page.
- Brightness detail: `short_press` applies the next brightness immediately and
@@ -85,10 +93,30 @@ Current Settings items:
uptime.
- `Renderer`: read-only transport/protocol summary for the remote frame link.
- `About`: device id and remote display protocol summary.
-
-The Home page is a remote-rendered Chinese desktop clock rather than a debug
-screen. It shows Chinese date and weekday, large `HH:MM`, compact seconds, a
-time-of-day greeting, and a short subtitle. Device id, tap count, sync status,
+- `Theme`: server-side clock palette selection (Midnight / Sakura / Amber /
+ Mono), applied to the home clock, date, lunar line, and card background.
+ `short_press` cycles and applies immediately, like `Font`.
+
+Settings holds only configuration and read-only diagnostics. Glanceable content
+(clock, weather) lives on the Home screen, not behind the Settings menu.
+
+The Home page is a calm single-screen clock + weather dashboard. It shows the
+Gregorian date (Arabic numerals) and short weekday, the current weather
+(temperature + condition, top-right), a lunar/solar-term/festival subtitle, large
+`HH:MM`, compact seconds, and a compact weather block: a single hourly row for the
+next few hours (现在 + condition icon + temperature) above a 今天 / 明天 / 后天 row
+(condition icon + high·low per day). Today's high/low and the next two days are the
+whole outlook — nothing beyond 后天 is shown, and the weather is kept small so the
+clock stays the hero. It runs no game animation, so it stays quiet by default.
+Temperatures are colour-coded cool→warm by value and condition icons are drawn in
+colour, so the 240x240 full-colour panel is not limited to one hue. Weather is fetched server-side from Open-Meteo for
+Hangzhou Xiaoshan and cached; failures are silent and never block the clock, and
+the weather elements simply do not render until the first forecast arrives.
+
+The ambient games (snake, life, breakout, ants, pacman, digital rain) live in a
+separate game show reached by a `short_press` on Home: a big clock on top and a
+large game below. The show auto-advances through the games on a dwell timer and
+on manual `short_press`, then returns to the calm home after the last game. Device id, tap count, sync status,
RSSI, and other development-only labels are intentionally kept out of the first
screen; detailed diagnostics live under Settings -> Device.
Hour, minute, and second digits use a server-side flip-style transition for the
diff --git a/remote-render/docker-compose.yml b/remote-render/docker-compose.yml
index 9cdedaa..9991ab8 100644
--- a/remote-render/docker-compose.yml
+++ b/remote-render/docker-compose.yml
@@ -6,6 +6,13 @@ services:
- "${REMOTE_RENDER_PORT:-8080}:8080"
environment:
NODE_ENV: production
+ # 容器内固定监听 8080;命中已存在的 /api/v1/health 端点上报健康状态。
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
logging:
driver: json-file
options:
diff --git a/remote-render/package.json b/remote-render/package.json
index dcdc1a4..82e5fd0 100644
--- a/remote-render/package.json
+++ b/remote-render/package.json
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.typecheck.json",
"test": "vitest run src",
"start": "node dist/main.js",
"preview": "node dist/tools/frame-preview.js"
diff --git a/remote-render/src/main.ts b/remote-render/src/main.ts
index 1781fa3..6377322 100644
--- a/remote-render/src/main.ts
+++ b/remote-render/src/main.ts
@@ -1,7 +1,31 @@
import {createRemoteRenderServer} from "./server.js";
+import {startWeatherPolling} from "./renderer/services/weather.js";
const port = Number(process.env.PORT ?? "8080");
const server = createRemoteRenderServer();
await server.listen(port, "0.0.0.0");
console.log(`[RemoteRender] listening on 0.0.0.0:${port}`);
+
+// 后台定时拉取萧山天气(可选功能,失败静默,不影响时钟渲染)。
+startWeatherPolling();
+
+// 容器停止时(docker stop 发送 SIGTERM)优雅关闭:停止接收新连接并让
+// 正在进行的 long-poll 请求自然结束,而不是被硬杀。重复信号直接退出。
+let shuttingDown = false;
+for (const signal of ["SIGTERM", "SIGINT"] as const) {
+ process.on(signal, () => {
+ if (shuttingDown) {
+ process.exit(0);
+ }
+ shuttingDown = true;
+ console.log(`[RemoteRender] ${signal} received, shutting down`);
+ server
+ .close()
+ .then(() => process.exit(0))
+ .catch((error: unknown) => {
+ console.error(error);
+ process.exit(1);
+ });
+ });
+}
diff --git a/remote-render/src/protocol.ts b/remote-render/src/protocol.ts
index a93cd52..3b365cc 100644
--- a/remote-render/src/protocol.ts
+++ b/remote-render/src/protocol.ts
@@ -77,7 +77,9 @@ export function encodeRgb565Rle(rgb565: Buffer | Uint8Array): Buffer {
return Buffer.alloc(0);
}
- const chunks: number[] = [];
+ // 最坏情况(无重复)每像素 3 字节,预分配后直接写入,避免构建 number[] 再拷贝。
+ const out = Buffer.allocUnsafe((rgb565.length / 2) * 3);
+ let outIndex = 0;
let runLo = rgb565[0];
let runHi = rgb565[1];
let runLength = 1;
@@ -88,13 +90,17 @@ export function encodeRgb565Rle(rgb565: Buffer | Uint8Array): Buffer {
runLength += 1;
continue;
}
- chunks.push(runLength, runLo, runHi);
+ out[outIndex++] = runLength;
+ out[outIndex++] = runLo;
+ out[outIndex++] = runHi;
runLo = lo;
runHi = hi;
runLength = 1;
}
- chunks.push(runLength, runLo, runHi);
- return Buffer.from(chunks);
+ out[outIndex++] = runLength;
+ out[outIndex++] = runLo;
+ out[outIndex++] = runHi;
+ return out.subarray(0, outIndex);
}
export function decodeRgb565Rle(payload: Buffer | Uint8Array, expectedPixels: number): Buffer {
diff --git a/remote-render/src/renderer.structure.test.ts b/remote-render/src/renderer.structure.test.ts
index f64ceed..cfff381 100644
--- a/remote-render/src/renderer.structure.test.ts
+++ b/remote-render/src/renderer.structure.test.ts
@@ -11,6 +11,7 @@ const expectedModules = [
"host/reconciler.ts",
"models/view-model.ts",
"pages/detail.tsx",
+ "pages/game-show.tsx",
"pages/home.tsx",
"pages/settings.tsx",
"rendering/animation.ts",
@@ -22,21 +23,26 @@ const expectedModules = [
"services/ant-colony.ts",
"services/auto-breakout.ts",
"services/auto-pacman.ts",
+ "services/auto-rain.ts",
"services/auto-snake.ts",
"services/clock-flip.ts",
+ "services/clock-theme.ts",
"services/conway-life.ts",
"services/font-registry.ts",
"services/home-ambient-game.ts",
"services/home-game-state.ts",
"services/home-copy.ts",
+ "services/lunar.ts",
"services/view-model.ts",
+ "services/weather.ts",
"types.ts",
"widgets/ant-colony.tsx",
"widgets/auto-breakout.tsx",
"widgets/auto-pacman.tsx",
+ "widgets/auto-rain.tsx",
"widgets/auto-snake.tsx",
"widgets/conway-life.tsx",
- "widgets/home-ambient-game.tsx",
+ "widgets/weather-icon.tsx",
] as const;
describe("renderer source structure", () => {
diff --git a/remote-render/src/renderer.test.tsx b/remote-render/src/renderer.test.tsx
index 86f3f51..23364e4 100644
--- a/remote-render/src/renderer.test.tsx
+++ b/remote-render/src/renderer.test.tsx
@@ -11,17 +11,18 @@ import {
renderDeviceView,
} from "./renderer/index.js";
import {decodeRgb565Rle, ENCODING_RGB565_RLE} from "./protocol.js";
-import {advanceHomeGameRuntime, createHomeGameRuntime, homeGameRuntimeToViewModel, switchHomeGameRuntime} from "./renderer/services/home-game-state.js";
+import {advanceHomeGameRuntime, createHomeGameRuntime, homeGameRuntimeToViewModel} from "./renderer/services/home-game-state.js";
describe("React remote renderer", () => {
test("uses Chinese date, weekday, time, and greeting copy", () => {
const copy = buildHomeCopy(new Date("2026-05-01T06:32:08.000+08:00"));
- expect(copy.dateText).toBe("五月一日");
+ expect(copy.dateText).toBe("5月1日");
expect(copy.weekdayText).toBe("星期五");
expect(copy.timeText).toBe("06:32");
expect(copy.secondsText).toBe(":08");
expect(copy.greeting).toBe("早上好");
+ expect(copy.lunarText).toBe("三月十五 · 劳动节"); // 农历汉字数字 + 公历节日
});
test("clock flip model uses explicit scheduler progress for hour, minute, and second digit changes", () => {
@@ -59,47 +60,52 @@ describe("React remote renderer", () => {
expect(Buffer.compare(flipping.rgba, settled.rgba)).not.toBe(0);
});
- test("home view renders an autonomous snake in the lower area", () => {
+ test("game-show page renders an autonomous game in the play area", () => {
const firstGame = createHomeGameRuntime("snake", 0, 0);
const nextGame = advanceHomeGameRuntime(firstGame, 1).runtime;
+ const ui = new DeviceUiState({page: "game"});
const first = renderDeviceCanvas({
currentTime: new Date("2026-05-01T12:34:56.000+08:00"),
deviceId: "desk-01",
buttonCount: 0,
+ uiState: ui,
homeGame: homeGameRuntimeToViewModel(firstGame),
});
const next = renderDeviceCanvas({
currentTime: new Date("2026-05-01T12:34:56.000+08:00"),
deviceId: "desk-01",
buttonCount: 0,
+ uiState: ui,
homeGame: homeGameRuntimeToViewModel(nextGame),
});
- const rects = computeDirtyRects(first, next, [[18, 136, 222, 226]]);
+ const rects = computeDirtyRects(first, next, [[0, 64, SCREEN_WIDTH, 232]]);
const payloadLength = rects.reduce((sum, rect) => sum + rect.payload.length, 0);
expect(rects.length).toBeGreaterThan(0);
expect(payloadLength).toBeGreaterThan(0);
- expect(payloadLength).toBeLessThan(8_000);
});
- test("home view switches the lower game from explicit state", () => {
+ test("game-show page switches the game from explicit state", () => {
const snakeGame = createHomeGameRuntime("snake", 0, 0);
- const lifeGame = switchHomeGameRuntime(snakeGame, 1);
+ const lifeGame = createHomeGameRuntime("life", 1, 1);
+ const ui = new DeviceUiState({page: "game"});
const snake = renderDeviceCanvas({
currentTime: new Date("2026-05-01T12:00:10.000+08:00"),
deviceId: "desk-01",
buttonCount: 0,
+ uiState: ui,
homeGame: homeGameRuntimeToViewModel(snakeGame),
});
const life = renderDeviceCanvas({
currentTime: new Date("2026-05-01T12:00:10.000+08:00"),
deviceId: "desk-01",
buttonCount: 0,
+ uiState: ui,
homeGame: homeGameRuntimeToViewModel(lifeGame),
});
- const rects = computeDirtyRects(snake, life, [[18, 136, 222, 226]]);
+ const rects = computeDirtyRects(snake, life, [[0, 64, SCREEN_WIDTH, 232]]);
const payloadLength = rects.reduce((sum, rect) => sum + rect.payload.length, 0);
expect(rects.length).toBeGreaterThan(0);
diff --git a/remote-render/src/renderer/components/frame-background.tsx b/remote-render/src/renderer/components/frame-background.tsx
index d51a704..07d03f7 100644
--- a/remote-render/src/renderer/components/frame-background.tsx
+++ b/remote-render/src/renderer/components/frame-background.tsx
@@ -1,9 +1,9 @@
import {Box} from "./primitives.js";
-export function FrameBackground() {
+export function FrameBackground({background = "#060a0d"}: {background?: string}) {
return (
<>
-
+
diff --git a/remote-render/src/renderer/constants.ts b/remote-render/src/renderer/constants.ts
index 4693b6b..2839d3b 100644
--- a/remote-render/src/renderer/constants.ts
+++ b/remote-render/src/renderer/constants.ts
@@ -1,7 +1,12 @@
export const SCREEN_WIDTH = 240;
export const SCREEN_HEIGHT = 240;
+// 安静首屏分区:顶部(日期+当前天气)/ 时钟带 / 下方 12h 预报。
+export const HEADER_REGION: RectTuple = [0, 8, SCREEN_WIDTH, 42];
export const TIME_REGION: RectTuple = [0, 42, SCREEN_WIDTH, 142];
-export const HOME_GAME_REGION: RectTuple = [18, 136, 222, 226];
+export const FORECAST_REGION: RectTuple = [0, 142, SCREEN_WIDTH, 232];
+// 游戏轮播页分区:顶部大时间 / 下方大游戏区。
+export const GAME_TIME_REGION: RectTuple = [0, 8, SCREEN_WIDTH, 64];
+export const GAME_AREA_REGION: RectTuple = [0, 64, SCREEN_WIDTH, 232];
export const DIRTY_TILE_WIDTH = 24;
export const DIRTY_TILE_HEIGHT = 8;
export const SUPERSAMPLE_SCALE = 1;
diff --git a/remote-render/src/renderer/hooks/useDeviceViewModel.ts b/remote-render/src/renderer/hooks/useDeviceViewModel.ts
index ea6a83e..c1dab74 100644
--- a/remote-render/src/renderer/hooks/useDeviceViewModel.ts
+++ b/remote-render/src/renderer/hooks/useDeviceViewModel.ts
@@ -1,11 +1,8 @@
-import {useMemo} from "react";
-
import type {DeviceViewModel} from "../models/view-model.js";
import {buildDeviceViewModel, type BuildDeviceViewModelInput} from "../services/view-model.js";
+// 每次渲染都会新建一个 reconciler container,且 input.state 是原地复用的可变对象,
+// 之前的 useMemo 依赖永远命中不到缓存。直接构建视图模型即可,行为不变。
export function useDeviceViewModel(input: BuildDeviceViewModelInput): DeviceViewModel {
- return useMemo(
- () => buildDeviceViewModel(input),
- [input.clockFlipProgress, input.currentTime, input.deviceId, input.homeGame, input.progress, input.state],
- );
+ return buildDeviceViewModel(input);
}
diff --git a/remote-render/src/renderer/index.ts b/remote-render/src/renderer/index.ts
index 0732774..29f45e3 100644
--- a/remote-render/src/renderer/index.ts
+++ b/remote-render/src/renderer/index.ts
@@ -6,7 +6,15 @@ import {renderDeviceCanvas} from "./rendering/device-canvas.js";
import {registerFonts} from "./services/font-registry.js";
import type {RenderedFrame} from "./types.js";
-export {HOME_GAME_REGION, SCREEN_HEIGHT, SCREEN_WIDTH, TIME_REGION} from "./constants.js";
+export {
+ FORECAST_REGION,
+ GAME_AREA_REGION,
+ GAME_TIME_REGION,
+ HEADER_REGION,
+ SCREEN_HEIGHT,
+ SCREEN_WIDTH,
+ TIME_REGION,
+} from "./constants.js";
export {renderCanvasFrame} from "./rendering/canvas-frame.js";
export {renderDeviceCanvas} from "./rendering/device-canvas.js";
export {computeDirtyRects} from "./rendering/dirty-rects.js";
diff --git a/remote-render/src/renderer/models/view-model.ts b/remote-render/src/renderer/models/view-model.ts
index 9bd2533..45ac15e 100644
--- a/remote-render/src/renderer/models/view-model.ts
+++ b/remote-render/src/renderer/models/view-model.ts
@@ -1,6 +1,8 @@
import type {HomeCopy} from "../types.js";
+import type {ClockTheme} from "../services/clock-theme.js";
+import type {WeatherView} from "../services/weather.js";
-export type DeviceViewModel = HomeViewModel | SettingsViewModel | DetailViewModel;
+export type DeviceViewModel = HomeViewModel | GameViewModel | SettingsViewModel | DetailViewModel;
export interface BaseViewModel {
fontKey: string;
@@ -10,6 +12,14 @@ export interface HomeViewModel extends BaseViewModel {
page: "home";
copy: HomeCopy;
clockGlyphs: ClockFlipGlyphViewModel[];
+ theme: ClockTheme;
+ weather?: WeatherView;
+}
+
+export interface GameViewModel extends BaseViewModel {
+ page: "game";
+ timeText: string;
+ theme: ClockTheme;
game: HomeAmbientGameViewModel;
}
@@ -62,8 +72,25 @@ export type HomeAmbientGameViewModel =
| {
kind: "pacman";
pacman: AutoPacmanViewModel;
+ }
+ | {
+ kind: "rain";
+ rain: AutoRainViewModel;
};
+export interface AutoRainViewModel {
+ columns: number;
+ rows: number;
+ cellSize: number;
+ cells: RainCellViewModel[];
+}
+
+export interface RainCellViewModel {
+ x: number;
+ y: number;
+ level: number;
+}
+
export interface ConwayLifeViewModel {
columns: number;
rows: number;
diff --git a/remote-render/src/renderer/pages/game-show.tsx b/remote-render/src/renderer/pages/game-show.tsx
new file mode 100644
index 0000000..d5ab85b
--- /dev/null
+++ b/remote-render/src/renderer/pages/game-show.tsx
@@ -0,0 +1,51 @@
+import {Box, Screen, Text} from "../components/primitives.js";
+import type {GameViewModel, HomeAmbientGameViewModel} from "../models/view-model.js";
+import {AntColony} from "../widgets/ant-colony.js";
+import {AutoBreakout} from "../widgets/auto-breakout.js";
+import {AutoPacman} from "../widgets/auto-pacman.js";
+import {AutoRain} from "../widgets/auto-rain.js";
+import {AutoSnake} from "../widgets/auto-snake.js";
+import {ConwayLife} from "../widgets/conway-life.js";
+
+// 游戏轮播页:顶部一个较大的时间,其余空间放大展示当前游戏。短按切下一个,
+// 播完自动回到安静首页(详见 state.ts 的 game-show 逻辑)。
+export function GameShowPage({model}: {model: GameViewModel}) {
+ const {width, height} = gameSize(model.game);
+ const x = Math.max(8, Math.round((240 - width) / 2));
+ const y = Math.max(64, Math.round(64 + (168 - height) / 2));
+ return (
+
+
+
+ {model.timeText}
+
+
+
+
+
+ );
+}
+
+function AmbientGame({game}: {game: HomeAmbientGameViewModel}) {
+ if (game.kind === "snake") return ;
+ if (game.kind === "life") return ;
+ if (game.kind === "breakout") return ;
+ if (game.kind === "ants") return ;
+ if (game.kind === "pacman") return ;
+ return ;
+}
+
+function gameSize(game: HomeAmbientGameViewModel): {width: number; height: number} {
+ if (game.kind === "breakout") return {width: game.breakout.width, height: game.breakout.height};
+ const grid =
+ game.kind === "snake"
+ ? game.snake
+ : game.kind === "life"
+ ? game.life
+ : game.kind === "ants"
+ ? game.ants
+ : game.kind === "pacman"
+ ? game.pacman
+ : game.rain;
+ return {width: grid.columns * grid.cellSize, height: grid.rows * grid.cellSize};
+}
diff --git a/remote-render/src/renderer/pages/home.tsx b/remote-render/src/renderer/pages/home.tsx
index 4d095bc..00e7cb8 100644
--- a/remote-render/src/renderer/pages/home.tsx
+++ b/remote-render/src/renderer/pages/home.tsx
@@ -2,24 +2,93 @@ import {FrameBackground} from "../components/frame-background.js";
import {Box, Screen, Text} from "../components/primitives.js";
import type {ClockFlipGlyphViewModel, HomeViewModel} from "../models/view-model.js";
import {mixColor} from "../services/color.js";
-import {HomeAmbientGame} from "../widgets/home-ambient-game.js";
+import {tempColor, type WeatherDayView, type WeatherView} from "../services/weather.js";
+import {WeatherIcon} from "../widgets/weather-icon.js";
export function HomePage({model}: {model: HomeViewModel}) {
+ const theme = model.theme;
+ const weather = model.weather;
return (
-
-
-
- {`${model.copy.dateText} ${model.copy.weekdayText}`}
+
+
+ {/* 顶部:左日期 + 右当前天气(图标 + 彩色温度) */}
+
+ {`${model.copy.dateText} ${model.copy.weekdayShort}`}
+
+ {weather ? (
+ <>
+
+
+ {`${weather.current.temp}°`}
+
+ >
+ ) : null}
+
+ {model.copy.lunarText}
{model.clockGlyphs.map((glyph) => (
-
+
))}
-
+ {weather ? : null}
+ {weather && weather.days.length >= 2 ? : null}
);
}
-function ClockGlyph({glyph}: {glyph: ClockFlipGlyphViewModel}) {
+// 接下来几小时(逐小时,6 列):小时 / 图标 / 彩色温度。保持紧凑,不放每小时降水%。
+function HourlyForecast({weather}: {weather: WeatherView}) {
+ const columns = weather.hours.slice(0, 6);
+ return (
+ <>
+ {columns.map((hour, index) => (
+
+ ))}
+ >
+ );
+}
+
+function HourColumn({label, hour, cx}: {label: string; hour: WeatherView["hours"][number]; cx: number}) {
+ return (
+ <>
+
+ {label}
+
+
+
+ {`${hour.temp}°`}
+
+ >
+ );
+}
+
+// 今天最高/最低 + 明天 + 后天(三列,紧凑):日 / 图标 / 高温·低温。不看两天之后。
+function DailyOutlook({weather}: {weather: WeatherView}) {
+ const days = weather.days.slice(0, 3);
+ return (
+ <>
+ {days.map((day, index) => (
+
+ ))}
+ >
+ );
+}
+
+function DailyColumn({day, cx}: {day: WeatherDayView; cx: number}) {
+ return (
+ <>
+ {day.label}
+
+
+ {`${day.tempMax}°`}
+
+
+ {`${day.tempMin}°`}
+
+ >
+ );
+}
+
+function ClockGlyph({glyph, background}: {glyph: ClockFlipGlyphViewModel; background: string}) {
const baseStyle = {x: 0, width: glyph.width, height: glyph.height, fontSize: glyph.fontSize, alignItems: "center"} as const;
if (glyph.previousChar === glyph.char) {
return (
@@ -31,9 +100,9 @@ function ClockGlyph({glyph}: {glyph: ClockFlipGlyphViewModel}) {
const eased = glyph.progress;
const travel = glyph.height * 0.5;
- const muted = mixColor(glyph.color, "#05080a", 0.72);
+ const muted = mixColor(glyph.color, background, 0.72);
return (
-
+
{glyph.previousChar}
diff --git a/remote-render/src/renderer/pages/settings.tsx b/remote-render/src/renderer/pages/settings.tsx
index ada801e..98abf2a 100644
--- a/remote-render/src/renderer/pages/settings.tsx
+++ b/remote-render/src/renderer/pages/settings.tsx
@@ -5,19 +5,24 @@ import type {SettingsRowViewModel, SettingsViewModel} from "../models/view-model
import {mixColor} from "../services/color.js";
export function SettingsPage({model}: {model: SettingsViewModel}) {
+ // 行距随条目数自适应:<=5 项保持原布局,更多项时收紧以始终容纳在卡片内。
+ const count = model.rows.length;
+ const spacing = count <= 5 ? 33 : Math.floor(170 / count);
+ const top = count <= 5 ? 58 : 50;
+ const rowHeight = count <= 5 ? 32 : spacing - 3;
return (
Settings
remote
{model.rows.map((row, index) => (
-
+
))}
);
}
-function Row({row, y, pulse}: {row: SettingsRowViewModel; y: number; pulse: number}) {
+function Row({row, y, height, pulse}: {row: SettingsRowViewModel; y: number; height: number; pulse: number}) {
return (
<>
{
+ test("advances deterministically and never ends", () => {
+ let runtime = createAutoRainRuntime({seed: "home-rain:0"});
+ expect(runtime.tick).toBe(0);
+ for (let index = 0; index < 50; index += 1) {
+ const advanced = advanceAutoRainRuntime(runtime);
+ expect(advanced.status).toBe("playing");
+ runtime = advanced.runtime;
+ }
+ expect(runtime.tick).toBe(50);
+ });
+
+ test("keeps every cell inside the grid with a bright head", () => {
+ const model = buildAutoRainViewModel({seed: "home-rain:0", step: 7});
+ expect(model.cells.length).toBeGreaterThan(0);
+ for (const cell of model.cells) {
+ expect(cell.x).toBeGreaterThanOrEqual(0);
+ expect(cell.x).toBeLessThan(model.columns);
+ expect(cell.y).toBeGreaterThanOrEqual(0);
+ expect(cell.y).toBeLessThan(model.rows);
+ expect(cell.level).toBeGreaterThan(0);
+ expect(cell.level).toBeLessThanOrEqual(1);
+ }
+ // 每列至多一个 head(level === 1)
+ const heads = model.cells.filter((cell) => cell.level >= 1);
+ expect(new Set(heads.map((cell) => cell.x)).size).toBe(heads.length);
+ });
+
+ test("is a pure function of (seed, tick)", () => {
+ const first = autoRainRuntimeToViewModel(advanceAutoRainRuntime(createAutoRainRuntime({seed: "s"})).runtime);
+ const second = autoRainRuntimeToViewModel(advanceAutoRainRuntime(createAutoRainRuntime({seed: "s"})).runtime);
+ expect(second).toEqual(first);
+ const other = buildAutoRainViewModel({seed: "different", step: 1});
+ expect(other.cells).not.toEqual(first.cells);
+ });
+});
diff --git a/remote-render/src/renderer/services/auto-rain.ts b/remote-render/src/renderer/services/auto-rain.ts
new file mode 100644
index 0000000..4b75918
--- /dev/null
+++ b/remote-render/src/renderer/services/auto-rain.ts
@@ -0,0 +1,65 @@
+import type {AutoRainViewModel, RainCellViewModel} from "../models/view-model.js";
+
+// 数字雨屏保:每列一条向下流动的光带,按 (seed, 列) 错峰,纯函数由 tick 推导,
+// 完全确定、无随机状态,永不结束(status 恒为 playing)。
+
+const DEFAULT_COLUMNS = 32;
+const DEFAULT_ROWS = 13;
+const DEFAULT_CELL_SIZE = 6;
+
+export interface AutoRainRuntime {
+ columns: number;
+ rows: number;
+ cellSize: number;
+ seed: string;
+ tick: number;
+}
+
+export function createAutoRainRuntime(input: {seed: string; columns?: number; rows?: number; cellSize?: number}): AutoRainRuntime {
+ return {
+ columns: input.columns ?? DEFAULT_COLUMNS,
+ rows: input.rows ?? DEFAULT_ROWS,
+ cellSize: input.cellSize ?? DEFAULT_CELL_SIZE,
+ seed: input.seed,
+ tick: 0,
+ };
+}
+
+export function advanceAutoRainRuntime(state: AutoRainRuntime): {runtime: AutoRainRuntime; status: "playing"} {
+ return {runtime: {...state, tick: state.tick + 1}, status: "playing"};
+}
+
+export function autoRainRuntimeToViewModel(state: AutoRainRuntime): AutoRainViewModel {
+ const period = state.rows + 7; // 比行数多出空档,让光带之间有间隔
+ const cells: RainCellViewModel[] = [];
+ for (let column = 0; column < state.columns; column += 1) {
+ const phase = hash(`${state.seed}:phase:${column}`) % period;
+ const length = 3 + (hash(`${state.seed}:len:${column}`) % 4); // 3..6
+ const head = (state.tick + phase) % period;
+ for (let trail = 0; trail < length; trail += 1) {
+ const y = head - trail;
+ if (y < 0 || y >= state.rows) continue;
+ const level = trail === 0 ? 1 : Math.max(0.18, 1 - trail / length);
+ cells.push({x: column, y, level});
+ }
+ }
+ return {columns: state.columns, rows: state.rows, cellSize: state.cellSize, cells};
+}
+
+export function buildAutoRainViewModel(input: {seed: string; step?: number; columns?: number; rows?: number; cellSize?: number}): AutoRainViewModel {
+ let state = createAutoRainRuntime(input);
+ const step = Math.max(0, Math.floor(input.step ?? 0));
+ for (let index = 0; index < step; index += 1) {
+ state = advanceAutoRainRuntime(state).runtime;
+ }
+ return autoRainRuntimeToViewModel(state);
+}
+
+function hash(value: string): number {
+ let result = 2166136261;
+ for (const char of value) {
+ result ^= char.charCodeAt(0);
+ result = Math.imul(result, 16777619);
+ }
+ return result >>> 0;
+}
diff --git a/remote-render/src/renderer/services/auto-snake.ts b/remote-render/src/renderer/services/auto-snake.ts
index 85cbc8b..40fc9df 100644
--- a/remote-render/src/renderer/services/auto-snake.ts
+++ b/remote-render/src/renderer/services/auto-snake.ts
@@ -264,7 +264,21 @@ function reachableArea(start: SnakeCellViewModel, occupied: Set, columns
return seen.size;
}
+// 哈密顿回路只取决于 (columns, rows),是纯函数。chooseDirection 每个 tick 都会
+// 调用它,这里按网格尺寸缓存,避免每秒重建 240 元素数组 + Map。回路对象只读
+// (cells 永不被修改),共享引用安全。
+const hamiltonianCycleCache = new Map();
+
function buildHamiltonianCycle(columns: number, rows: number): HamiltonianCycle | null {
+ const cacheKey = `${columns}x${rows}`;
+ const cached = hamiltonianCycleCache.get(cacheKey);
+ if (cached !== undefined) return cached;
+ const result = computeHamiltonianCycle(columns, rows);
+ hamiltonianCycleCache.set(cacheKey, result);
+ return result;
+}
+
+function computeHamiltonianCycle(columns: number, rows: number): HamiltonianCycle | null {
if (columns <= 1 || rows <= 1) return null;
const cells = rows % 2 === 0 ? buildEvenRowsCycle(columns, rows) : columns % 2 === 0 ? transposeCycle(buildEvenRowsCycle(rows, columns)) : null;
if (!cells || cells.length !== columns * rows || !sameCellDistance(cells[0], cells[cells.length - 1])) return null;
diff --git a/remote-render/src/renderer/services/clock-flip.ts b/remote-render/src/renderer/services/clock-flip.ts
index 00adfa2..03fefb7 100644
--- a/remote-render/src/renderer/services/clock-flip.ts
+++ b/remote-render/src/renderer/services/clock-flip.ts
@@ -18,6 +18,8 @@ const SECONDS_LAYOUT = [
export interface BuildClockFlipGlyphsOptions {
durationMs?: number;
progress?: number;
+ timeColor?: string;
+ secondsColor?: string;
}
export function buildClockFlipGlyphs(currentTime: Date, options: BuildClockFlipGlyphsOptions = {}): ClockFlipGlyphViewModel[] {
@@ -32,7 +34,7 @@ export function buildClockFlipGlyphs(currentTime: Date, options: BuildClockFlipG
y: 68,
height: 62,
fontSize: 52,
- color: "#f0f8ee",
+ color: options.timeColor ?? "#f0f8ee",
layout: BIG_TIME_LAYOUT,
}),
...buildGlyphs({
@@ -43,7 +45,7 @@ export function buildClockFlipGlyphs(currentTime: Date, options: BuildClockFlipG
y: 92,
height: 24,
fontSize: 18,
- color: "#80dac6",
+ color: options.secondsColor ?? "#80dac6",
layout: SECONDS_LAYOUT,
}),
];
diff --git a/remote-render/src/renderer/services/clock-theme.ts b/remote-render/src/renderer/services/clock-theme.ts
new file mode 100644
index 0000000..9edbf1c
--- /dev/null
+++ b/remote-render/src/renderer/services/clock-theme.ts
@@ -0,0 +1,22 @@
+import {THEME_MIDNIGHT, THEME_SAKURA, THEME_AMBER, THEME_MONO} from "../../ui-state.js";
+
+// 时钟主题调色板:仅服务端渲染,切换不需要重新烧录固件。
+export interface ClockTheme {
+ background: string; // 卡片 / 屏幕底色
+ time: string; // HH:MM 主色
+ seconds: string; // 秒与强调色
+ date: string; // 公历日期行
+ lunar: string; // 农历副标题
+}
+
+const THEMES: Record = {
+ // 中性深色(参考 Apple 深色天气):干净的白与冷灰,去掉之前偏绿的色调。
+ [THEME_MIDNIGHT]: {background: "#080b0f", time: "#f3f6fa", seconds: "#8fa0ad", date: "#cad3dc", lunar: "#8b96a1"},
+ [THEME_SAKURA]: {background: "#0b0609", time: "#ffe6ef", seconds: "#ff9ec6", date: "#e6b6cc", lunar: "#b9889e"},
+ [THEME_AMBER]: {background: "#0a0805", time: "#ffe7b8", seconds: "#ffb84d", date: "#d8c298", lunar: "#a89169"},
+ [THEME_MONO]: {background: "#070708", time: "#f3f3f3", seconds: "#9aa2a2", date: "#bdbdbd", lunar: "#888c8c"},
+};
+
+export function resolveClockTheme(key: string): ClockTheme {
+ return THEMES[key] ?? THEMES[THEME_MIDNIGHT];
+}
diff --git a/remote-render/src/renderer/services/font-registry.ts b/remote-render/src/renderer/services/font-registry.ts
index d909962..b780c5e 100644
--- a/remote-render/src/renderer/services/font-registry.ts
+++ b/remote-render/src/renderer/services/font-registry.ts
@@ -1,7 +1,7 @@
import path from "node:path";
import {GlobalFonts} from "@napi-rs/canvas";
-import {FONT_MAPLE_MONO_NF_CN, FONT_NOTO_CJK, FONT_WENKAI_SCREEN} from "../../ui-state.js";
+import {FONT_MAPLE_MONO_NF_CN, FONT_NOTO_CJK} from "../../ui-state.js";
export function registerFonts(): void {
const candidates: Array<[string, string]> = [
@@ -27,9 +27,3 @@ export function fontFamily(fontKey: string): string {
if (fontKey === FONT_NOTO_CJK) return '"Noto Sans CJK", "PingFang SC", "STHeiti", sans-serif';
return '"LXGW WenKai Screen", "Noto Sans CJK", "PingFang SC", "STHeiti", sans-serif';
}
-
-export function nextFontLabel(fontKey: string): string {
- if (fontKey === FONT_WENKAI_SCREEN) return FONT_MAPLE_MONO_NF_CN;
- if (fontKey === FONT_MAPLE_MONO_NF_CN) return FONT_NOTO_CJK;
- return FONT_WENKAI_SCREEN;
-}
diff --git a/remote-render/src/renderer/services/home-ambient-game.test.ts b/remote-render/src/renderer/services/home-ambient-game.test.ts
index 007cf5b..c28e2b5 100644
--- a/remote-render/src/renderer/services/home-ambient-game.test.ts
+++ b/remote-render/src/renderer/services/home-ambient-game.test.ts
@@ -18,12 +18,14 @@ describe("home ambient game view model", () => {
const breakout = switchHomeGameRuntime(life, 6);
const ants = switchHomeGameRuntime(breakout, 9);
const pacman = switchHomeGameRuntime(ants, 12);
- const snakeAgain = switchHomeGameRuntime(pacman, 15);
+ const rain = switchHomeGameRuntime(pacman, 15);
+ const snakeAgain = switchHomeGameRuntime(rain, 18);
expect(life.kind).toBe("life");
expect(breakout.kind).toBe("breakout");
expect(ants.kind).toBe("ants");
expect(pacman.kind).toBe("pacman");
+ expect(rain.kind).toBe("rain");
expect(snakeAgain.kind).toBe("snake");
});
@@ -68,7 +70,7 @@ describe("home ambient game view model", () => {
expect(restarted.runtime.kind).toBe("pacman");
expect(restarted.runtime.startedAt).toBe(0);
expect(timedOut.status).toBe("timeout");
- expect(timedOut.runtime.kind).toBe("snake");
+ expect(timedOut.runtime.kind).toBe("rain");
expect(timedOut.runtime.startedAt).toBe(1200);
});
});
diff --git a/remote-render/src/renderer/services/home-copy.ts b/remote-render/src/renderer/services/home-copy.ts
index 468b500..82eeab6 100644
--- a/remote-render/src/renderer/services/home-copy.ts
+++ b/remote-render/src/renderer/services/home-copy.ts
@@ -1,18 +1,22 @@
import type {HomeCopy} from "../types.js";
+import {describeLunarDate} from "./lunar.js";
export function buildHomeCopy(currentTime: Date): HomeCopy {
const parts = getShanghaiParts(currentTime);
+ const lunar = describeLunarDate(parts.year, parts.month, parts.day);
return {
- dateText: `${chineseMonth(parts.month)}月${chineseDay(parts.day)}日`,
+ dateText: `${parts.month}月${parts.day}日`,
weekdayText: chineseWeekday(parts.weekday),
+ weekdayShort: chineseWeekdayShort(parts.weekday),
timeText: `${pad2(parts.hour)}:${pad2(parts.minute)}`,
secondsText: `:${pad2(parts.second)}`,
greeting: greetingForHour(parts.hour),
subtitle: subtitleForHour(parts.hour),
+ lunarText: lunar.label ? `${lunar.lunarDate} · ${lunar.label}` : lunar.lunarDate,
};
}
-function getShanghaiParts(date: Date): {month: number; day: number; weekday: number; hour: number; minute: number; second: number} {
+function getShanghaiParts(date: Date): {year: number; month: number; day: number; weekday: number; hour: number; minute: number; second: number} {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Shanghai",
year: "numeric",
@@ -27,6 +31,7 @@ function getShanghaiParts(date: Date): {month: number; day: number; weekday: num
const value = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);
const weekdayMap: Record = {Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6};
return {
+ year: value("year"),
month: value("month"),
day: value("day"),
weekday: weekdayMap[parts.find((part) => part.type === "weekday")?.value ?? "Mon"] ?? 0,
@@ -36,27 +41,14 @@ function getShanghaiParts(date: Date): {month: number; day: number; weekday: num
};
}
-function chineseMonth(month: number): string {
- return ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"][Math.max(1, Math.min(12, month)) - 1];
-}
-
-function chineseDay(day: number): string {
- return chineseNumber(Math.max(1, Math.min(31, day)));
-}
-
-function chineseNumber(value: number): string {
- const digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
- if (value <= 10) return value === 10 ? "十" : digits[value];
- if (value < 20) return `十${digits[value - 10]}`;
- const tens = Math.floor(value / 10);
- const ones = value % 10;
- return ones === 0 ? `${digits[tens]}十` : `${digits[tens]}十${digits[ones]}`;
-}
-
function chineseWeekday(weekday: number): string {
return ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"][Math.max(0, Math.min(6, weekday))];
}
+function chineseWeekdayShort(weekday: number): string {
+ return ["周一", "周二", "周三", "周四", "周五", "周六", "周日"][Math.max(0, Math.min(6, weekday))];
+}
+
function greetingForHour(hour: number): string {
if (hour >= 5 && hour < 11) return "早上好";
if (hour >= 11 && hour < 14) return "中午好";
diff --git a/remote-render/src/renderer/services/home-game-state.ts b/remote-render/src/renderer/services/home-game-state.ts
index dae25ba..fc303e7 100644
--- a/remote-render/src/renderer/services/home-game-state.ts
+++ b/remote-render/src/renderer/services/home-game-state.ts
@@ -17,6 +17,12 @@ import {
autoPacmanRuntimeToViewModel,
createAutoPacmanRuntime,
} from "./auto-pacman.js";
+import {
+ type AutoRainRuntime,
+ advanceAutoRainRuntime,
+ autoRainRuntimeToViewModel,
+ createAutoRainRuntime,
+} from "./auto-rain.js";
import {
type AutoSnakeRuntime,
advanceAutoSnakeRuntime,
@@ -30,10 +36,10 @@ import {
createConwayLifeRuntime,
} from "./conway-life.js";
-export type HomeGameKind = "snake" | "life" | "breakout" | "ants" | "pacman";
+export type HomeGameKind = "snake" | "life" | "breakout" | "ants" | "pacman" | "rain";
export type HomeGameAdvanceStatus = "playing" | "failed" | "won" | "timeout";
-export const HOME_GAME_KINDS: HomeGameKind[] = ["snake", "life", "breakout", "ants", "pacman"];
+export const HOME_GAME_KINDS: HomeGameKind[] = ["snake", "life", "breakout", "ants", "pacman", "rain"];
export const HOME_GAME_ROUND_SECONDS = 20 * 60;
export interface HomeGameRuntime {
@@ -46,23 +52,28 @@ export interface HomeGameRuntime {
breakout?: AutoBreakoutRuntime;
ants?: AntColonyRuntime;
pacman?: AutoPacmanRuntime;
+ rain?: AutoRainRuntime;
}
export function createHomeGameRuntime(kind: HomeGameKind = "snake", round = 0, startedAt = 0): HomeGameRuntime {
const seed = `home-${kind}:${round}`;
+ // 游戏现在在轮播页放大展示,使用较大的网格 / 画布尺寸(约 216-224px 宽)。
if (kind === "snake") {
- return {kind, round, startedAt, seed, snake: createAutoSnakeRuntime()};
+ return {kind, round, startedAt, seed, snake: createAutoSnakeRuntime({cellSize: 9})};
}
if (kind === "life") {
- return {kind, round, startedAt, seed, life: createConwayLifeRuntime({seed})};
+ return {kind, round, startedAt, seed, life: createConwayLifeRuntime({seed, cellSize: 7})};
}
if (kind === "breakout") {
- return {kind, round, startedAt, seed, breakout: createAutoBreakoutRuntime({seed})};
+ return {kind, round, startedAt, seed, breakout: createAutoBreakoutRuntime({seed, width: 224, height: 132})};
}
if (kind === "ants") {
- return {kind, round, startedAt, seed, ants: createAntColonyRuntime({seed})};
+ return {kind, round, startedAt, seed, ants: createAntColonyRuntime({seed, cellSize: 7})};
+ }
+ if (kind === "rain") {
+ return {kind, round, startedAt, seed, rain: createAutoRainRuntime({seed, cellSize: 7})};
}
- return {kind, round, startedAt, seed, pacman: createAutoPacmanRuntime({seed})};
+ return {kind, round, startedAt, seed, pacman: createAutoPacmanRuntime({seed, cellSize: 9})};
}
export function advanceHomeGameRuntime(runtime: HomeGameRuntime, now: number): {runtime: HomeGameRuntime; status: HomeGameAdvanceStatus} {
@@ -98,6 +109,10 @@ export function advanceHomeGameRuntime(runtime: HomeGameRuntime, now: number): {
}
return {runtime: {...runtime, pacman: advanced.runtime}, status: "playing"};
}
+ if (runtime.kind === "rain" && runtime.rain) {
+ const advanced = advanceAutoRainRuntime(runtime.rain);
+ return {runtime: {...runtime, rain: advanced.runtime}, status: advanced.status};
+ }
return {runtime: restartHomeGameRuntime(runtime), status: "failed"};
}
@@ -121,6 +136,9 @@ export function homeGameRuntimeToViewModel(runtime: HomeGameRuntime): HomeAmbien
if (runtime.kind === "pacman" && runtime.pacman) {
return {kind: "pacman", pacman: autoPacmanRuntimeToViewModel(runtime.pacman)};
}
+ if (runtime.kind === "rain" && runtime.rain) {
+ return {kind: "rain", rain: autoRainRuntimeToViewModel(runtime.rain)};
+ }
return {kind: "snake", snake: autoSnakeRuntimeToViewModel(createAutoSnakeRuntime())};
}
diff --git a/remote-render/src/renderer/services/lunar.test.ts b/remote-render/src/renderer/services/lunar.test.ts
new file mode 100644
index 0000000..6c004fd
--- /dev/null
+++ b/remote-render/src/renderer/services/lunar.test.ts
@@ -0,0 +1,35 @@
+import {describe, expect, test} from "vitest";
+
+import {describeLunarDate} from "./lunar.js";
+
+describe("lunar calendar", () => {
+ test("maps well-known Spring Festivals to 正月初一/春节", () => {
+ expect(describeLunarDate(2024, 2, 10)).toMatchObject({lunarDate: "正月初一", label: "春节"});
+ expect(describeLunarDate(2025, 1, 29)).toMatchObject({lunarDate: "正月初一", label: "春节"});
+ expect(describeLunarDate(2026, 2, 17)).toMatchObject({lunarDate: "正月初一", label: "春节"});
+ });
+
+ test("maps major lunar festivals", () => {
+ expect(describeLunarDate(2026, 6, 19)).toMatchObject({lunarDate: "五月初五", label: "端午节"});
+ expect(describeLunarDate(2026, 9, 25)).toMatchObject({lunarDate: "八月十五", label: "中秋节"});
+ expect(describeLunarDate(2025, 10, 6)).toMatchObject({lunarDate: "八月十五", label: "中秋节"});
+ });
+
+ test("maps Gregorian public holidays", () => {
+ expect(describeLunarDate(2026, 1, 1).label).toBe("元旦");
+ expect(describeLunarDate(2026, 10, 1).label).toBe("国庆节");
+ expect(describeLunarDate(2026, 5, 1).label).toBe("劳动节");
+ });
+
+ test("recognises solar terms", () => {
+ expect(describeLunarDate(2026, 4, 5).label).toBe("清明");
+ expect(describeLunarDate(2026, 6, 21).label).toBe("夏至");
+ expect(describeLunarDate(2025, 12, 21).label).toBe("冬至");
+ });
+
+ test("formats an ordinary lunar day without a label", () => {
+ const ordinary = describeLunarDate(2026, 6, 28);
+ expect(ordinary.lunarDate).toMatch(/^[正二三四五六七八九十冬腊]月/);
+ expect(ordinary.label).toBeUndefined();
+ });
+});
diff --git a/remote-render/src/renderer/services/lunar.ts b/remote-render/src/renderer/services/lunar.ts
new file mode 100644
index 0000000..586bf30
--- /dev/null
+++ b/remote-render/src/renderer/services/lunar.ts
@@ -0,0 +1,191 @@
+// 自包含的农历 / 二十四节气 / 传统节日推算(不引入第三方依赖)。
+// 算法采用业界通用的 1900-2100 lunarInfo 查表法 + 香港天文台节气公式,
+// 适用区间 1900-2100,作为时钟副标题展示足够准确。
+
+// 每个元素编码该农历年的闰月位置与各月大小,覆盖 1900-2100。
+const LUNAR_INFO = [
+ 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909
+ 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919
+ 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929
+ 0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939
+ 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949
+ 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959
+ 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969
+ 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979
+ 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989
+ 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999
+ 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009
+ 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019
+ 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029
+ 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039
+ 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049
+ 0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0, // 2050-2059
+ 0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069
+ 0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079
+ 0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089
+ 0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099
+ 0x0d520, // 2100
+];
+
+const SOLAR_TERM_OFFSETS = [
+ 0, 21208, 42467, 63836, 85337, 107014, 128867, 150921, 173149, 195551, 218072, 240693, 263343, 285989, 308563,
+ 331033, 353350, 375494, 397447, 419210, 440795, 462224, 483532, 504758,
+];
+
+const SOLAR_TERM_NAMES = [
+ "小寒", "大寒", "立春", "雨水", "惊蛰", "春分", "清明", "谷雨", "立夏", "小满", "芒种", "夏至",
+ "小暑", "大暑", "立秋", "处暑", "白露", "秋分", "寒露", "霜降", "立冬", "小雪", "大雪", "冬至",
+];
+
+// 农历采用常规汉字数字(一二三…十),与公历的阿拉伯数字形成对比。
+const LUNAR_MONTH_NAMES = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];
+const LUNAR_DAY_DIGITS = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
+
+const LUNAR_FESTIVALS: Record = {
+ "1-1": "春节",
+ "1-15": "元宵节",
+ "2-2": "龙抬头",
+ "5-5": "端午节",
+ "7-7": "七夕",
+ "7-15": "中元节",
+ "8-15": "中秋节",
+ "9-9": "重阳节",
+ "12-8": "腊八节",
+};
+
+const SOLAR_FESTIVALS: Record = {
+ "1-1": "元旦",
+ "3-12": "植树节",
+ "5-1": "劳动节",
+ "6-1": "儿童节",
+ "8-1": "建军节",
+ "9-10": "教师节",
+ "10-1": "国庆节",
+};
+
+export interface LunarDescription {
+ // 例如 "五月初四"
+ lunarDate: string;
+ // 节日或节气(节日优先),无则为 undefined
+ label?: string;
+}
+
+interface LunarDate {
+ year: number;
+ month: number;
+ day: number;
+ isLeap: boolean;
+}
+
+const MIN_YEAR = 1900;
+const MAX_YEAR = 2100;
+
+// 给定公历(上海本地)年月日,返回农历日期串与节日/节气标签。
+export function describeLunarDate(year: number, month: number, day: number): LunarDescription {
+ if (year < MIN_YEAR || year > MAX_YEAR) {
+ return {lunarDate: ""};
+ }
+ const lunar = solarToLunar(year, month, day);
+ const lunarDate = formatLunarMonthDay(lunar);
+ return {lunarDate, label: festivalOrTerm(lunar, year, month, day)};
+}
+
+function festivalOrTerm(lunar: LunarDate, year: number, month: number, day: number): string | undefined {
+ // 除夕:腊月最后一天
+ if (!lunar.isLeap && lunar.month === 12 && lunar.day === monthDays(lunar.year, 12)) {
+ return "除夕";
+ }
+ if (!lunar.isLeap) {
+ const festival = LUNAR_FESTIVALS[`${lunar.month}-${lunar.day}`];
+ if (festival) return festival;
+ }
+ const solarFestival = SOLAR_FESTIVALS[`${month}-${day}`];
+ if (solarFestival) return solarFestival;
+ return solarTermOn(year, month, day);
+}
+
+// 该公历日期若恰为某节气,返回节气名,否则 undefined。
+function solarTermOn(year: number, month: number, day: number): string | undefined {
+ for (const termIndex of [(month - 1) * 2, (month - 1) * 2 + 1]) {
+ if (solarTermDay(year, termIndex) === day) {
+ return SOLAR_TERM_NAMES[termIndex];
+ }
+ }
+ return undefined;
+}
+
+function solarTermDay(year: number, termIndex: number): number {
+ const ms = 31556925974.7 * (year - MIN_YEAR) + SOLAR_TERM_OFFSETS[termIndex] * 60000;
+ const date = new Date(ms + Date.UTC(MIN_YEAR, 0, 6, 2, 5, 0));
+ return date.getUTCDate();
+}
+
+function lunarYearDays(year: number): number {
+ let sum = 348; // 12 个月 * 29 天
+ for (let bit = 0x8000; bit > 0x8; bit >>= 1) {
+ sum += LUNAR_INFO[year - MIN_YEAR] & bit ? 1 : 0;
+ }
+ return sum + leapDays(year);
+}
+
+function leapMonth(year: number): number {
+ return LUNAR_INFO[year - MIN_YEAR] & 0xf;
+}
+
+function leapDays(year: number): number {
+ if (leapMonth(year) === 0) return 0;
+ return LUNAR_INFO[year - MIN_YEAR] & 0x10000 ? 30 : 29;
+}
+
+function monthDays(year: number, month: number): number {
+ return LUNAR_INFO[year - MIN_YEAR] & (0x10000 >> month) ? 30 : 29;
+}
+
+function solarToLunar(year: number, month: number, day: number): LunarDate {
+ // 基准:1900-01-31 为农历 1900 年正月初一。
+ let offset = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(1900, 0, 31)) / 86400000);
+
+ let lunarYear = MIN_YEAR;
+ let yearDays = 0;
+ for (; lunarYear <= MAX_YEAR; lunarYear += 1) {
+ yearDays = lunarYearDays(lunarYear);
+ if (offset < yearDays) break;
+ offset -= yearDays;
+ }
+
+ const leap = leapMonth(lunarYear);
+ let isLeap = false;
+ let lunarMonth = 1;
+ let daysInMonth = 0;
+ for (; lunarMonth < 13; lunarMonth += 1) {
+ // 闰月作为额外一轮插入在第 leap 个月之后(此时 lunarMonth 临时回退到 leap)。
+ if (leap > 0 && lunarMonth === leap + 1 && !isLeap) {
+ lunarMonth -= 1;
+ isLeap = true;
+ daysInMonth = leapDays(lunarYear);
+ } else {
+ daysInMonth = monthDays(lunarYear, lunarMonth);
+ }
+ // 离开闰月时复位标记,必须在 break 之前,保证命中当天的 isLeap 正确。
+ if (isLeap && lunarMonth === leap + 1) {
+ isLeap = false;
+ }
+ if (offset < daysInMonth) break;
+ offset -= daysInMonth;
+ }
+
+ return {year: lunarYear, month: lunarMonth, day: offset + 1, isLeap};
+}
+
+function formatLunarMonthDay(lunar: LunarDate): string {
+ const prefix = lunar.isLeap ? "闰" : "";
+ return `${prefix}${LUNAR_MONTH_NAMES[lunar.month - 1]}月${lunarDayName(lunar.day)}`;
+}
+
+function lunarDayName(day: number): string {
+ if (day <= 10) return day === 10 ? "初十" : `初${LUNAR_DAY_DIGITS[day]}`;
+ if (day < 20) return `十${LUNAR_DAY_DIGITS[day - 10]}`;
+ if (day === 20) return "二十";
+ if (day < 30) return `廿${LUNAR_DAY_DIGITS[day - 20]}`;
+ return "三十";
+}
diff --git a/remote-render/src/renderer/services/view-model.ts b/remote-render/src/renderer/services/view-model.ts
index 55e1e89..0175ec9 100644
--- a/remote-render/src/renderer/services/view-model.ts
+++ b/remote-render/src/renderer/services/view-model.ts
@@ -1,6 +1,9 @@
import {
FONT_LABELS,
SETTINGS_ITEMS,
+ THEME_LABELS,
+ nextFontKey,
+ nextThemeKey,
type DeviceUiState,
} from "../../ui-state.js";
import type {
@@ -11,9 +14,10 @@ import type {
SettingsRowViewModel,
} from "../models/view-model.js";
import {buildClockFlipGlyphs} from "./clock-flip.js";
-import {nextFontLabel} from "./font-registry.js";
+import {resolveClockTheme} from "./clock-theme.js";
import {buildHomeAmbientGameViewModel} from "./home-ambient-game.js";
import {buildHomeCopy} from "./home-copy.js";
+import {buildWeatherView, getWeatherSnapshot} from "./weather.js";
export interface BuildDeviceViewModelInput {
currentTime: Date;
@@ -37,12 +41,27 @@ export function buildDeviceViewModel(input: BuildDeviceViewModelInput): DeviceVi
if (input.state.page === "detail") {
return buildDetailViewModel(input, fontKey);
}
+ const theme = resolveClockTheme(resolveThemeKeyForView(input.state));
+ if (input.state.page === "game") {
+ return {
+ page: "game",
+ fontKey,
+ theme,
+ timeText: buildHomeCopy(input.currentTime).timeText,
+ game: input.homeGame ?? buildHomeAmbientGameViewModel({kind: "snake"}),
+ };
+ }
return {
page: "home",
fontKey,
copy: buildHomeCopy(input.currentTime),
- clockGlyphs: buildClockFlipGlyphs(input.currentTime, {progress: input.clockFlipProgress}),
- game: input.homeGame ?? buildHomeAmbientGameViewModel({kind: "snake"}),
+ clockGlyphs: buildClockFlipGlyphs(input.currentTime, {
+ progress: input.clockFlipProgress,
+ timeColor: theme.time,
+ secondsColor: theme.seconds,
+ }),
+ theme,
+ weather: buildWeatherView(getWeatherSnapshot()) ?? undefined,
};
}
@@ -53,6 +72,13 @@ export function resolveFontKeyForView(state: DeviceUiState): string {
return state.fontKey;
}
+export function resolveThemeKeyForView(state: DeviceUiState): string {
+ if (state.page === "detail" && SETTINGS_ITEMS[state.detailIndex % SETTINGS_ITEMS.length] === "Theme") {
+ return state.pendingThemeKey;
+ }
+ return state.themeKey;
+}
+
function buildSettingsRows(state: DeviceUiState): SettingsRowViewModel[] {
return SETTINGS_ITEMS.map((item, index) => ({
key: item,
@@ -83,7 +109,9 @@ function buildDetailViewModel(input: BuildDeviceViewModelInput, fontKey: string)
title: "Brightness",
subtitle: "short apply",
valueLabel: `${value}%`,
- appliedLabel: input.state.brightness === input.state.pendingBrightness ? "applied" : `saved ${input.state.brightness}%`,
+ // short_press / long_press 都会让 brightness 与 pendingBrightness 同步,
+ // 且亮度详情页期间不接受状态同步覆盖,因此此处恒为 applied。
+ appliedLabel: "applied",
fillWidth: Math.round(170 * (value / 100)),
pulse: isAnimating ? pulse(input.progress) : 0,
};
@@ -129,12 +157,24 @@ function buildRowsDetail(item: string, input: BuildDeviceViewModelInput): {title
subtitle: "short apply",
rows: toRows([
["Current", FONT_LABELS[input.state.fontKey] ?? "Font"],
- ["Next", FONT_LABELS[nextFontLabel(input.state.fontKey)] ?? "Font"],
+ ["Next", FONT_LABELS[nextFontKey(input.state.fontKey)] ?? "Font"],
["Engine", "React"],
["Layout", "Yoga"],
]),
};
}
+ if (item === "Theme") {
+ return {
+ title: "Theme",
+ subtitle: "short apply",
+ rows: toRows([
+ ["Current", THEME_LABELS[input.state.themeKey] ?? "Theme"],
+ ["Next", THEME_LABELS[nextThemeKey(input.state.themeKey)] ?? "Theme"],
+ ["Scope", "clock palette"],
+ ["Apply", "long press"],
+ ]),
+ };
+ }
return {
title: item,
subtitle: "Setting detail",
diff --git a/remote-render/src/renderer/services/weather.test.ts b/remote-render/src/renderer/services/weather.test.ts
new file mode 100644
index 0000000..78452c3
--- /dev/null
+++ b/remote-render/src/renderer/services/weather.test.ts
@@ -0,0 +1,94 @@
+import {afterEach, describe, expect, test} from "vitest";
+
+import {
+ buildWeatherView,
+ getWeatherSnapshot,
+ parseOpenMeteo,
+ parseOpenMeteoDays,
+ refreshWeather,
+ setWeatherSnapshotForTest,
+ tempColor,
+ wmoLabel,
+} from "./weather.js";
+
+const SAMPLE = {
+ hourly: {
+ time: ["2026-06-29T06:00", "2026-06-29T07:00", "2026-06-29T08:00"],
+ temperature_2m: [24.4, 25.7, 30.2],
+ weather_code: [3, 51, 95],
+ precipitation_probability: [10, 40, 88],
+ },
+ daily: {
+ time: ["2026-06-29", "2026-06-30", "2026-07-01"],
+ weather_code: [3, 61, 95],
+ temperature_2m_max: [31.2, 29.6, 26.1],
+ temperature_2m_min: [25.4, 25.1, 24.0],
+ precipitation_probability_max: [20, 100, 80],
+ },
+};
+
+afterEach(() => setWeatherSnapshotForTest(null));
+
+describe("weather service", () => {
+ test("parses Open-Meteo hourly payload", () => {
+ const hours = parseOpenMeteo(SAMPLE);
+ expect(hours).toHaveLength(3);
+ expect(hours[0]).toEqual({time: "2026-06-29T06:00", temp: 24, code: 3, precip: 10});
+ expect(hours[2]).toEqual({time: "2026-06-29T08:00", temp: 30, code: 95, precip: 88});
+ });
+
+ test("returns empty for malformed payloads", () => {
+ expect(parseOpenMeteo(null)).toEqual([]);
+ expect(parseOpenMeteo({})).toEqual([]);
+ expect(parseOpenMeteo({hourly: {time: 5}})).toEqual([]);
+ });
+
+ test("maps WMO codes to Chinese labels", () => {
+ expect(wmoLabel(0)).toBe("晴");
+ expect(wmoLabel(3)).toBe("阴");
+ expect(wmoLabel(65)).toBe("大雨");
+ expect(wmoLabel(95)).toBe("雷阵雨");
+ });
+
+ test("parses Open-Meteo daily payload into labelled days", () => {
+ const days = parseOpenMeteoDays(SAMPLE);
+ expect(days).toHaveLength(3);
+ expect(days[1]).toEqual({date: "2026-06-30", code: 61, tempMax: 30, tempMin: 25, precip: 100});
+ expect(parseOpenMeteoDays({})).toEqual([]);
+ });
+
+ test("colours temperatures from cool to warm", () => {
+ expect(tempColor(2)).not.toBe(tempColor(30)); // 冷暖不同色
+ expect(typeof tempColor(26)).toBe("string");
+ });
+
+ test("builds a view with current condition, 12h extremes, and a 3-day outlook", () => {
+ const view = buildWeatherView({fetchedAtMs: 0, hours: parseOpenMeteo(SAMPLE), days: parseOpenMeteoDays(SAMPLE)});
+ expect(view).not.toBeNull();
+ expect(view!.location).toBe("萧山");
+ expect(view!.current).toMatchObject({temp: 24, label: "阴", icon: "overcast"});
+ expect(view!.maxPrecip).toBe(88);
+ expect(view!.tempLow).toBe(24);
+ expect(view!.tempHigh).toBe(30);
+ expect(view!.hours[2]).toMatchObject({hourLabel: "08", temp: 30, precip: 88, label: "雷阵雨", icon: "thunder"});
+ expect(view!.days).toHaveLength(3);
+ expect(view!.days[1]).toMatchObject({label: "明天", icon: "rain", tempMax: 30, tempMin: 25, precip: 100});
+ });
+
+ test("buildWeatherView returns null without data", () => {
+ expect(buildWeatherView(null)).toBeNull();
+ expect(buildWeatherView({fetchedAtMs: 0, hours: [], days: []})).toBeNull();
+ });
+
+ test("refreshWeather caches a successful fetch and keeps the cache on failure", async () => {
+ const ok: typeof fetch = (async () => ({ok: true, json: async () => SAMPLE})) as unknown as typeof fetch;
+ await refreshWeather({fetchImpl: ok as never, nowMs: 1000});
+ expect(getWeatherSnapshot()?.hours).toHaveLength(3);
+
+ const fail: typeof fetch = (async () => {
+ throw new Error("network down");
+ }) as unknown as typeof fetch;
+ await refreshWeather({fetchImpl: fail as never, nowMs: 2000});
+ expect(getWeatherSnapshot()?.hours).toHaveLength(3); // 失败时保留上次缓存
+ });
+});
diff --git a/remote-render/src/renderer/services/weather.ts b/remote-render/src/renderer/services/weather.ts
new file mode 100644
index 0000000..e70d18d
--- /dev/null
+++ b/remote-render/src/renderer/services/weather.ts
@@ -0,0 +1,258 @@
+// 可选天气:服务端定时拉取浙江杭州萧山区未来 12 小时预报并缓存。
+// 数据源 Open-Meteo(免费、无需 API key、含中国)。失败保持静默并沿用上次缓存,
+// 渲染层在没有数据时不显示天气,不影响时钟主流程。
+
+// 杭州市萧山区大致坐标
+const XIAOSHAN_LATITUDE = 30.18;
+const XIAOSHAN_LONGITUDE = 120.27;
+export const WEATHER_LOCATION_LABEL = "萧山";
+
+const FORECAST_HOURS = 12;
+const DEFAULT_REFRESH_MS = 30 * 60 * 1000; // 30 分钟
+const FETCH_TIMEOUT_MS = 10 * 1000;
+
+export interface WeatherHour {
+ time: string; // ISO,本地时区
+ temp: number; // 摄氏度
+ code: number; // WMO weather code
+ precip: number; // 降水概率 %
+}
+
+export interface WeatherDay {
+ date: string; // "2026-06-30"
+ code: number;
+ tempMax: number;
+ tempMin: number;
+ precip: number; // 当日最大降水概率 %
+}
+
+export interface WeatherSnapshot {
+ fetchedAtMs: number;
+ hours: WeatherHour[];
+ days: WeatherDay[];
+}
+
+export type WeatherIconKind = "sun" | "cloud" | "overcast" | "fog" | "rain" | "snow" | "thunder";
+
+export interface WeatherHourView {
+ hourLabel: string; // "09"
+ temp: number;
+ precip: number;
+ label: string;
+ icon: WeatherIconKind;
+}
+
+export interface WeatherDayView {
+ label: string; // "明天" / "后天"
+ icon: WeatherIconKind;
+ tempMax: number;
+ tempMin: number;
+ precip: number;
+}
+
+export interface WeatherView {
+ location: string;
+ current: {temp: number; label: string; code: number; icon: WeatherIconKind};
+ maxPrecip: number;
+ tempLow: number;
+ tempHigh: number;
+ hours: WeatherHourView[];
+ days: WeatherDayView[]; // [今天, 明天, 后天]
+}
+
+const FORECAST_DAYS = 3;
+const OPEN_METEO_URL =
+ `https://api.open-meteo.com/v1/forecast?latitude=${XIAOSHAN_LATITUDE}&longitude=${XIAOSHAN_LONGITUDE}` +
+ `&hourly=temperature_2m,weather_code,precipitation_probability&forecast_hours=${FORECAST_HOURS}` +
+ `&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&forecast_days=${FORECAST_DAYS}` +
+ `&timezone=Asia%2FShanghai`;
+
+let snapshot: WeatherSnapshot | null = null;
+let pollTimer: ReturnType | null = null;
+
+export function getWeatherSnapshot(): WeatherSnapshot | null {
+ return snapshot;
+}
+
+// 仅供测试使用:直接注入快照。
+export function setWeatherSnapshotForTest(value: WeatherSnapshot | null): void {
+ snapshot = value;
+}
+
+type FetchLike = (url: string, init?: {signal?: AbortSignal}) => Promise<{ok: boolean; json: () => Promise}>;
+
+export interface RefreshWeatherOptions {
+ fetchImpl?: FetchLike;
+ nowMs?: number;
+}
+
+export async function refreshWeather(options: RefreshWeatherOptions = {}): Promise {
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike | undefined);
+ if (!fetchImpl) {
+ return snapshot;
+ }
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
+ const timer = controller ? setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) : undefined;
+ try {
+ const response = await fetchImpl(OPEN_METEO_URL, controller ? {signal: controller.signal} : undefined);
+ if (!response.ok) {
+ return snapshot;
+ }
+ const payload = await response.json();
+ const hours = parseOpenMeteo(payload);
+ if (hours.length > 0) {
+ snapshot = {fetchedAtMs: options.nowMs ?? Date.now(), hours, days: parseOpenMeteoDays(payload)};
+ }
+ return snapshot;
+ } catch (error) {
+ // 静默失败:保留上次缓存,不打断时钟渲染。
+ console.warn("[Weather] refresh failed:", error instanceof Error ? error.message : error);
+ return snapshot;
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
+
+export function startWeatherPolling(options: RefreshWeatherOptions & {intervalMs?: number} = {}): () => void {
+ void refreshWeather(options);
+ const intervalMs = options.intervalMs ?? DEFAULT_REFRESH_MS;
+ pollTimer = setInterval(() => void refreshWeather(options), intervalMs);
+ if (typeof pollTimer === "object" && pollTimer && "unref" in pollTimer) {
+ (pollTimer as {unref?: () => void}).unref?.();
+ }
+ return stopWeatherPolling;
+}
+
+export function stopWeatherPolling(): void {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+}
+
+export function parseOpenMeteo(payload: unknown): WeatherHour[] {
+ const hourly = (payload as {hourly?: Record} | null)?.hourly;
+ if (!hourly) return [];
+ const time = hourly.time as string[] | undefined;
+ const temperature = hourly.temperature_2m as number[] | undefined;
+ const code = hourly.weather_code as number[] | undefined;
+ const precip = hourly.precipitation_probability as number[] | undefined;
+ if (!Array.isArray(time) || !Array.isArray(temperature)) return [];
+ const count = Math.min(time.length, temperature.length, FORECAST_HOURS);
+ const hours: WeatherHour[] = [];
+ for (let index = 0; index < count; index += 1) {
+ hours.push({
+ time: time[index],
+ temp: Math.round(temperature[index]),
+ code: Math.round(code?.[index] ?? 0),
+ precip: Math.round(precip?.[index] ?? 0),
+ });
+ }
+ return hours;
+}
+
+export function parseOpenMeteoDays(payload: unknown): WeatherDay[] {
+ const daily = (payload as {daily?: Record} | null)?.daily;
+ if (!daily) return [];
+ const time = daily.time as string[] | undefined;
+ const code = daily.weather_code as number[] | undefined;
+ const tempMax = daily.temperature_2m_max as number[] | undefined;
+ const tempMin = daily.temperature_2m_min as number[] | undefined;
+ const precip = daily.precipitation_probability_max as number[] | undefined;
+ if (!Array.isArray(time) || !Array.isArray(tempMax) || !Array.isArray(tempMin)) return [];
+ const count = Math.min(time.length, tempMax.length, tempMin.length, FORECAST_DAYS);
+ const days: WeatherDay[] = [];
+ for (let index = 0; index < count; index += 1) {
+ days.push({
+ date: time[index],
+ code: Math.round(code?.[index] ?? 0),
+ tempMax: Math.round(tempMax[index]),
+ tempMin: Math.round(tempMin[index]),
+ precip: Math.round(precip?.[index] ?? 0),
+ });
+ }
+ return days;
+}
+
+const DAY_LABELS = ["今天", "明天", "后天"];
+
+export function buildWeatherView(input: WeatherSnapshot | null): WeatherView | null {
+ if (!input || input.hours.length === 0) return null;
+ const hours = input.hours;
+ const temps = hours.map((hour) => hour.temp);
+ const current = hours[0];
+ return {
+ location: WEATHER_LOCATION_LABEL,
+ current: {temp: current.temp, label: wmoLabel(current.code), code: current.code, icon: weatherIconKind(current.code)},
+ maxPrecip: Math.max(...hours.map((hour) => hour.precip)),
+ tempLow: Math.min(...temps),
+ tempHigh: Math.max(...temps),
+ hours: hours.map((hour) => ({
+ hourLabel: hourLabel(hour.time),
+ temp: hour.temp,
+ precip: hour.precip,
+ label: wmoLabel(hour.code),
+ icon: weatherIconKind(hour.code),
+ })),
+ days: (input.days ?? []).map((day, index) => ({
+ label: DAY_LABELS[index] ?? day.date.slice(5),
+ icon: weatherIconKind(day.code),
+ tempMax: day.tempMax,
+ tempMin: day.tempMin,
+ precip: day.precip,
+ })),
+ };
+}
+
+// 温度 -> 颜色(冷蓝→暖红),让温度数字带上直观的色彩。
+export function tempColor(temp: number): string {
+ if (temp <= 0) return "#7cc4ff";
+ if (temp <= 8) return "#69d6e0";
+ if (temp <= 15) return "#7fe0a6";
+ if (temp <= 21) return "#ffd95a";
+ if (temp <= 27) return "#ffae4d";
+ if (temp <= 32) return "#ff8a52";
+ return "#ff6a5a";
+}
+
+// WMO weather code -> 图标类别
+export function weatherIconKind(code: number): WeatherIconKind {
+ if (code === 0 || code === 1) return "sun";
+ if (code === 2) return "cloud";
+ if (code === 3) return "overcast";
+ if (code === 45 || code === 48) return "fog";
+ if (code >= 71 && code <= 77) return "snow";
+ if (code === 85 || code === 86) return "snow";
+ if (code >= 95) return "thunder";
+ if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) return "rain";
+ return "cloud";
+}
+
+function hourLabel(iso: string): string {
+ const match = /T(\d{2}):/.exec(iso);
+ return match ? match[1] : "";
+}
+
+// WMO weather code -> 简短中文描述
+export function wmoLabel(code: number): string {
+ if (code === 0) return "晴";
+ if (code === 1) return "少云";
+ if (code === 2) return "多云";
+ if (code === 3) return "阴";
+ if (code === 45 || code === 48) return "雾";
+ if (code >= 51 && code <= 55) return "毛毛雨";
+ if (code === 56 || code === 57) return "冻雨";
+ if (code === 61) return "小雨";
+ if (code === 63) return "中雨";
+ if (code === 65) return "大雨";
+ if (code === 66 || code === 67) return "冻雨";
+ if (code === 71) return "小雪";
+ if (code === 73) return "中雪";
+ if (code === 75) return "大雪";
+ if (code === 77) return "米雪";
+ if (code >= 80 && code <= 82) return "阵雨";
+ if (code === 85 || code === 86) return "阵雪";
+ if (code === 95) return "雷阵雨";
+ if (code === 96 || code === 99) return "雷暴";
+ return "—";
+}
diff --git a/remote-render/src/renderer/types.ts b/remote-render/src/renderer/types.ts
index ffb8d4b..eb73101 100644
--- a/remote-render/src/renderer/types.ts
+++ b/remote-render/src/renderer/types.ts
@@ -16,10 +16,13 @@ export interface RenderedFrame {
export interface HomeCopy {
dateText: string;
weekdayText: string;
+ weekdayShort: string;
timeText: string;
secondsText: string;
greeting: string;
subtitle: string;
+ // 农历日期 + 节日/节气,例如 "五月十五" 或 "八月十五 · 中秋节"
+ lunarText: string;
}
export interface Style {
diff --git a/remote-render/src/renderer/view.tsx b/remote-render/src/renderer/view.tsx
index 5496f74..8269af4 100644
--- a/remote-render/src/renderer/view.tsx
+++ b/remote-render/src/renderer/view.tsx
@@ -2,6 +2,7 @@ import type {DeviceUiState} from "../ui-state.js";
import type {HomeAmbientGameViewModel} from "./models/view-model.js";
import {useDeviceViewModel} from "./hooks/useDeviceViewModel.js";
import {DetailPage} from "./pages/detail.js";
+import {GameShowPage} from "./pages/game-show.js";
import {HomePage} from "./pages/home.js";
import {SettingsPage} from "./pages/settings.js";
@@ -23,5 +24,6 @@ export function DeviceView({
const model = useDeviceViewModel({currentTime, deviceId, state, progress, clockFlipProgress, homeGame});
if (model.page === "settings") return ;
if (model.page === "detail") return ;
+ if (model.page === "game") return ;
return ;
}
diff --git a/remote-render/src/renderer/widgets/auto-rain.tsx b/remote-render/src/renderer/widgets/auto-rain.tsx
new file mode 100644
index 0000000..d1f5cb9
--- /dev/null
+++ b/remote-render/src/renderer/widgets/auto-rain.tsx
@@ -0,0 +1,30 @@
+import {Box} from "../components/primitives.js";
+import type {AutoRainViewModel, RainCellViewModel} from "../models/view-model.js";
+
+export function AutoRain({model}: {model: AutoRainViewModel}) {
+ const width = model.columns * model.cellSize;
+ const height = model.rows * model.cellSize;
+ return (
+
+ {model.cells.map((cell) => (
+
+ ))}
+
+ );
+}
+
+function RainCell({cell, cellSize}: {cell: RainCellViewModel; cellSize: number}) {
+ return (
+ = 1 ? "#d6ffe6" : "#39e08a",
+ opacity: cell.level,
+ }}
+ />
+ );
+}
diff --git a/remote-render/src/renderer/widgets/home-ambient-game.tsx b/remote-render/src/renderer/widgets/home-ambient-game.tsx
deleted file mode 100644
index 8400bc0..0000000
--- a/remote-render/src/renderer/widgets/home-ambient-game.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import {Box} from "../components/primitives.js";
-import type {HomeAmbientGameViewModel} from "../models/view-model.js";
-import {AntColony} from "./ant-colony.js";
-import {AutoBreakout} from "./auto-breakout.js";
-import {AutoPacman} from "./auto-pacman.js";
-import {AutoSnake} from "./auto-snake.js";
-import {ConwayLife} from "./conway-life.js";
-
-export function HomeAmbientGame({model}: {model: HomeAmbientGameViewModel}) {
- return (
-
- {model.kind === "snake" && }
- {model.kind === "life" && }
- {model.kind === "breakout" && }
- {model.kind === "ants" && }
- {model.kind === "pacman" && }
-
- );
-}
diff --git a/remote-render/src/renderer/widgets/weather-icon.tsx b/remote-render/src/renderer/widgets/weather-icon.tsx
new file mode 100644
index 0000000..602b060
--- /dev/null
+++ b/remote-render/src/renderer/widgets/weather-icon.tsx
@@ -0,0 +1,47 @@
+import {Box} from "../components/primitives.js";
+import type {WeatherIconKind} from "../services/weather.js";
+
+// 用基本图元拼出的小天气图标(约 20x20,绝对定位在屏幕坐标 x,y)。
+const CLOUD = "#cdd5dd";
+const CLOUD_DARK = "#9aa6b0";
+const SUN = "#ffce54";
+const RAINDROP = "#5ac8fa";
+const BOLT = "#ffd24d";
+const SNOW = "#eaf1f6";
+
+export function WeatherIcon({kind, x, y}: {kind: WeatherIconKind; x: number; y: number}) {
+ if (kind === "sun") {
+ return ;
+ }
+ if (kind === "fog") {
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+ const color = kind === "overcast" ? CLOUD_DARK : CLOUD;
+ return (
+ <>
+
+
+
+ {kind === "rain" ? (
+ <>
+
+
+
+ >
+ ) : null}
+ {kind === "thunder" ? : null}
+ {kind === "snow" ? (
+ <>
+
+
+ >
+ ) : null}
+ >
+ );
+}
diff --git a/remote-render/src/server.test.ts b/remote-render/src/server.test.ts
index ff38aef..c486aee 100644
--- a/remote-render/src/server.test.ts
+++ b/remote-render/src/server.test.ts
@@ -47,4 +47,77 @@ describe("Node HTTP API", () => {
expect(invalid.status).toBe(422);
});
+
+ test("accepts a valid input event with 202", async () => {
+ const response = await fetch(`${baseUrl}/api/v1/devices/desk-input/input`, {
+ method: "POST",
+ headers: {"content-type": "application/json"},
+ body: JSON.stringify({seq: 1, event: "short_press", uptime_ms: 1000}),
+ });
+
+ expect(response.status).toBe(202);
+ });
+
+ test("returns 204 for commands when none queued and the queued command after a brightness change", async () => {
+ const none = await fetch(`${baseUrl}/api/v1/devices/desk-cmd/commands?after=0`);
+ expect(none.status).toBe(204);
+
+ const post = (seq: number, event: string, uptimeMs: number) =>
+ fetch(`${baseUrl}/api/v1/devices/desk-cmd/input`, {
+ method: "POST",
+ headers: {"content-type": "application/json"},
+ body: JSON.stringify({seq, event, uptime_ms: uptimeMs}),
+ });
+ await post(1, "long_press", 100); // home -> settings (Brightness selected)
+ await post(2, "long_press", 200); // settings -> brightness detail
+ await post(3, "short_press", 300); // adjust brightness -> queues set_brightness
+
+ const queued = await fetch(`${baseUrl}/api/v1/devices/desk-cmd/commands?after=0`);
+ expect(queued.status).toBe(200);
+ await expect(queued.json()).resolves.toMatchObject({type: "set_brightness"});
+ });
+
+ test("returns 404 for unknown routes", async () => {
+ const response = await fetch(`${baseUrl}/api/v1/unknown`);
+ expect(response.status).toBe(404);
+ });
+
+ test("returns 422 for malformed JSON instead of 500", async () => {
+ const response = await fetch(`${baseUrl}/api/v1/devices/desk-bad-json/input`, {
+ method: "POST",
+ headers: {"content-type": "application/json"},
+ body: "{bad json",
+ });
+
+ expect(response.status).toBe(422);
+ });
+
+ test("returns 422 for non-object JSON bodies instead of 500", async () => {
+ const nullBody = await fetch(`${baseUrl}/api/v1/devices/desk-null/input`, {
+ method: "POST",
+ headers: {"content-type": "application/json"},
+ body: "null",
+ });
+ expect(nullBody.status).toBe(422);
+
+ const nullStatus = await fetch(`${baseUrl}/api/v1/devices/desk-null/status`, {
+ method: "POST",
+ headers: {"content-type": "application/json"},
+ body: "null",
+ });
+ expect(nullStatus.status).toBe(422);
+ });
+
+ test("rejects an oversized request body with 413 and stays responsive", async () => {
+ const oversized = await fetch(`${baseUrl}/api/v1/devices/desk-big/input`, {
+ method: "POST",
+ headers: {"content-type": "application/json"},
+ body: `{"seq":1,"event":"short_press","uptime_ms":1,"pad":"${"x".repeat(64 * 1024)}"}`,
+ });
+ expect(oversized.status).toBe(413);
+
+ await expect(fetch(`${baseUrl}/api/v1/health`).then((response) => response.json())).resolves.toEqual({
+ status: "ok",
+ });
+ });
});
diff --git a/remote-render/src/server.ts b/remote-render/src/server.ts
index de32b41..c9fe301 100644
--- a/remote-render/src/server.ts
+++ b/remote-render/src/server.ts
@@ -9,9 +9,27 @@ export interface RemoteRenderServer {
address(): ReturnType;
}
+// 用于把客户端请求错误(坏 JSON、超大请求体)映射成正确的 4xx 状态码,
+// 而不是让它们冒泡成 500 internal server error 并污染日志。
+class HttpError extends Error {
+ constructor(
+ public status: number,
+ public detail: string,
+ ) {
+ super(detail);
+ }
+}
+
+// 请求体上限。合法的 input/status 负载只有几个小整数,16KB 足够宽松。
+const MAX_REQUEST_BODY_BYTES = 16 * 1024;
+
export function createRemoteRenderServer(registry = new DeviceRegistry()): RemoteRenderServer {
const server = http.createServer((request, response) => {
handleRequest(registry, request, response).catch((error: unknown) => {
+ if (error instanceof HttpError) {
+ sendJson(response, error.status, {detail: error.detail});
+ return;
+ }
console.error(error);
sendJson(response, 500, {detail: "internal server error"});
});
@@ -132,11 +150,28 @@ function parseBoundedInt(value: string | null, min: number, max: number, fallbac
async function readJson(request: IncomingMessage): Promise> {
const chunks: Buffer[] = [];
+ let total = 0;
for await (const chunk of request) {
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ total += buffer.length;
+ if (total > MAX_REQUEST_BODY_BYTES) {
+ throw new HttpError(413, "payload too large");
+ }
+ chunks.push(buffer);
}
if (chunks.length === 0) return {};
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ } catch {
+ throw new HttpError(422, "invalid JSON");
+ }
+ // 只接受 JSON 对象;null / 数字 / 数组等非对象负载统一当成空对象,
+ // 让后续字段校验返回 422,而不是在属性访问时抛 TypeError -> 500。
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ return {};
+ }
+ return parsed as Record;
}
function isInt(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): value is number {
diff --git a/remote-render/src/state.test.ts b/remote-render/src/state.test.ts
index 88f8fb3..ead93c2 100644
--- a/remote-render/src/state.test.ts
+++ b/remote-render/src/state.test.ts
@@ -2,7 +2,6 @@ import {describe, expect, test} from "vitest";
import {applyFrameToRgba, decodeFrame} from "./tools/frame-preview.js";
import {DeviceRegistry} from "./state.js";
-import {HOME_GAME_REGION} from "./renderer/index.js";
describe("device registry", () => {
test("returns latest frame then no content for same frame id", async () => {
@@ -16,25 +15,21 @@ describe("device registry", () => {
expect(second).toBeNull();
});
- test("short press on home switches the ambient game and sends the full game region", async () => {
+ test("short press on home enters the game show with a full frame", async () => {
const registry = new DeviceRegistry();
const first = await registry.getFrame("desk-02", 0, 0);
const frameId = first!.readUInt32LE(8);
- const initialGame = registry.devices.get("desk-02")!.homeGame!.kind;
+ expect(registry.devices.get("desk-02")!.homeGame).toBeNull(); // 安静首页无游戏
expect(registry.recordInput("desk-02", 1, "short_press", 1000)).toBe(true);
- const switched = await registry.getFrame("desk-02", frameId, 1);
- const decoded = decodeFrame(switched!);
+ const shown = await registry.getFrame("desk-02", frameId, 1);
+ const decoded = decodeFrame(shown!);
- expect(registry.devices.get("desk-02")!.homeGame!.kind).not.toBe(initialGame);
- expect(decoded.fullFrame).toBe(false);
- expect(decoded.rects).toEqual(
- expect.arrayContaining([
- expect.objectContaining({x: HOME_GAME_REGION[0], y: HOME_GAME_REGION[1], width: HOME_GAME_REGION[2] - HOME_GAME_REGION[0], height: HOME_GAME_REGION[3] - HOME_GAME_REGION[1]}),
- ]),
- );
+ expect(registry.devices.get("desk-02")!.ui.page).toBe("game");
+ expect(registry.devices.get("desk-02")!.homeGame).not.toBeNull();
+ expect(decoded.fullFrame).toBe(true);
});
test("double press on home forces a full refresh frame", async () => {
@@ -109,77 +104,54 @@ describe("device registry", () => {
expect(Buffer.compare(rgba, fullSnapshot)).toBe(0);
});
- test("emits slower home game frames after clock cleanup", async () => {
+ test("game show advances the game animation on each tick", async () => {
let now = 0;
- const baseTime = new Date("2026-05-01T12:34:56.000+08:00").getTime();
const registry = new DeviceRegistry({
monotonic: () => now,
- now: () => new Date(baseTime + now * 1000),
- frameIntervalSeconds: 1,
- animationFrameIntervalSeconds: 0.05,
- clockFlipAnimationSeconds: 0.3,
homeGameFrameIntervalSeconds: 1,
+ gameShowDwellSeconds: 100,
});
- const deviceId = "desk-snake-live";
+ const deviceId = "desk-show";
const first = await registry.getFrame(deviceId, 0, 0);
- const firstFrameId = first!.readUInt32LE(8);
+ let have = first!.readUInt32LE(8);
- now = 0.5;
- await expect(registry.getFrame(deviceId, firstFrameId, 0)).resolves.toBeNull();
+ expect(registry.recordInput(deviceId, 1, "short_press", 100)).toBe(true);
+ const enter = await registry.getFrame(deviceId, have, 0);
+ have = enter!.readUInt32LE(8);
+ expect(registry.devices.get(deviceId)!.ui.page).toBe("game");
now = 1;
- const gameFrame = await registry.getFrame(deviceId, firstFrameId, 0);
- const gameDecoded = decodeFrame(gameFrame!);
- let have = gameFrame!.readUInt32LE(8);
-
- expect(gameDecoded.fullFrame).toBe(false);
- expect(gameDecoded.rects.length).toBeGreaterThan(0);
- expect(gameDecoded.rects.some((rect) => rect.y >= 136 && rect.y < 226)).toBe(true);
-
- now = 1.31;
- const cleanupFrame = await registry.getFrame(deviceId, have, 0);
- have = cleanupFrame!.readUInt32LE(8);
+ const tick = await registry.getFrame(deviceId, have, 0);
+ const decoded = decodeFrame(tick!);
- now = 1.5;
- await expect(registry.getFrame(deviceId, have, 0)).resolves.toBeNull();
+ expect(decoded.fullFrame).toBe(false);
+ expect(decoded.rects.length).toBeGreaterThan(0);
+ expect(decoded.rects.some((rect) => rect.y >= 64)).toBe(true); // 游戏区
});
- test("sends the full game region when the home game times out and switches", async () => {
+ test("game show auto-advances by dwell and returns to the calm home after the last game", async () => {
let now = 0;
- const baseTime = new Date("2026-05-01T12:09:59.000+08:00").getTime();
const registry = new DeviceRegistry({
monotonic: () => now,
- now: () => new Date(baseTime + now * 1000),
- frameIntervalSeconds: 1,
- animationFrameIntervalSeconds: 0.05,
- clockFlipAnimationSeconds: 0.3,
+ gameShowDwellSeconds: 5,
homeGameFrameIntervalSeconds: 1,
});
- const deviceId = "desk-game-switch-clear";
+ const deviceId = "desk-carousel";
- const first = await registry.getFrame(deviceId, 0, 0);
- let rgba = applyFrameToRgba(Buffer.alloc(0), 240, decodeFrame(first!));
- let have = first!.readUInt32LE(8);
- const initialGame = registry.devices.get(deviceId)!.homeGame!.kind;
-
- now = 1200;
- const switched = await registry.getFrame(deviceId, have, 0);
- const decoded = decodeFrame(switched!);
- rgba = applyFrameToRgba(rgba, 240, decoded);
- have = switched!.readUInt32LE(8);
- const fullSnapshot = applyFrameToRgba(Buffer.alloc(0), 240, decodeFrame(registry.devices.get(deviceId)!.fullFrame));
- const [left, top, right, bottom] = HOME_GAME_REGION;
-
- expect(registry.devices.get(deviceId)!.homeGame!.kind).not.toBe(initialGame);
- expect(decoded.fullFrame).toBe(false);
- expect(decoded.rects).toEqual(
- expect.arrayContaining([
- expect.objectContaining({x: left, y: top, width: right - left, height: bottom - top}),
- ]),
- );
- expect(Buffer.compare(rgba, fullSnapshot)).toBe(0);
- expect(registry.devices.get(deviceId)!.latestBaseFrameId).toBe(have - 1);
+ await registry.getFrame(deviceId, 0, 0);
+ expect(registry.recordInput(deviceId, 1, "short_press", 100)).toBe(true);
+ expect(registry.devices.get(deviceId)!.ui.page).toBe("game");
+ expect(registry.devices.get(deviceId)!.ui.gameIndex).toBe(0);
+
+ // 每过一个停留时长自动切下一个;6 个游戏播完后回到安静首页。
+ for (let step = 1; step <= 6; step += 1) {
+ now = step * 5;
+ await registry.getFrame(deviceId, registry.devices.get(deviceId)!.frameId, 0);
+ }
+
+ expect(registry.devices.get(deviceId)!.ui.page).toBe("home");
+ expect(registry.devices.get(deviceId)!.homeGame).toBeNull();
});
test("emits a full final frame when a navigation animation expires between polls", async () => {
@@ -247,4 +219,27 @@ describe("device registry", () => {
uptimeMs: 4321,
});
});
+
+ test("evicts idle devices past the TTL while keeping active ones", async () => {
+ let now = 0;
+ const registry = new DeviceRegistry({
+ monotonic: () => now,
+ deviceIdleTtlSeconds: 100,
+ evictionSweepIntervalSeconds: 10,
+ });
+
+ await registry.getFrame("idle-1", 0, 0);
+ await registry.getFrame("idle-2", 0, 0);
+ expect(registry.devices.size).toBe(2);
+
+ now = 50;
+ await registry.getFrame("active", 0, 0);
+ expect(registry.devices.has("idle-1")).toBe(true);
+
+ now = 200;
+ await registry.getFrame("active", 0, 0);
+ expect(registry.devices.has("idle-1")).toBe(false);
+ expect(registry.devices.has("idle-2")).toBe(false);
+ expect(registry.devices.has("active")).toBe(true);
+ });
});
diff --git a/remote-render/src/state.ts b/remote-render/src/state.ts
index 2de7635..cdf06f1 100644
--- a/remote-render/src/state.ts
+++ b/remote-render/src/state.ts
@@ -2,7 +2,10 @@ import {encodeFrame} from "./protocol.js";
import {
SCREEN_HEIGHT,
SCREEN_WIDTH,
- HOME_GAME_REGION,
+ FORECAST_REGION,
+ GAME_AREA_REGION,
+ GAME_TIME_REGION,
+ HEADER_REGION,
TIME_REGION,
type CanvasImage,
type RectTuple,
@@ -20,10 +23,10 @@ import {
isAnimationActive,
} from "./ui-state.js";
import {
+ HOME_GAME_KINDS,
createHomeGameRuntime,
advanceHomeGameRuntime,
homeGameRuntimeToViewModel,
- switchHomeGameRuntime,
type HomeGameRuntime,
} from "./renderer/services/home-game-state.js";
@@ -45,6 +48,8 @@ export interface FrameResult {
export class DeviceState {
frameId = 0;
+ // 最近一次被访问的单调时刻(秒),用于淘汰长时间不活跃的设备条目。
+ lastTouchedAt = 0;
buttonCount = 0;
lastInputSeq = 0;
lastInputUptimeMs = -1;
@@ -54,9 +59,15 @@ export class DeviceState {
lastClockAnimationFrameAt = -1;
lastClockAnimationCleanupSecond = -1;
lastHomeGameFrameAt = -1;
+ // 游戏轮播:当前展示的游戏运行时 + 本局开始的单调时刻(用于停留时长自动切下一个)。
homeGame: HomeGameRuntime | null = null;
+ gameShownAt = -1;
frame: Buffer = Buffer.alloc(0);
- fullFrame: Buffer = Buffer.alloc(0);
+ // 全屏帧只有冷启动 / 重同步客户端才会用到。不再在每个 partial 帧里重新编码
+ // 整屏,改为惰性计算:partial 渲染时把缓存置空,真正有客户端要全屏帧时再从
+ // 当前 canvas 编码一次(同一 frameId 复用),其余帧省下整屏
+ // RGBA->RGB565->RLE->CRC 的开销。
+ fullFrameCache: Buffer | null = null;
latestBaseFrameId = 0;
latestFullFrame = true;
canvas: CanvasImage | null = null;
@@ -65,6 +76,18 @@ export class DeviceState {
latestCommand: QueuedCommand | null = null;
constructor(public deviceId: string) {}
+
+ get fullFrame(): Buffer {
+ if (this.fullFrameCache === null) {
+ this.fullFrameCache =
+ this.canvas === null
+ ? Buffer.alloc(0)
+ : encodeRenderedFrame(
+ renderCanvasFrame(this.canvas, {frameId: this.frameId, baseFrameId: 0, fullFrame: true}),
+ );
+ }
+ return this.fullFrameCache;
+ }
}
export interface RecordStatusInput {
@@ -82,7 +105,10 @@ interface DeviceRegistryOptions {
animationFrameIntervalSeconds?: number;
clockFlipAnimationSeconds?: number;
homeGameFrameIntervalSeconds?: number;
+ gameShowDwellSeconds?: number;
now?: () => Date;
+ deviceIdleTtlSeconds?: number;
+ evictionSweepIntervalSeconds?: number;
}
export class DeviceRegistry {
@@ -92,7 +118,11 @@ export class DeviceRegistry {
private animationFrameIntervalSeconds: number;
private clockFlipAnimationSeconds: number;
private homeGameFrameIntervalSeconds: number;
+ private gameShowDwellSeconds: number;
private now: () => Date;
+ private deviceIdleTtlSeconds: number;
+ private evictionSweepIntervalSeconds: number;
+ private lastEvictionSweepAt = -Infinity;
constructor(options: DeviceRegistryOptions = {}) {
this.monotonic = options.monotonic ?? (() => performance.now() / 1000);
@@ -100,7 +130,13 @@ export class DeviceRegistry {
this.animationFrameIntervalSeconds = options.animationFrameIntervalSeconds ?? 1 / 20;
this.clockFlipAnimationSeconds = options.clockFlipAnimationSeconds ?? 0.3;
this.homeGameFrameIntervalSeconds = options.homeGameFrameIntervalSeconds ?? 1;
+ // 游戏轮播:每个游戏停留时长,到点自动切下一个;播完回到安静首页。
+ this.gameShowDwellSeconds = options.gameShowDwellSeconds ?? 20;
this.now = options.now ?? (() => new Date());
+ // 默认 1 小时不活跃即淘汰,最多每 60s 扫描一次,避免任意 / 预览 device id
+ // 让 devices Map 无限增长。回来的真实设备会因 have>frameId 自动收到全屏帧重同步。
+ this.deviceIdleTtlSeconds = options.deviceIdleTtlSeconds ?? 3600;
+ this.evictionSweepIntervalSeconds = options.evictionSweepIntervalSeconds ?? 60;
}
async getFrame(deviceId: string, have: number, waitMs: number): Promise {
@@ -142,24 +178,41 @@ export class DeviceRegistry {
if (!this.shouldAcceptInput(state, seq, uptimeMs)) {
return false;
}
+ const now = this.monotonic();
const previousPage = state.ui.page;
state.lastInputSeq = seq;
state.lastInputUptimeMs = uptimeMs;
state.buttonCount += 1;
- const commands = applyInputEvent(state.ui, event, this.monotonic());
+ const commands = applyInputEvent(state.ui, event, now);
for (const command of commands) {
this.queueCommand(state, command);
}
- if (previousPage === "home" && state.ui.page === "home" && event === "double_press") {
- this.render(state, true);
+
+ // 游戏轮播页:进入(首页单击)/ 切下一个(单击)/ 播完回首页
+ if (state.ui.page === "game") {
+ if (state.ui.gameIndex >= HOME_GAME_KINDS.length) {
+ this.endGameShow(state);
+ this.render(state, true);
+ } else {
+ this.startGameShow(state, now);
+ }
+ return true;
+ }
+
+ // 离开游戏轮播(双击回首页 / 长按进设置):清理游戏运行时后整屏 / 动画切换
+ if (previousPage === "game") {
+ state.homeGame = null;
+ state.gameShownAt = -1;
+ this.render(state, !state.ui.animation);
return true;
}
- if (previousPage === "home" && state.ui.page === "home" && event === "short_press" && !state.ui.animation) {
- state.homeGame = switchHomeGameRuntime(this.ensureHomeGame(state), this.monotonic());
- state.lastHomeGameFrameAt = this.monotonic();
- this.render(state, false, [], 1, [HOME_GAME_REGION]);
+
+ // 首页双击 = 强制整屏刷新
+ if (previousPage === "home" && state.ui.page === "home" && event === "double_press") {
+ this.render(state, true);
return true;
}
+ // 首页无可见变化(short_press 已进游戏页、long_press 已进设置)
if (previousPage === "home" && state.ui.page === "home" && !state.ui.animation) {
return true;
}
@@ -189,15 +242,32 @@ export class DeviceRegistry {
}
private ensureDevice(deviceId: string): DeviceState {
+ const now = this.monotonic();
let state = this.devices.get(deviceId);
if (!state) {
state = new DeviceState(deviceId);
this.render(state, true);
this.devices.set(deviceId, state);
}
+ state.lastTouchedAt = now;
+ this.evictIdleDevices(now);
return state;
}
+ // 节流扫描:每 evictionSweepIntervalSeconds 最多一次,删除超过 TTL 未访问的设备。
+ // 当前正在访问的设备刚刷新过 lastTouchedAt,不会被误删。
+ private evictIdleDevices(now: number): void {
+ if (now - this.lastEvictionSweepAt < this.evictionSweepIntervalSeconds) {
+ return;
+ }
+ this.lastEvictionSweepAt = now;
+ for (const [deviceId, state] of this.devices) {
+ if (now - state.lastTouchedAt > this.deviceIdleTtlSeconds) {
+ this.devices.delete(deviceId);
+ }
+ }
+ }
+
private renderIfDue(state: DeviceState): number {
const now = this.monotonic();
if (isAnimationActive(state.ui, now)) {
@@ -211,6 +281,11 @@ export class DeviceRegistry {
state.lastAnimationFrameAt = -1;
return this.render(state, true);
}
+ // 游戏轮播页:推进当前游戏动画,停留到点自动切下一个,播完回安静首页。
+ if (state.ui.page === "game") {
+ return this.renderGameShowIfDue(state, now);
+ }
+
const currentSecond = Math.floor(now / this.frameIntervalSeconds);
if (state.ui.page === "home" && currentSecond === state.lastClockAnimationSecond) {
const elapsed = now - currentSecond * this.frameIntervalSeconds;
@@ -224,29 +299,39 @@ export class DeviceRegistry {
}
}
if (currentSecond <= state.lastRenderSecond) {
- if (state.ui.page === "home") {
- const advanced = this.advanceHomeGameIfDue(state, now);
- if (advanced) {
- return this.render(state, false, advanced.status === "playing" ? [HOME_GAME_REGION] : [], 1, [HOME_GAME_REGION]);
- }
- }
return 0;
}
if (state.ui.page === "home") {
state.lastClockAnimationSecond = currentSecond;
state.lastClockAnimationFrameAt = now;
- const advanced = this.advanceHomeGameIfDue(state, now);
- return this.render(
- state,
- false,
- advanced ? [TIME_REGION, HOME_GAME_REGION] : [TIME_REGION],
- 0,
- advanced && advanced.status !== "playing" ? [HOME_GAME_REGION] : undefined,
- );
+ // 安静首页每秒刷新:顶部(日期+当前天气)、时钟带、下方 12h 预报;无游戏动画。
+ return this.render(state, false, [HEADER_REGION, TIME_REGION, FORECAST_REGION]);
}
return this.render(state, false, [TIME_REGION]);
}
+ // 游戏轮播页的逐帧推进与停留到点切换。
+ private renderGameShowIfDue(state: DeviceState, now: number): number {
+ if (state.gameShownAt >= 0 && now - state.gameShownAt >= this.gameShowDwellSeconds) {
+ state.ui.gameIndex += 1;
+ if (state.ui.gameIndex >= HOME_GAME_KINDS.length) {
+ this.endGameShow(state);
+ return this.render(state, true);
+ }
+ state.homeGame = createHomeGameRuntime(HOME_GAME_KINDS[state.ui.gameIndex], state.ui.gameIndex, now);
+ state.gameShownAt = now;
+ state.lastHomeGameFrameAt = now;
+ return this.render(state, true);
+ }
+ if (state.homeGame && (state.lastHomeGameFrameAt < 0 || now - state.lastHomeGameFrameAt >= this.homeGameFrameIntervalSeconds)) {
+ const advanced = advanceHomeGameRuntime(state.homeGame, now);
+ state.homeGame = advanced.runtime;
+ state.lastHomeGameFrameAt = now;
+ return this.render(state, false, [GAME_TIME_REGION, GAME_AREA_REGION]);
+ }
+ return 0;
+ }
+
private selectFrameForClient(state: DeviceState, have: number): Buffer | null {
if (have === 0 || have > state.frameId) {
return state.fullFrame;
@@ -273,14 +358,14 @@ export class DeviceRegistry {
clockFlipProgress?: number,
forcedRegions?: RectTuple[],
): number {
- const started = this.monotonic();
const now = this.monotonic();
- const homeGame = state.ui.page === "home" ? this.ensureHomeGame(state, now) : undefined;
- if (state.ui.page !== "home") {
+ const started = now;
+ // 仅游戏轮播页携带游戏;首页/设置/详情无游戏。离开游戏页时清理运行时。
+ const showGame = state.ui.page === "game" ? state.homeGame : null;
+ if (state.ui.page !== "game") {
state.homeGame = null;
state.lastHomeGameFrameAt = -1;
- } else if (fullFrame || state.lastHomeGameFrameAt < 0) {
- state.lastHomeGameFrameAt = now;
+ state.gameShownAt = -1;
}
const baseFrameId = state.frameId;
state.frameId += 1;
@@ -296,7 +381,7 @@ export class DeviceRegistry {
uiState: state.ui,
animationProgress: currentAnimationProgress(state.ui, now),
clockFlipProgress,
- homeGame: homeGame ? homeGameRuntimeToViewModel(homeGame) : undefined,
+ homeGame: showGame ? homeGameRuntimeToViewModel(showGame) : undefined,
});
let rendered: RenderedFrame;
if (fullFrame || state.canvas === null) {
@@ -321,28 +406,26 @@ export class DeviceRegistry {
state.latestBaseFrameId = rendered.baseFrameId;
state.latestFullFrame = rendered.fullFrame;
state.canvas = currentCanvas;
- state.fullFrame = fullFrame
- ? state.frame
- : encodeRenderedFrame(renderCanvasFrame(currentCanvas, {frameId: state.frameId, baseFrameId: 0, fullFrame: true}));
+ // 全屏渲染时 state.frame 本身就是整屏帧,直接缓存;partial 渲染则置空,
+ // 等真正有冷启动 / 重同步客户端请求时再由 get fullFrame 惰性编码。
+ state.fullFrameCache = fullFrame ? state.frame : null;
return Math.max(0, this.monotonic() - started);
}
- private ensureHomeGame(state: DeviceState, now = this.monotonic()): HomeGameRuntime {
- if (!state.homeGame) {
- state.homeGame = createHomeGameRuntime("snake", 0, now);
- }
- return state.homeGame;
+ // 进入 / 切到 gameIndex 指向的游戏,整屏切换。
+ private startGameShow(state: DeviceState, now: number): void {
+ state.homeGame = createHomeGameRuntime(HOME_GAME_KINDS[state.ui.gameIndex % HOME_GAME_KINDS.length], state.ui.gameIndex, now);
+ state.gameShownAt = now;
+ state.lastHomeGameFrameAt = now;
+ this.render(state, true);
}
- private advanceHomeGameIfDue(state: DeviceState, now: number): {status: string} | null {
- const homeGame = this.ensureHomeGame(state, now);
- if (state.lastHomeGameFrameAt >= 0 && now - state.lastHomeGameFrameAt < this.homeGameFrameIntervalSeconds) {
- return null;
- }
- const advanced = advanceHomeGameRuntime(homeGame, now);
- state.homeGame = advanced.runtime;
- state.lastHomeGameFrameAt = now;
- return {status: advanced.status};
+ // 轮播播完:回到安静首页。
+ private endGameShow(state: DeviceState): void {
+ state.ui.page = "home";
+ state.ui.gameIndex = 0;
+ state.homeGame = null;
+ state.gameShownAt = -1;
}
private queueCommand(state: DeviceState, command: DeviceCommand): void {
diff --git a/remote-render/src/ui-state.test.ts b/remote-render/src/ui-state.test.ts
index b652dfa..26af68e 100644
--- a/remote-render/src/ui-state.test.ts
+++ b/remote-render/src/ui-state.test.ts
@@ -14,14 +14,25 @@ describe("device UI state", () => {
expect(currentAnimationProgress(state, 10)).toBe(0);
});
- test("short press on home has no visible state change", () => {
+ test("short press on home enters the game show at the first game", () => {
const state = new DeviceUiState();
const commands = applyInputEvent(state, "short_press", 10);
expect(commands).toEqual([]);
+ expect(state.page).toBe("game");
+ expect(state.gameIndex).toBe(0);
+ });
+
+ test("short press in the game show advances to the next game", () => {
+ const state = new DeviceUiState({page: "game", gameIndex: 0});
+
+ applyInputEvent(state, "short_press", 10);
+ expect(state.page).toBe("game");
+ expect(state.gameIndex).toBe(1);
+
+ applyInputEvent(state, "double_press", 20);
expect(state.page).toBe("home");
- expect(state.animation).toBe("");
});
test("brightness detail queues a set brightness command", () => {
diff --git a/remote-render/src/ui-state.ts b/remote-render/src/ui-state.ts
index 518f28c..baf05cb 100644
--- a/remote-render/src/ui-state.ts
+++ b/remote-render/src/ui-state.ts
@@ -1,4 +1,4 @@
-export type PageName = "home" | "settings" | "detail";
+export type PageName = "home" | "game" | "settings" | "detail";
export type InputEventName = "short_press" | "double_press" | "long_press";
export const FONT_WENKAI_SCREEN = "lxgw_wenkai_screen";
@@ -10,9 +10,21 @@ export const FONT_LABELS: Record = {
[FONT_MAPLE_MONO_NF_CN]: "Maple",
[FONT_NOTO_CJK]: "Noto",
};
-export const SETTINGS_ITEMS = ["Brightness", "Font", "Device", "Renderer", "About"] as const;
+export const SETTINGS_ITEMS = ["Brightness", "Font", "Device", "Renderer", "About", "Theme"] as const;
export const BRIGHTNESS_OPTIONS = [20, 40, 50, 60, 80, 100] as const;
+export const THEME_MIDNIGHT = "midnight";
+export const THEME_SAKURA = "sakura";
+export const THEME_AMBER = "amber";
+export const THEME_MONO = "mono";
+export const THEME_OPTIONS = [THEME_MIDNIGHT, THEME_SAKURA, THEME_AMBER, THEME_MONO] as const;
+export const THEME_LABELS: Record = {
+ [THEME_MIDNIGHT]: "Midnight",
+ [THEME_SAKURA]: "Sakura",
+ [THEME_AMBER]: "Amber",
+ [THEME_MONO]: "Mono",
+};
+
export class DeviceCommand {
constructor(
public type: string,
@@ -33,10 +45,13 @@ export interface DeviceUiStateInit {
page?: PageName;
selectedIndex?: number;
detailIndex?: number;
+ gameIndex?: number;
brightness?: number;
pendingBrightness?: number;
fontKey?: string;
pendingFontKey?: string;
+ themeKey?: string;
+ pendingThemeKey?: string;
animation?: string;
animationStartedAt?: number;
animationDuration?: number;
@@ -46,10 +61,13 @@ export class DeviceUiState {
page: PageName = "home";
selectedIndex = 0;
detailIndex = 0;
+ gameIndex = 0;
brightness = 50;
pendingBrightness = 50;
fontKey = FONT_WENKAI_SCREEN;
pendingFontKey = FONT_WENKAI_SCREEN;
+ themeKey = THEME_MIDNIGHT;
+ pendingThemeKey = THEME_MIDNIGHT;
diagnostics = new DeviceDiagnostics();
animation = "";
animationStartedAt = 0;
@@ -66,6 +84,24 @@ export function applyInputEvent(state: DeviceUiState, event: InputEventName, now
state.page = "settings";
state.selectedIndex = 0;
startAnimation(state, "enter_settings", now);
+ } else if (event === "short_press") {
+ // 单击进入游戏轮播:从第一个游戏开始(首页本身保持安静,不跑游戏)。
+ state.page = "game";
+ state.gameIndex = 0;
+ }
+ return [];
+ }
+
+ if (state.page === "game") {
+ // 游戏轮播页:单击切下一个,播完由 state 层判定回首页;双击直接回首页;长按进设置。
+ if (event === "short_press") {
+ state.gameIndex += 1;
+ } else if (event === "double_press") {
+ state.page = "home";
+ } else if (event === "long_press") {
+ state.page = "settings";
+ state.selectedIndex = 0;
+ startAnimation(state, "enter_settings", now);
}
return [];
}
@@ -81,6 +117,8 @@ export function applyInputEvent(state: DeviceUiState, event: InputEventName, now
state.pendingBrightness = state.brightness;
} else if (isFontDetail(state)) {
state.pendingFontKey = state.fontKey;
+ } else if (isThemeDetail(state)) {
+ state.pendingThemeKey = state.themeKey;
}
startAnimation(state, "enter_detail", now);
} else if (event === "double_press") {
@@ -109,13 +147,13 @@ export function applyInputEvent(state: DeviceUiState, event: InputEventName, now
}
if (isFontDetail(state)) {
+ // 字体切换本身会改变页面文字(一次性可见),但没有任何动画消费 font_select /
+ // font_applied,故不再启动空动画,避免 0.32s 内 20fps 的无效重渲染。
if (event === "short_press") {
state.pendingFontKey = nextFontKey(state.pendingFontKey);
state.fontKey = state.pendingFontKey;
- startAnimation(state, "font_select", now);
} else if (event === "long_press") {
state.fontKey = state.pendingFontKey;
- startAnimation(state, "font_applied", now);
} else if (event === "double_press") {
state.pendingFontKey = state.fontKey;
state.page = "settings";
@@ -124,9 +162,23 @@ export function applyInputEvent(state: DeviceUiState, event: InputEventName, now
return [];
}
- if (event === "short_press") {
- startAnimation(state, "detail_pulse", now);
- } else {
+ if (isThemeDetail(state)) {
+ if (event === "short_press") {
+ state.pendingThemeKey = nextThemeKey(state.pendingThemeKey);
+ state.themeKey = state.pendingThemeKey;
+ } else if (event === "long_press") {
+ state.themeKey = state.pendingThemeKey;
+ } else if (event === "double_press") {
+ state.pendingThemeKey = state.themeKey;
+ state.page = "settings";
+ startAnimation(state, "back_to_settings", now);
+ }
+ return [];
+ }
+
+ // 只读详情页(Device/Renderer/About/Weather):short_press 无可见效果,不再触发
+ // detail_pulse 空动画;long_press / double_press 返回设置页。
+ if (event !== "short_press") {
state.page = "settings";
startAnimation(state, "back_to_settings", now);
}
@@ -166,6 +218,10 @@ function isFontDetail(state: DeviceUiState): boolean {
return SETTINGS_ITEMS[state.detailIndex % SETTINGS_ITEMS.length] === "Font";
}
+function isThemeDetail(state: DeviceUiState): boolean {
+ return SETTINGS_ITEMS[state.detailIndex % SETTINGS_ITEMS.length] === "Theme";
+}
+
function nextBrightnessValue(value: number): number {
for (const option of BRIGHTNESS_OPTIONS) {
if (option > value) return option;
@@ -173,8 +229,14 @@ function nextBrightnessValue(value: number): number {
return BRIGHTNESS_OPTIONS[0];
}
-function nextFontKey(value: string): string {
+export function nextFontKey(value: string): string {
const index = FONT_OPTIONS.findIndex((option) => option === value);
if (index < 0) return FONT_OPTIONS[0];
return FONT_OPTIONS[(index + 1) % FONT_OPTIONS.length];
}
+
+export function nextThemeKey(value: string): string {
+ const index = THEME_OPTIONS.findIndex((option) => option === value);
+ if (index < 0) return THEME_OPTIONS[0];
+ return THEME_OPTIONS[(index + 1) % THEME_OPTIONS.length];
+}
diff --git a/remote-render/tsconfig.typecheck.json b/remote-render/tsconfig.typecheck.json
new file mode 100644
index 0000000..5621ae4
--- /dev/null
+++ b/remote-render/tsconfig.typecheck.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": []
+}
diff --git a/src/Net.cpp b/src/Net.cpp
index 47f54a0..2dba8c8 100644
--- a/src/Net.cpp
+++ b/src/Net.cpp
@@ -61,14 +61,10 @@ bool portalUsesAccessPoint()
return s_portalMode == ConfigPortalMode::AccessPoint;
}
+// 仅在 WiFi 已连接后调用,把加载进度条补满到底。每步 drawLoading(1, 1) 自带
+// delay(1) 喂狗,约 194ms 动画。
void loadingUntilConnected()
{
- uint8_t step = 1;
- while (WiFi.status() != WL_CONNECTED)
- {
- display::drawLoading(30, step);
- step = 1;
- }
for (int index = 0; index < 194; ++index)
{
display::drawLoading(1, 1);
diff --git a/src/main.cpp b/src/main.cpp
index 62b54a5..8fce0a2 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -32,6 +32,7 @@ uint32_t g_lastCommandPollMs = 0;
uint32_t g_lastStatusSyncMs = 0;
uint32_t g_lastErrorDrawMs = 0;
bool g_statusSyncPending = true;
+bool g_frameErrorShown = false;
app::HoldInteractionState g_holdInteraction;
uint32_t g_holdStartedMs = 0;
uint16_t g_holdLastPixels = UINT16_MAX;
@@ -206,6 +207,18 @@ void processButtonEvents(uint32_t nowMs)
}
}
+// 离线提示画的是整屏 banner。服务端恢复后,如果设备已经持有最新帧,轮询只会
+// 拿到 204 或零矩形的 partial 帧,都不会覆盖 banner。这里在恢复后的第一次成功
+// 轮询把 have 归零,强制下次请求整屏帧覆盖掉离线提示(仅恢复时多取一帧)。
+void clearFrameErrorIfRecovered()
+{
+ if (g_frameErrorShown)
+ {
+ g_frameErrorShown = false;
+ g_haveFrameId = 0;
+ }
+}
+
bool pollFrame(uint32_t nowMs)
{
if (nowMs - g_lastFramePollMs < app_config::kRemoteFramePollMs)
@@ -222,16 +235,19 @@ bool pollFrame(uint32_t nowMs)
if (result == remote::FrameFetchResult::Updated)
{
g_haveFrameId = nextFrameId;
+ clearFrameErrorIfRecovered();
return true;
}
if (result == remote::FrameFetchResult::NotModified)
{
+ clearFrameErrorIfRecovered();
return true;
}
if (result == remote::FrameFetchResult::Failed && nowMs - g_lastErrorDrawMs > 3000U)
{
g_lastErrorDrawMs = nowMs;
+ g_frameErrorShown = true;
const std::string ipLine = currentDeviceIpStatusLine();
drawStatus("Render server offline", g_config.remoteBaseUrl.c_str(), ipLine.c_str());
}
diff --git a/src/remote/HttpFrameClient.cpp b/src/remote/HttpFrameClient.cpp
index 080edc8..af9421f 100644
--- a/src/remote/HttpFrameClient.cpp
+++ b/src/remote/HttpFrameClient.cpp
@@ -2,7 +2,9 @@
#include
#include
+#include
#include
+#include
#include
#include "AppConfig.h"
@@ -14,16 +16,6 @@ namespace remote
namespace
{
-String joinUrl(const String &baseUrl, const String &path)
-{
- String normalized = baseUrl;
- while (normalized.endsWith("/"))
- {
- normalized.remove(normalized.length() - 1);
- }
- return normalized + path;
-}
-
bool rectFitsFrame(const FrameHeader &frame, const RectHeader &rect)
{
return rect.x + rect.width <= frame.width && rect.y + rect.height <= frame.height;
@@ -42,7 +34,8 @@ uint32_t parseHeaderMs(const String &value)
{
return 0;
}
- return parsed > UINT32_MAX ? UINT32_MAX : static_cast(parsed);
+ // 仅用于诊断日志。strtoul 溢出时已返回 ULONG_MAX,在 ESP8266 上即 UINT32_MAX。
+ return static_cast(parsed);
}
} // namespace
@@ -65,8 +58,18 @@ FrameFetchResult HttpFrameClient::fetchLatest(const String &baseUrl, const Strin
app::FrameDiagnostics diagnostics;
const uint32_t requestStartedMs = millis();
- const String url = joinUrl(baseUrl, "/api/v1/devices/" + deviceId + "/frame?have=" + String(haveFrameId) +
- "&wait_ms=" + String(waitMs));
+ // 去掉 baseUrl 末尾多余的 '/' 后用定长缓冲拼出请求 URL,避免每次轮询(最快 20Hz)
+ // 都产生多个 Arduino String 临时对象,缓解 ~40-50KB 堆的碎片化。
+ char baseTrimmed[100];
+ snprintf(baseTrimmed, sizeof(baseTrimmed), "%s", baseUrl.c_str());
+ size_t baseLen = strlen(baseTrimmed);
+ while (baseLen > 0 && baseTrimmed[baseLen - 1] == '/')
+ {
+ baseTrimmed[--baseLen] = '\0';
+ }
+ char url[200];
+ snprintf(url, sizeof(url), "%s/api/v1/devices/%s/frame?have=%lu&wait_ms=%lu", baseTrimmed, deviceId.c_str(),
+ static_cast(haveFrameId), static_cast(waitMs));
const uint32_t beginStartedMs = millis();
if (!http_.begin(client_, url))
{
@@ -85,9 +88,8 @@ FrameFetchResult HttpFrameClient::fetchLatest(const String &baseUrl, const Strin
const uint32_t getStartedMs = millis();
const int statusCode = http_.GET();
diagnostics.getMs = millis() - getStartedMs;
- diagnostics.serverWaitMs = parseHeaderMs(http_.header("X-SDD-Server-Wait-Ms"));
- diagnostics.serverRenderMs = parseHeaderMs(http_.header("X-SDD-Server-Render-Ms"));
- diagnostics.serverTotalMs = parseHeaderMs(http_.header("X-SDD-Server-Total-Ms"));
+ // 计时响应头只在确实要打印诊断时才读取/解析,避免每次轮询多产生 3 个
+ // Arduino String 返回值;详见下方 shouldLogFrameDiagnostics 块。
if (statusCode == HTTP_CODE_NO_CONTENT)
{
keepAlivePolicy_.rememberSuccessfulRequest(baseUrl.c_str());
@@ -132,6 +134,9 @@ FrameFetchResult HttpFrameClient::fetchLatest(const String &baseUrl, const Strin
diagnostics.totalMs = millis() - requestStartedMs;
if (app::shouldLogFrameDiagnostics(header.fullFrame, header.payloadLength, header.rectCount))
{
+ diagnostics.serverWaitMs = parseHeaderMs(http_.header("X-SDD-Server-Wait-Ms"));
+ diagnostics.serverRenderMs = parseHeaderMs(http_.header("X-SDD-Server-Render-Ms"));
+ diagnostics.serverTotalMs = parseHeaderMs(http_.header("X-SDD-Server-Total-Ms"));
Serial.printf(
"[RemoteFrame] frame=%lu %s rects=%u payload=%lu begin_ms=%lu get_ms=%lu header_ms=%lu "
"srv_wait_ms=%lu srv_render_ms=%lu srv_total_ms=%lu client_overhead_ms=%lu read_ms=%lu "