Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions docs/recent-iterations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 32 additions & 4 deletions docs/remote-rendering-http-frame-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions remote-render/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions remote-render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions remote-render/src/main.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
14 changes: 10 additions & 4 deletions remote-render/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion remote-render/src/renderer.structure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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", () => {
Expand Down
22 changes: 14 additions & 8 deletions remote-render/src/renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@
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", () => {
Expand Down Expand Up @@ -59,47 +60,52 @@
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);
Expand Down Expand Up @@ -198,6 +204,6 @@
uiState: new DeviceUiState({fontKey: FONT_MAPLE_MONO_NF_CN}),
});

expect(Buffer.compare(wenkai.rgba, maple.rgba)).not.toBe(0);

Check failure on line 207 in remote-render/src/renderer.test.tsx

View workflow job for this annotation

GitHub Actions / remote-render (Node)

src/renderer.test.tsx > React remote renderer > font key changes the rendered text pixels

AssertionError: expected +0 not to be +0 // Object.is equality ❯ src/renderer.test.tsx:207:57
});
});
4 changes: 2 additions & 2 deletions remote-render/src/renderer/components/frame-background.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {Box} from "./primitives.js";

export function FrameBackground() {
export function FrameBackground({background = "#060a0d"}: {background?: string}) {
return (
<>
<Box style={{x: 8, y: 8, width: 224, height: 224, borderRadius: 14, backgroundColor: "#060a0d", borderColor: "#2a3a3e", borderWidth: 2}} />
<Box style={{x: 8, y: 8, width: 224, height: 224, borderRadius: 14, backgroundColor: background, borderColor: "#2a3a3e", borderWidth: 2}} />
<Box style={{x: 16, y: 16, width: 208, height: 208, borderRadius: 11, borderColor: "#101f22", borderWidth: 1}} />
<Box style={{x: 32, y: 55, width: 176, height: 1, backgroundColor: "#142627"}} />
<Box style={{x: 42, y: 132, width: 156, height: 1, backgroundColor: "#122224"}} />
Expand Down
Loading
Loading