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 "